File size: 14,155 Bytes
67d18ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 | /**
* Localhost-only HTTP control listener for the Android app's Kotlin shell.
*
* The Kotlin foreground service starts the Node.js proxy server and a small
* control listener on a separate port (passed via env `ZCODE_CONTROL_PORT`).
* The proxy listener serves `/v1/*`, `/webui`, `/health`; the control listener
* serves only `POST /control` and is bound to `127.0.0.1` so other devices on
* the LAN cannot reach it. Two layers enforce loopback-only access:
*
* 1. `server.listen(port, "127.0.0.1", ...)` — never binds to `0.0.0.0`.
* 2. Per-request `req.socket.remoteAddress` check — defends against a future
* bind regression where the listener accidentally widens.
*
* The listener exposes a JSON command protocol so Kotlin can drive OAuth
* (via embedded WebView), start/stop the proxy server, update runtime config
* (provider/plan), poll logs, and shut down the Node process.
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
import type { ProviderId } from "../provider/types.js";
import type { Credential } from "../auth/types.js";
import {
ZaiOAuthClient,
BigmodelOAuthClient,
AuthCodeOAuthClient,
type OAuthFlowClient,
} from "../auth/oauth.js";
import { KeyResolver } from "../auth/resolver.js";
import { saveCredential, clearCredential, loadCredential } from "../auth/store.js";
/** Supported plan tiers. Mirrors `ProxyConfig.plan`. */
export type PlanTier = "coding-plan" | "start-plan";
/** The control protocol: request shape for `POST /control`. */
export type ControlCommand =
| { cmd: "status" }
| { cmd: "startOAuth"; provider: ProviderId }
| { cmd: "deliverOAuthCode"; provider: ProviderId; code: string; state: string }
| { cmd: "logout" }
| { cmd: "setConfig"; provider?: ProviderId; plan?: PlanTier }
| { cmd: "startProxy" }
| { cmd: "stopProxy" }
| { cmd: "getLogs"; since?: number }
| { cmd: "shutdown" };
/** Successful response envelope. */
export type ControlOk =
| { ok: true; state: "running"; provider: ProviderId; plan: PlanTier; proxyPort: number; loggedIn: boolean }
| { ok: true; event: "oauthUrl"; authorizeUrl: string; callbackPort: number }
| { ok: true; event: "loginOk"; provider: ProviderId }
| { ok: true; event: "loggedOut" }
| { ok: true; event: "configUpdated"; provider: ProviderId; plan: PlanTier }
| { ok: true; event: "proxyStarted"; port: number }
| { ok: true; event: "proxyStopped" }
| { ok: true; event: "logs"; nextSince: number; lines: string[] }
| { ok: true; event: "shuttingDown" };
/** Failure response envelope. */
export interface ControlError {
ok: false;
error: string;
}
export type ControlResponse = ControlOk | ControlError;
/** Result type returned by lifecycle hooks (start/stop proxy). */
export type LifecycleResult =
| { ok: true; port: number }
| { ok: false; error: string };
/** Result type returned by `setConfig` hook. */
export type ConfigUpdateResult =
| { ok: true; provider: ProviderId; plan: PlanTier }
| { ok: false; error: string };
/** Internal mutable state shared with the proxy entry. */
export interface ControlState {
provider: ProviderId;
plan: PlanTier;
/** Currently-bound proxy server port. 0 when proxy is stopped. */
proxyPort: number;
/** Active OAuth client while a flow is in flight; nulled on completion. */
activeOauth?: {
client: OAuthFlowClient;
callbackUrl: string;
state: string;
};
}
interface StartControlOpts {
port: number;
state: ControlState;
/** Start the proxy server. Returns the bound port on success. */
onStartProxy?: () => Promise<LifecycleResult>;
/** Stop the proxy server. */
onStopProxy?: () => Promise<{ ok: true } | { ok: false; error: string }>;
/** Update runtime config (provider and/or plan). */
onSetConfig?: (changes: { provider?: ProviderId; plan?: PlanTier }) => Promise<ConfigUpdateResult>;
/** Hook for graceful shutdown (called by the `shutdown` command). */
onShutdown?: () => Promise<void> | void;
/** Log buffer polled by `getLogs`. If omitted, an internal one is used. */
logBuffer?: LogBuffer;
}
/** Bounded ring buffer for runtime log lines with monotonic sequence numbers. */
export class LogBuffer {
private readonly lines: string[] = [];
private readonly capacity: number;
private nextSeq = 0;
constructor(capacity = 500) {
this.capacity = capacity;
}
push(line: string): void {
this.lines.push(line);
this.nextSeq++;
if (this.lines.length > this.capacity) {
this.lines.splice(0, this.lines.length - this.capacity);
}
}
/**
* Returns lines whose logical sequence number is `>= since`, plus the
* next-since cursor (use as the next `since` value for incremental polling).
*/
since(since: number): { nextSince: number; lines: string[] } {
const baseSeq = Math.max(0, this.nextSeq - this.lines.length);
const wantStart = Math.max(since, baseSeq);
const offset = wantStart - baseSeq;
if (offset >= this.lines.length) {
return { nextSince: this.nextSeq, lines: [] };
}
return { nextSince: this.nextSeq, lines: this.lines.slice(offset) };
}
/** Returns all lines currently in the buffer. */
snapshot(): readonly string[] {
return this.lines;
}
/** Monotonic cursor; safe to expose externally. */
get cursor(): number {
return this.nextSeq;
}
}
/** Start the control listener bound to 127.0.0.1. Resolves once listening. */
export function startControlListener(opts: StartControlOpts): Promise<{ close(): Promise<void> }> {
const logBuffer = opts.logBuffer ?? new LogBuffer();
const server: Server = createServer(async (req, res) => {
try {
const result = await handleControlRequest(req, opts.state, {
onStartProxy: opts.onStartProxy,
onStopProxy: opts.onStopProxy,
onSetConfig: opts.onSetConfig,
onShutdown: opts.onShutdown,
logBuffer,
});
writeJson(res, result.status, result.body);
} catch (err) {
writeJson(res, 500, { ok: false, error: `internal_error: ${(err as Error).message}` });
}
});
return new Promise((resolve, reject) => {
server.on("error", reject);
server.listen(opts.port, "127.0.0.1", () => resolve({
close: () => new Promise<void>((r) => server.close(() => r())),
}));
});
}
export interface ControlHandlerResult {
status: number;
body: ControlResponse;
}
/** Context passed to `handleControlRequest` for hook wiring + log access. */
export interface HandlerContext {
onStartProxy?: () => Promise<LifecycleResult>;
onStopProxy?: () => Promise<{ ok: true } | { ok: false; error: string }>;
onSetConfig?: (changes: { provider?: ProviderId; plan?: PlanTier }) => Promise<ConfigUpdateResult>;
onShutdown?: () => Promise<void> | void;
logBuffer: LogBuffer;
}
export function handleControlRequestForTest(
req: IncomingMessage,
state: ControlState,
onShutdown?: () => Promise<void> | void,
): Promise<ControlHandlerResult> {
// Backwards-compatible shape: only `onShutdown` is wired.
const ctx: HandlerContext = { onShutdown, logBuffer: new LogBuffer() };
return handleControlRequest(req, state, ctx);
}
/**
* Test entry that allows wiring all lifecycle hooks. Prefer this in new tests
* for startProxy/stopProxy/setConfig/getLogs coverage.
*/
export function handleControlRequestWithHooksForTest(
req: IncomingMessage,
state: ControlState,
ctx: HandlerContext,
): Promise<ControlHandlerResult> {
return handleControlRequest(req, state, ctx);
}
async function handleControlRequest(
req: IncomingMessage,
state: ControlState,
ctx: HandlerContext,
): Promise<ControlHandlerResult> {
if (!isLoopback(req.socket.remoteAddress)) {
return { status: 403, body: { ok: false, error: "forbidden: non-loopback remote address" } };
}
const parsed = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method !== "POST" || parsed.pathname !== "/control") {
return { status: 404, body: { ok: false, error: `not_found: ${req.method} ${parsed.pathname}` } };
}
const body = await readBody(req);
let cmd: ControlCommand;
try {
cmd = JSON.parse(body) as ControlCommand;
} catch {
return { status: 400, body: { ok: false, error: "invalid_json" } };
}
const result = await dispatch(cmd, state, ctx);
return { status: 200, body: result };
}
async function dispatch(
cmd: ControlCommand,
state: ControlState,
ctx: HandlerContext,
): Promise<ControlResponse> {
switch (cmd.cmd) {
case "status": {
const cred = await loadCredential().catch(() => null);
return {
ok: true,
state: "running",
provider: state.provider,
plan: state.plan,
proxyPort: state.proxyPort,
loggedIn: cred != null,
};
}
case "startOAuth": {
// Tear down any previous in-flight flow so its callback port is released.
if (state.activeOauth) {
await state.activeOauth.client.close().catch(() => {});
state.activeOauth = undefined;
}
// Z.AI uses the server-mediated cli login (no local callback); bigmodel
// keeps the classic localhost auth-code callback server.
const client: OAuthFlowClient =
cmd.provider === "bigmodel" ? new BigmodelOAuthClient() : new ZaiOAuthClient();
const started = await client.start();
const callbackPort = started.callbackUrl
? Number(new URL(started.callbackUrl).port) || 80
: 0;
state.activeOauth = {
client,
callbackUrl: started.callbackUrl,
state: started.state,
};
client.complete(started).then(async (tokens) => {
const resolver = new KeyResolver();
const cred: Credential = await resolver.resolveCodingPlanCredential(tokens.accessToken, cmd.provider, tokens.userId);
if (tokens.jwt) cred.jwt = tokens.jwt;
await saveCredential(cred);
console.log(`OAuth completed for ${cmd.provider}`);
}).catch((err: unknown) => {
// Timeouts / rejections are expected when the user abandons the
// browser; nothing to surface beyond the log buffer.
console.error(`OAuth flow ended without success: ${(err as Error)?.message ?? String(err)}`);
}).finally(() => {
// MUST run on rejection too — otherwise the callback port leaks until
// process death (Android: only a device reboot clears it).
void client.close().catch(() => {});
if (state.activeOauth?.state === started.state) state.activeOauth = undefined;
});
return {
ok: true,
event: "oauthUrl",
authorizeUrl: started.authorizeUrl,
callbackPort,
};
}
case "deliverOAuthCode": {
const active = state.activeOauth;
// Code delivery only applies to callback-based (auth-code) flows — the
// Z.AI cli login completes via server polling and has no code to deliver.
if (!(active?.client instanceof AuthCodeOAuthClient) || active.state !== cmd.state) {
return { ok: false, error: "no_matching_oauth_flow" };
}
try {
const { accessToken, userId, jwt } = await active.client.exchangeCode(
cmd.code,
active.callbackUrl,
cmd.state,
);
const resolver = new KeyResolver();
const cred: Credential = await resolver.resolveCodingPlanCredential(accessToken, cmd.provider, userId);
if (jwt) cred.jwt = jwt;
await saveCredential(cred);
state.activeOauth = undefined;
await active.client.close().catch(() => {});
return { ok: true, event: "loginOk", provider: cmd.provider };
} catch (err) {
state.activeOauth = undefined;
await active.client.close().catch(() => {});
return { ok: false, error: `oauth_exchange_failed: ${(err as Error).message}` };
}
}
case "logout": {
await clearCredential();
return { ok: true, event: "loggedOut" };
}
case "setConfig": {
if (!ctx.onSetConfig) return { ok: false, error: "config_update_unavailable" };
const result = await ctx.onSetConfig({ provider: cmd.provider, plan: cmd.plan });
if (!result.ok) return result;
state.provider = result.provider;
state.plan = result.plan;
return { ok: true, event: "configUpdated", provider: result.provider, plan: result.plan };
}
case "startProxy": {
if (!ctx.onStartProxy) return { ok: false, error: "proxy_lifecycle_unavailable" };
const result = await ctx.onStartProxy();
if (!result.ok) return result;
state.proxyPort = result.port;
return { ok: true, event: "proxyStarted", port: result.port };
}
case "stopProxy": {
if (!ctx.onStopProxy) return { ok: false, error: "proxy_lifecycle_unavailable" };
const result = await ctx.onStopProxy();
if (!result.ok) return result;
state.proxyPort = 0;
return { ok: true, event: "proxyStopped" };
}
case "getLogs": {
const since = typeof cmd.since === "number" ? cmd.since : 0;
const { nextSince, lines } = ctx.logBuffer.since(since);
return { ok: true, event: "logs", nextSince, lines: [...lines] };
}
case "shutdown": {
if (ctx.onShutdown) await ctx.onShutdown();
return { ok: true, event: "shuttingDown" };
}
default:
return { ok: false, error: `unknown_cmd: ${(cmd as { cmd: string }).cmd}` };
}
}
function isLoopback(addr: string | undefined): boolean {
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
}
function writeJson(res: ServerResponse, status: number, body: unknown): void {
const json = JSON.stringify(body);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(json),
});
res.end(json);
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
|