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);
  });
}