{
if (!panelRuntime) return;
- void panelRuntime.control.close().catch(() => {});
- void panelRuntime.panel.close().catch(() => {});
+ void panelRuntime.close().catch(() => {});
};
process.on("SIGINT", () => {
diff --git a/src/server/panel-page.txt b/src/server/panel-page.txt
index d79129c..0ce1c56 100644
--- a/src/server/panel-page.txt
+++ b/src/server/panel-page.txt
@@ -278,13 +278,27 @@
if (plan) {
parts.push('' + esc(plan.level || "coding plan") + "
");
for (const limit of plan.limits || []) {
- const used = limit.percentage != null ? limit.percentage : (limit.total ? (limit.used / limit.total) * 100 : null);
+ // Upstream `number` is NOT a comparable total (live TIME_LIMIT row:
+ // remaining=3894, number=1), so like the CLI/TUI this shows remaining
+ // alone. `percentage` is the one self-consistent signal: when present
+ // it is a used share (0-100), and the bar is the remaining share
+ // derived from it — never an X/Y fabrication. Direction is not
+ // re-labelled here because upstream owns its meaning.
+ const remaining = limit.remaining;
+ const pct = limit.percentage;
+ const remainingFrac =
+ typeof pct === "number" && isFinite(pct) && pct >= 0 && pct <= 100 ? 1 - pct / 100 : null;
parts.push(
'' + esc(limit.type) + " " +
- esc(limit.remaining ?? "?") + " left of " + esc(limit.total ?? "?") + " " + esc(limit.unit || "") +
- (limit.nextResetTime ? " · resets " + esc(fmtTs(limit.nextResetTime)) : "") +
- "
" +
- (used == null ? "" : '
'),
+ (typeof remaining === "number"
+ ? esc(remaining.toLocaleString()) + (limit.unit ? " " + esc(limit.unit) : "") + " remaining"
+ : "unavailable") +
+ "" +
+ (limit.nextResetTime ? ' · resets ' + esc(fmtTs(limit.nextResetTime)) + "" : "") +
+ "" +
+ (remainingFrac === null
+ ? ""
+ : '
'),
);
}
} else {
diff --git a/src/server/panel.test.ts b/src/server/panel.test.ts
index 3975eb4..dc87074 100644
--- a/src/server/panel.test.ts
+++ b/src/server/panel.test.ts
@@ -1,14 +1,12 @@
/**
* Tests for the optional web panel (issue #58): token enforcement, the
- * pass-through to the loopback control listener, and the tokenless static
- * routes that a browser navigation cannot attach headers to.
+ * in-process control dispatch behind `POST /api/control` (no second listener),
+ * and the tokenless static routes a browser navigation cannot attach a header
+ * to.
*/
import { afterEach, describe, expect, it } from "bun:test";
-import { createServer, type Server } from "node:http";
import {
- DEFAULT_PANEL_CONTROL_PORT,
DEFAULT_PANEL_PORT,
- PANEL_CONTROL_PORT_ENV,
PANEL_ENABLED_ENV,
PANEL_PORT_ENV,
PANEL_TOKEN_ENV,
@@ -17,66 +15,35 @@ import {
startPanelServer,
type PanelServer,
} from "./panel.js";
+import {
+ LogBuffer,
+ createControlDispatcher,
+ type ControlCommand,
+ type ControlResponse,
+ type ControlState,
+} from "../android/control.js";
const TOKEN = "panel-token-for-tests";
-const CONTROL_OK = JSON.stringify({ ok: true, event: "proxyStopped" });
-
-interface StubControl {
- port: number;
- /** Bodies the panel actually forwarded, in order. */
- bodies: string[];
- /** Canned answer the stub returns for the next request. */
- reply: { status: number; body: string };
- close(): Promise;
-}
-/** Minimal stand-in for `startControlListener` (same POST /control contract). */
-async function startStubControl(): Promise {
- const stub: StubControl = {
- port: 0,
- bodies: [],
- reply: { status: 200, body: CONTROL_OK },
- close: async () => {},
- };
- const server: Server = createServer((req, res) => {
- const chunks: Buffer[] = [];
- req.on("data", (chunk: Buffer) => chunks.push(chunk));
- req.on("end", () => {
- stub.bodies.push(Buffer.concat(chunks).toString("utf8"));
- res.writeHead(stub.reply.status, { "content-type": "application/json" });
- res.end(stub.reply.body);
- });
- });
- await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
- const address = server.address();
- stub.port = typeof address === "object" && address ? address.port : 0;
- stub.close = () => new Promise((resolve) => server.close(() => resolve()));
- return stub;
-}
+/** Same shape the serve entry passes to `startPanelServer`. */
+type Dispatcher = (cmd: ControlCommand) => Promise;
let panels: PanelServer[] = [];
-let stubs: StubControl[] = [];
afterEach(async () => {
await Promise.all(panels.map((panel) => panel.close().catch(() => {})));
- await Promise.all(stubs.map((stub) => stub.close()));
panels = [];
- stubs = [];
});
/** Panel bound to a free port; the token is always the test token. */
-async function startPanel(controlPort: number): Promise {
- const panel = await startPanelServer({ port: 0, token: TOKEN, controlPort });
+async function startPanel(
+ handleControl: Dispatcher = async () => ({ ok: true, event: "proxyStopped" }),
+): Promise {
+ const panel = await startPanelServer({ port: 0, token: TOKEN, handleControl });
panels.push(panel);
return panel;
}
-async function startStub(): Promise {
- const stub = await startStubControl();
- stubs.push(stub);
- return stub;
-}
-
function panelUrl(panel: PanelServer, path: string): string {
return `http://127.0.0.1:${panel.port}${path}`;
}
@@ -113,70 +80,71 @@ describe("resolvePanelSettings", () => {
expect(resolvePanelSettings({ [PANEL_TOKEN_ENV]: TOKEN })).toBeNull();
});
- it("falls back to the default ports", () => {
+ it("resolves the token, the default port, and nothing else", () => {
+ // Only `{token, port}`: the panel no longer has a control port to forward
+ // to, so there is no second listener that could outlive a failed start.
expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1", [PANEL_TOKEN_ENV]: ` ${TOKEN} ` })).toEqual({
token: TOKEN,
port: DEFAULT_PANEL_PORT,
- controlPort: DEFAULT_PANEL_CONTROL_PORT,
});
});
- it("honours explicit ports", () => {
+ it("honours an explicit panel port", () => {
expect(
resolvePanelSettings({
[PANEL_ENABLED_ENV]: "true",
[PANEL_TOKEN_ENV]: TOKEN,
[PANEL_PORT_ENV]: "9100",
- [PANEL_CONTROL_PORT_ENV]: "9101",
}),
- ).toEqual({ token: TOKEN, port: 9100, controlPort: 9101 });
+ ).toEqual({ token: TOKEN, port: 9100 });
});
it("refuses to start without a token rather than serving an open control plane", () => {
expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1" })).toBeNull();
expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1", [PANEL_TOKEN_ENV]: " " })).toBeNull();
});
-
- it("refuses a port collision with the control listener", () => {
- expect(
- resolvePanelSettings({
- [PANEL_ENABLED_ENV]: "1",
- [PANEL_TOKEN_ENV]: TOKEN,
- [PANEL_PORT_ENV]: "8091",
- [PANEL_CONTROL_PORT_ENV]: "8091",
- }),
- ).toBeNull();
- });
});
describe("startPanelServer", () => {
it("refuses to start without a token", async () => {
- await expect(startPanelServer({ port: 0, token: "", controlPort: 1 })).rejects.toThrow(
+ const handleControl: Dispatcher = async () => ({ ok: true, event: "proxyStopped" });
+ await expect(startPanelServer({ port: 0, token: "", handleControl })).rejects.toThrow(
/panel token required/,
);
- await expect(startPanelServer({ port: 0, token: " ", controlPort: 1 })).rejects.toThrow(
+ await expect(startPanelServer({ port: 0, token: " ", handleControl })).rejects.toThrow(
/panel token required/,
);
});
it("binds loopback on a free port and reports the real one", async () => {
- const panel = await startPanel(1);
+ const panel = await startPanel();
expect(panel.hostname).toBe("127.0.0.1");
expect(panel.port).toBeGreaterThan(0);
});
it("frees the port on close", async () => {
- const panel = await startPanel(1);
+ const panel = await startPanel();
const port = panel.port;
await panel.close();
panels = panels.filter((candidate) => candidate !== panel);
await expect(fetch(`http://127.0.0.1:${port}/healthz`)).rejects.toThrow();
});
+
+ it("fails on a taken port without disturbing the panel already there", async () => {
+ // Regression for the #58 review's P2: with no control listener in the
+ // startup path there is nothing partially started to leak or to clean up.
+ const first = await startPanel();
+ const handleControl: Dispatcher = async () => ({ ok: true, event: "proxyStopped" });
+ await expect(startPanelServer({ port: first.port, token: TOKEN, handleControl })).rejects.toThrow();
+
+ const health = await fetch(panelUrl(first, "/healthz"));
+ expect(health.status).toBe(200);
+ });
});
describe("panel static routes", () => {
it("serves the shell and the liveness probe without a token", async () => {
- const panel = await startPanel(1);
+ const panel = await startPanel();
for (const path of ["/", "/panel"]) {
const res = await fetch(panelUrl(panel, path));
@@ -192,8 +160,21 @@ describe("panel static routes", () => {
expect(await health.json()).toEqual({ ok: true, service: "zcode-panel" });
});
+ it("never fabricates a total for a coding-plan limit", async () => {
+ // Regression for the #58 review's P2/P4: upstream `number` is not a
+ // comparable total (live TIME_LIMIT row: remaining=3894, number=1), so the
+ // window row shows `remaining` alone and only draws a bar when upstream
+ // hands us a usable percentage.
+ const panel = await startPanel();
+ const html = await (await fetch(panelUrl(panel, "/"))).text();
+ expect(html).not.toContain("left of");
+ expect(html).not.toContain("limit.total");
+ expect(html).toContain("remaining");
+ expect(html).toContain("limit.percentage");
+ });
+
it("answers unknown paths with 404 and a non-POST control call with 405", async () => {
- const panel = await startPanel(1);
+ const panel = await startPanel();
const missing = await fetch(panelUrl(panel, "/nope"));
expect(missing.status).toBe(404);
@@ -206,9 +187,12 @@ describe("panel static routes", () => {
});
describe("panel control authentication", () => {
- it("rejects a missing or wrong token and never talks to the control listener", async () => {
- const stub = await startStub();
- const panel = await startPanel(stub.port);
+ it("rejects a missing or wrong token without dispatching anything", async () => {
+ let calls = 0;
+ const panel = await startPanel(async () => {
+ calls++;
+ return { ok: true, event: "proxyStopped" };
+ });
const attempts: RequestInit[] = [
{},
@@ -221,66 +205,83 @@ describe("panel control authentication", () => {
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ ok: false, error: "unauthorized" });
}
- expect(stub.bodies).toEqual([]);
+ expect(calls).toBe(0);
});
- it("accepts either header spelling and forwards the body verbatim", async () => {
- const stub = await startStub();
- const panel = await startPanel(stub.port);
- stub.reply = {
- status: 200,
- body: JSON.stringify({ ok: true, event: "quota", quota: { provider: "zai" } }),
+ it("rejects an unauthenticated oversized body as 401, not 413", async () => {
+ let calls = 0;
+ const panel = await startPanel(async () => {
+ calls++;
+ return { ok: true, event: "proxyStopped" };
+ });
+
+ const res = await controlRequest(panel, { body: "x".repeat(70 * 1024) });
+ expect(res.status).toBe(401);
+ expect(calls).toBe(0);
+ });
+
+ it("accepts either header spelling and returns the control envelope", async () => {
+ const seen: ControlCommand[] = [];
+ const handleControl: Dispatcher = async (cmd) => {
+ seen.push(cmd);
+ if (cmd.cmd !== "quota") return { ok: true, event: "loggedOut" };
+ return { ok: true, event: "quota", quota: { provider: "zai" } as never };
};
+ const panel = await startPanel(handleControl);
const viaHeader = await controlRequest(panel, {
headers: { "content-type": "application/json", "x-panel-token": TOKEN },
body: JSON.stringify({ cmd: "quota" }),
});
expect(viaHeader.status).toBe(200);
- expect(await viaHeader.json()).toEqual({
- ok: true,
- event: "quota",
- quota: { provider: "zai" },
- });
+ expect(await viaHeader.json()).toEqual({ ok: true, event: "quota", quota: { provider: "zai" } });
const viaBearer = await controlRequest(panel, {
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(viaBearer.status).toBe(200);
- expect(stub.bodies).toEqual(['{"cmd":"quota"}', '{"cmd":"status"}']);
+ expect(seen).toEqual([{ cmd: "quota" }, { cmd: "status" }]);
});
- it("passes a control-layer error status and body through unchanged", async () => {
- const stub = await startStub();
- const panel = await startPanel(stub.port);
- stub.reply = { status: 400, body: JSON.stringify({ ok: false, error: "invalid_json" }) };
-
+ it("keeps the control protocol's error envelope (200 + ok:false) verbatim", async () => {
+ const panel = await startPanel(async (cmd) => ({ ok: false, error: `unknown_cmd: ${cmd.cmd}` }));
const res = await controlRequest(panel, {
headers: { authorization: `Bearer ${TOKEN}` },
- body: "not json",
+ body: JSON.stringify({ cmd: "nope" }),
});
- expect(res.status).toBe(400);
- expect(await res.json()).toEqual({ ok: false, error: "invalid_json" });
- expect(stub.bodies).toEqual(["not json"]);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: false, error: "unknown_cmd: nope" });
});
- it("reports control_unavailable when the control listener is down", async () => {
- const stub = await startStub();
- const controlPort = stub.port;
- await stub.close();
+ it("rejects malformed JSON and non-command payloads before dispatch", async () => {
+ let calls = 0;
+ const panel = await startPanel(async () => {
+ calls++;
+ return { ok: true, event: "proxyStopped" };
+ });
- const panel = await startPanel(controlPort);
- const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` } });
- expect(res.status).toBe(502);
- const body = (await res.json()) as { ok: boolean; error: string };
- expect(body.ok).toBe(false);
- expect(body.error).toContain("control_unavailable");
+ const badJson = await controlRequest(panel, {
+ headers: { authorization: `Bearer ${TOKEN}` },
+ body: "not json",
+ });
+ expect(badJson.status).toBe(400);
+ expect(await badJson.json()).toEqual({ ok: false, error: "invalid_json" });
+
+ for (const body of ["null", "[]", '"status"', "{}", '{"cmd":42}']) {
+ const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` }, body });
+ expect(res.status).toBe(400);
+ expect(await res.json()).toEqual({ ok: false, error: "invalid_command" });
+ }
+ expect(calls).toBe(0);
});
- it("rejects an oversized command body", async () => {
- const stub = await startStub();
- const panel = await startPanel(stub.port);
+ it("rejects an oversized command body before dispatch", async () => {
+ let calls = 0;
+ const panel = await startPanel(async () => {
+ calls++;
+ return { ok: true, event: "proxyStopped" };
+ });
const res = await controlRequest(panel, {
headers: { authorization: `Bearer ${TOKEN}` },
@@ -288,6 +289,62 @@ describe("panel control authentication", () => {
});
expect(res.status).toBe(413);
expect(await res.json()).toEqual({ ok: false, error: "request_too_large" });
- expect(stub.bodies).toEqual([]);
+ expect(calls).toBe(0);
+ });
+
+ it("reports a dispatcher crash as internal_error instead of a bare failure", async () => {
+ const panel = await startPanel(async () => {
+ throw new Error("boom");
+ });
+ const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` } });
+ expect(res.status).toBe(500);
+ expect(await res.json()).toEqual({ ok: false, error: "internal_error: boom" });
+ });
+});
+
+describe("panel ↔ control dispatcher wiring", () => {
+ it("runs a real control command in process, with no extra listener", async () => {
+ // The full path the panel uses in `serve`: a real dispatcher built from the
+ // Android control module, driven over the panel's own authenticated HTTP
+ // surface. Nothing here binds a control port.
+ let stops = 0;
+ const state: ControlState = { provider: "zai", plan: "coding-plan", proxyPort: 8080 };
+ const handleControl = createControlDispatcher(state, {
+ logBuffer: new LogBuffer(),
+ onStopProxy: async () => {
+ stops++;
+ return { ok: true };
+ },
+ });
+ const panel = await startPanel(handleControl);
+
+ const denied = await controlRequest(panel, {
+ body: JSON.stringify({ cmd: "stopProxy" }),
+ });
+ expect(denied.status).toBe(401);
+ expect(stops).toBe(0);
+
+ const allowed = await controlRequest(panel, {
+ headers: { authorization: `Bearer ${TOKEN}` },
+ body: JSON.stringify({ cmd: "stopProxy" }),
+ });
+ expect(allowed.status).toBe(200);
+ expect(await allowed.json()).toEqual({ ok: true, event: "proxyStopped" });
+ expect(stops).toBe(1);
+ expect(state.proxyPort).toBe(0);
+ });
+
+ it("answers `status` from the same hook state the proxy entry uses", async () => {
+ const state: ControlState = { provider: "bigmodel", plan: "start-plan", proxyPort: 0 };
+ const handleControl = createControlDispatcher(state, { logBuffer: new LogBuffer() });
+ const panel = await startPanel(handleControl);
+
+ const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` } });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { ok: boolean; provider: string; plan: string; proxyPort: number };
+ expect(body.ok).toBe(true);
+ expect(body.provider).toBe("bigmodel");
+ expect(body.plan).toBe("start-plan");
+ expect(body.proxyPort).toBe(0);
});
});
diff --git a/src/server/panel.ts b/src/server/panel.ts
index 80fad08..96e038d 100644
--- a/src/server/panel.ts
+++ b/src/server/panel.ts
@@ -9,7 +9,14 @@
* token-guarded HTTP surface plus one embedded page. It adds no new state and
* no new upstream calls:
*
- * browser → panel (token) → POST /api/control → 127.0.0.1:/control
+ * browser → panel (token) → POST /api/control → in-process dispatcher
+ *
+ * The dispatcher is `createControlDispatcher()` from `src/android/control.ts`:
+ * the same command semantics `POST /control` serves, called directly instead of
+ * over a second loopback HTTP port. Opening such a port would mean an
+ * unauthenticated path to `stopProxy` / `logout` / `shutdown` for anything that
+ * can reach loopback (a browser on the box can POST `text/plain` cross-origin
+ * without reading the response), so the panel does not do it.
*
* Security model (deliberate, see the discussion on #58):
* - off by default: `ZCODE_PANEL_ENABLED` must be set to a truthy value;
@@ -17,15 +24,15 @@
* - binds loopback only, and never touches `auth.proxyApiKey` or `/v1/*`,
* so enabling the panel does not change the proxy's own auth surface;
* - `/api/*` requires `Authorization: Bearer ` or `X-Panel-Token`,
- * compared with `timingSafeEqual`;
- * - the control listener keeps its own loopback check and its own port, so a
- * reachable panel is not a privilege escalation of the `/webui` exemption;
+ * compared with `timingSafeEqual`, and a body above `MAX_BODY_BYTES` is
+ * rejected before it is parsed;
* - `GET /` and `GET /healthz` are tokenless because a browser cannot attach
* a header to a top-level navigation: `/` returns the static shell (no
* account data) and `/healthz` returns a fixed `{"ok":true}`.
*/
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { timingSafeEqual } from "node:crypto";
+import type { ControlCommand, ControlResponse } from "../android/control.js";
import panelHtml from "./panel-page.txt" with { type: "text" };
/** Env flag that enables the panel. Empty / `0` / `false` / `no` / `off` = off. */
@@ -34,25 +41,26 @@ export const PANEL_ENABLED_ENV = "ZCODE_PANEL_ENABLED";
export const PANEL_TOKEN_ENV = "ZCODE_PANEL_TOKEN";
/** Panel listen port (loopback). */
export const PANEL_PORT_ENV = "ZCODE_PANEL_PORT";
-/** Loopback port of the control listener the panel forwards to. */
-export const PANEL_CONTROL_PORT_ENV = "ZCODE_PANEL_CONTROL_PORT";
/** Defaults mirror the Android entry's wiring so operators only set one thing. */
export const DEFAULT_PANEL_PORT = 8090;
-export const DEFAULT_PANEL_CONTROL_PORT = 8091;
/** Control commands are small JSON documents; anything bigger is a mistake. */
const MAX_BODY_BYTES = 64 * 1024;
-/** A `quota` command can hit two upstream planes, so allow a slow answer. */
-const CONTROL_TIMEOUT_MS = 30_000;
+
+/**
+ * In-process control dispatch: `POST /api/control` hands the parsed command to
+ * this and returns whatever the control protocol answers, unchanged.
+ */
+export type ControlDispatcher = (cmd: ControlCommand) => Promise;
export interface PanelOptions {
/** HTTP port. `0` picks a free port (used by tests). */
port: number;
/** Shared secret required on `/api/*`; must be non-empty. */
token: string;
- /** Loopback port of the control listener to forward commands to. */
- controlPort: number;
+ /** In-process dispatcher backing `POST /api/control`. */
+ handleControl: ControlDispatcher;
/** Bind address. Loopback by default and intentionally not configurable. */
hostname?: string;
}
@@ -68,7 +76,6 @@ export interface PanelServer {
export interface PanelSettings {
token: string;
port: number;
- controlPort: number;
}
/** Request handler produced by {@link createPanelHandler}. */
@@ -86,8 +93,8 @@ export function isPanelEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
/**
* Resolve the panel configuration from the environment. Returns `null` when the
* panel must not start — either it was not requested, or it was requested
- * without a token / with a port collision, both of which are configuration
- * mistakes worth a loud message rather than an unauthenticated listener.
+ * without a token, which is a configuration mistake worth a loud message rather
+ * than an unauthenticated listener.
*/
export function resolvePanelSettings(env: NodeJS.ProcessEnv = process.env): PanelSettings | null {
if (!isPanelEnabled(env)) return null;
@@ -99,16 +106,7 @@ export function resolvePanelSettings(env: NodeJS.ProcessEnv = process.env): Pane
}
const port = Number(env[PANEL_PORT_ENV] ?? DEFAULT_PANEL_PORT) || DEFAULT_PANEL_PORT;
- const controlPort =
- Number(env[PANEL_CONTROL_PORT_ENV] ?? DEFAULT_PANEL_CONTROL_PORT) || DEFAULT_PANEL_CONTROL_PORT;
- if (port === controlPort) {
- console.error(
- `[panel] ${PANEL_PORT_ENV} and ${PANEL_CONTROL_PORT_ENV} must differ (both ${port}) — panel not started`,
- );
- return null;
- }
-
- return { token, port, controlPort };
+ return { token, port };
}
/** First value of a possibly-repeated request header. */
@@ -176,30 +174,6 @@ async function readBody(req: IncomingMessage): Promise {
return tooLarge ? null : Buffer.concat(chunks).toString("utf8");
}
-/**
- * Forward one command to the control listener. Status and body are passed
- * through unchanged, so the panel speaks exactly the documented protocol
- * (`{ok:true,...}` / `{ok:false,error}`) and never invents a shape.
- */
-async function forwardToControl(
- controlPort: number,
- body: string,
-): Promise<{ status: number; body: string }> {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), CONTROL_TIMEOUT_MS);
- try {
- const res = await fetch(`http://127.0.0.1:${controlPort}/control`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body,
- signal: controller.signal,
- });
- return { status: res.status, body: await res.text() };
- } finally {
- clearTimeout(timer);
- }
-}
-
/**
* Build the panel request handler. Exported separately from
* {@link startPanelServer} so tests can drive it without binding a port.
@@ -229,6 +203,8 @@ export function createPanelHandler(opts: PanelOptions): PanelHandler {
sendJson(res, 405, { ok: false, error: "method_not_allowed" });
return;
}
+ // Auth before anything else: an unauthenticated request must not reach the
+ // dispatcher (which can stop the proxy or clear the stored credential).
if (!tokenMatches(extractToken(req), token)) {
sendJson(res, 401, { ok: false, error: "unauthorized" });
return;
@@ -240,22 +216,26 @@ export function createPanelHandler(opts: PanelOptions): PanelHandler {
return;
}
+ let cmd: ControlCommand;
+ try {
+ const parsed: unknown = JSON.parse(body);
+ if (typeof parsed !== "object" || parsed === null || typeof (parsed as { cmd?: unknown }).cmd !== "string") {
+ sendJson(res, 400, { ok: false, error: "invalid_command" });
+ return;
+ }
+ cmd = parsed as ControlCommand;
+ } catch {
+ sendJson(res, 400, { ok: false, error: "invalid_json" });
+ return;
+ }
+
try {
- const upstream = await forwardToControl(opts.controlPort, body);
- const payload = upstream.body;
- res.writeHead(upstream.status, {
- "content-type": "application/json; charset=utf-8",
- "content-length": Buffer.byteLength(payload),
- "cache-control": "no-store",
- });
- res.end(payload);
+ // Same envelope semantics as the control listener: a failed command is a
+ // 200 with `{ok:false,error}`, so the page can render the reason verbatim.
+ const result = await opts.handleControl(cmd);
+ sendJson(res, 200, result);
} catch (err) {
- // The control listener is not up (wrong port, crashed, timed out): say so
- // instead of returning a bare 500, because that is the common misconfig.
- sendJson(res, 502, {
- ok: false,
- error: `control_unavailable: ${(err as Error).message}`,
- });
+ sendJson(res, 500, { ok: false, error: `internal_error: ${(err as Error).message}` });
}
};
}
@@ -263,7 +243,8 @@ export function createPanelHandler(opts: PanelOptions): PanelHandler {
/**
* Start the panel on the loopback interface. Throws when the token is missing
* (silently starting an unauthenticated panel is the one outcome this module
- * refuses to allow) or when the port cannot be bound.
+ * refuses to allow) or when the port cannot be bound. There is nothing else to
+ * roll back on failure: the panel owns the only listener it opens.
*/
export async function startPanelServer(opts: PanelOptions): Promise {
const token = (opts.token ?? "").trim();
From a23794be13a6c12992faec94b21fa31240979d76 Mon Sep 17 00:00:00 2001
From: Ma6302 <143102004+Ma6302@users.noreply.github.com>
Date: Fri, 2 Oct 2026 00:10:47 +0800
Subject: [PATCH 3/3] =?UTF-8?q?fix(serve):=20=E9=9D=A2=E6=9D=BF=E5=81=9C?=
=?UTF-8?q?=E6=AD=A2=E4=BB=A3=E7=90=86=E5=90=8E=E4=BB=8D=E8=83=BD=E6=AD=A3?=
=?UTF-8?q?=E5=B8=B8=E9=80=80=E5=87=BA=EF=BC=8C=E7=99=BB=E5=87=BA=E5=90=8C?=
=?UTF-8?q?=E6=AD=A5=E8=BF=90=E8=A1=8C=E4=B8=AD=E7=9A=84=E5=87=AD=E6=8D=AE?=
=?UTF-8?q?=EF=BC=88#59=20=E8=AF=84=E5=AE=A1=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
按 @TriDefender 的复审修正两处 P2:
- P2(进程退出):进程退出不再依赖「代理句柄是否存在」。`serve` 把动态
import 的自动领取调度器与验证码池句柄留住,统一由一个幂等的 `shutdown()`
收尾:关面板 → `ClaimScheduler.stop()` → `shutdownCaptcha()` → 若有代理则
`stop(true)`,否则 `process.exit(0)`;SIGINT/SIGTERM 与面板新增的 `shutdown`
命令共用这条路径。面板回 shutdown 时先写响应、50ms 后再退出,避免
`process.exit()` 截断 HTTP 应答。此前从面板 Stop proxy 后
`serverRef.current` 已是 null,信号处理器里的可选调用不再退出,而未 unref
的领取定时器与验证码池定时器仍吊着事件循环,`docker stop` 要等超时强杀、
终端 Ctrl+C 也退不掉。
- P2(认证同步):面板每次成功命令后比对凭据文件指纹,与内存不一致时重新
`setOAuthCredential()` / 新增的 `clearOAuthCredential()`;登出且代理在跑时
先清掉内存凭据再 `stopProxy()`,状态回到 `proxyPort: 0`。此前登出只删磁盘
凭据,`/v1` 与自动领取仍用 AuthManager 里的旧凭据继续消费额度。
`AuthManager` 新增 `clearOAuthCredential()`;Android 控制协议与
`control.ts` 未改动。
验证:`bun x tsc --noEmit` 通过;`bun test` 935 pass / 1 fail(唯一失败为既有
的 Windows captcha worker 用例);面板登出文案改为提示会同时停掉运行中的代理。
VPS 端到端(host 网络,plan=start-plan,自动领取与验证码池都在跑):
- 面板 Stop proxy 后 SIGTERM 95ms 退出、SIGINT 114ms 退出,日志
`shutdown: cleared auto-claim + captcha pool timers`;
- 面板 `shutdown`(代理仍在跑)先收到 `{"ok":true,"event":"shuttingDown"}`,
297ms 后容器自行退出,exit 0;
- 代理运行中删掉磁盘凭据并让面板跑一条命令:下一个
`/v1/chat/completions` 立即返回 503 `credential_unavailable`,证明内存凭据
已同步失效而不是继续用旧账号;凭据放回后再跑一条命令,日志
`auth: switched to the account now on disk`,请求恢复 200;
- 面板登出:`{"ok":true,"event":"loggedOut"}`,代理随即停止(8098 不再监听)、
`status` 变成 `proxyPort: 0 / loggedIn: false`、`startProxy` 返回
`not_logged_in`,日志 `panel: logout cleared the live credential — proxy stopped`;
- 磁盘凭据删不掉时(EACCES)登出返回 internal_error,代理与内存凭据保持原样,
不会留下半截状态。
Signed-off-by: Ma6302 <143102004+Ma6302@users.noreply.github.com>
---
README.md | 2 +-
README_EN.md | 2 +-
src/auth/manager.test.ts | 15 ++++
src/auth/manager.ts | 12 ++++
src/index.ts | 141 +++++++++++++++++++++++++++++++++-----
src/server/panel-page.txt | 5 +-
6 files changed, 158 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 8a2ec81..f501636 100644
--- a/README.md
+++ b/README.md
@@ -187,7 +187,7 @@ services:
套餐类型(`plan`: `coding-plan` 个人套餐 / `start-plan` 体验套餐)在面板里按 t 切换,会写回 config.yaml。
-服务器这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动;命令走进程内分发,不会再额外开一个控制端口。
+服务器这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动;命令走进程内分发,不会再额外开一个控制端口。面板上的「Stop proxy」只停代理,进程本身仍能正常退出(SIGTERM/SIGINT 和面板的 shutdown 都会先清掉后台定时器——自动领取、验证码池——再退出);在面板里登出会同时清掉运行中的凭据并停掉代理,避免登出后新请求还继续花旧账号的额度。
**Docker 里怎么连面板**:面板只监听**容器自己的** `127.0.0.1`,所以默认 bridge 网络下 `-p 8080:8080` 映射不出来,只补一个 `-p 8090:8090` 也连不上(端口映射到的是容器的非回环地址)。Linux 服务器上用 host 网络,让容器直接用宿主机回环:
diff --git a/README_EN.md b/README_EN.md
index ff88ee3..c00c54b 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -188,7 +188,7 @@ The config file is `config.yaml` in the project root (auto-generated on first st
The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggled in the panel with t, which writes the change back to config.yaml.
-Without a TUI (cloud server) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=`, start the proxy, then forward the port and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. Commands are dispatched in process, so no extra control port is opened.
+Without a TUI (cloud server) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=`, start the proxy, then forward the port and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. Commands are dispatched in process, so no extra control port is opened. Stopping the proxy from the page does not keep the process alive: SIGTERM/SIGINT and the panel's own shutdown all clear the background timers (auto-claim, captcha pool) before exiting. Logging out from the page also clears the live credential and stops the proxy, so a logged-out account is not spent any further.
**Reaching the panel from Docker**: the panel listens on the *container's own* `127.0.0.1`, so with the default bridge network `-p 8080:8080` does not expose it, and adding `-p 8090:8090` does not help either (that maps a non-loopback container address). On a Linux server, use host networking so the container shares the host's loopback:
diff --git a/src/auth/manager.test.ts b/src/auth/manager.test.ts
index c759581..8d79959 100644
--- a/src/auth/manager.test.ts
+++ b/src/auth/manager.test.ts
@@ -66,4 +66,19 @@ describe("AuthManager", () => {
await expect(mgr.getCredential()).rejects.toThrow(/expired/);
await expect(mgr.getCredential()).rejects.toThrow(/not available/);
});
+
+ it("drops the credential on clearOAuthCredential", async () => {
+ const mgr = new AuthManager();
+ mgr.setOAuthCredential({ apiKey: "oa", provider: "zai" });
+ mgr.clearOAuthCredential();
+ await expect(mgr.getCredential()).rejects.toThrow(/not available/);
+ });
+
+ it("accepts a fresh credential after clearOAuthCredential", async () => {
+ const mgr = new AuthManager();
+ mgr.setOAuthCredential({ apiKey: "old", provider: "zai" });
+ mgr.clearOAuthCredential();
+ mgr.setOAuthCredential({ apiKey: "new", provider: "zai" });
+ expect((await mgr.getCredential()).apiKey).toBe("new");
+ });
});
diff --git a/src/auth/manager.ts b/src/auth/manager.ts
index c6264b9..4c97414 100644
--- a/src/auth/manager.ts
+++ b/src/auth/manager.ts
@@ -36,4 +36,16 @@ export class AuthManager {
setOAuthCredential(cred: Credential): void {
this.oauthCred = cred;
}
+
+ /**
+ * Drop the in-memory credential without touching the store.
+ *
+ * Used when the user logs out while the process keeps running (the `serve`
+ * web panel): this manager is consulted before the store, so a credential
+ * that is already gone from disk would otherwise keep being spent by `/v1`
+ * requests and by auto-claim.
+ */
+ clearOAuthCredential(): void {
+ this.oauthCred = null;
+ }
}
diff --git a/src/index.ts b/src/index.ts
index 5e9f4c0..32fea61 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,6 +25,7 @@ import { buildServerOptions } from "./server/server-options.js";
import {
resolvePanelSettings,
startPanelServer,
+ type ControlDispatcher,
type PanelServer,
type PanelSettings,
} from "./server/panel.js";
@@ -205,6 +206,11 @@ function installLogTee(): LogBuffer {
* The proxy lifecycle hooks mirror `runAndroid` on purpose: `serve` starts the
* proxy eagerly, so `serverRef` is pre-filled and the start/stop commands only
* matter for restarts (including the `stop_proxy_first` rule before setConfig).
+ *
+ * Two behaviours are panel-only and stay out of the shared control layer: the
+ * `shutdown` command unwinds the whole process (through the same path as the
+ * signals, so it works after the proxy was stopped from the page), and a
+ * logout/login re-syncs the live credential — see `handleControl` below.
*/
async function startServePanel(
settings: PanelSettings,
@@ -214,9 +220,33 @@ async function startServePanel(
auth: AuthManager;
serverRef: { current: ProxyServer | null };
logBuffer: LogBuffer;
+ /** Unwind the process; independent of whether the proxy is still running. */
+ shutdown: () => void;
},
): Promise {
- const { config, path, auth, serverRef, logBuffer } = ctx;
+ const { config, path, auth, serverRef, logBuffer, shutdown } = ctx;
+
+ // The panel can log out (or log in another account) while `serve` keeps
+ // running, but AuthManager caches the credential in memory and auto-claim
+ // prefers it over the store — so a disk-only change would leave `/v1` and
+ // auto-claim serving the account that was just replaced (issue #58 review,
+ // P2). Fingerprint the store and re-sync after every panel command: the
+ // page polls `getLogs` every 2s, so a background login lands within one poll.
+ let authFingerprint = JSON.stringify((await loadCredential().catch(() => null)) ?? null);
+
+ async function syncAuthWithDisk(): Promise {
+ const onDisk = await loadCredential().catch(() => null);
+ const fingerprint = JSON.stringify(onDisk ?? null);
+ if (fingerprint === authFingerprint) return;
+ authFingerprint = fingerprint;
+ if (onDisk) {
+ auth.setOAuthCredential(onDisk);
+ console.log("auth: switched to the account now on disk");
+ } else {
+ auth.clearOAuthCredential();
+ console.log("auth: credential cleared (logged out)");
+ }
+ }
const controlState: ControlState = {
provider: config.provider,
@@ -229,6 +259,7 @@ async function startServePanel(
const cred = await loadCredential().catch(() => null);
if (!cred) return { ok: false, error: "not_logged_in" };
auth.setOAuthCredential(cred);
+ authFingerprint = JSON.stringify(cred);
try {
const s = await startServer(buildServerOptions(config, auth, false));
serverRef.current = s;
@@ -268,17 +299,47 @@ async function startServePanel(
// token, so a second loopback listener would only add an unauthenticated way
// to reach stopProxy / logout / shutdown (issue #58 review, P1) and a second
// thing to clean up when the panel fails to start (P2, now structurally gone).
- const handleControl = createControlDispatcher(controlState, {
+ const dispatchControl = createControlDispatcher(controlState, {
logBuffer,
onStartProxy: startProxy,
onStopProxy: stopProxy,
onSetConfig: setConfig,
onQuota: () => collectQuotaSnapshot(config),
- onShutdown: async () => {
- serverRef.current?.stop(true);
- },
});
+ /** Grace period for the `shutdown` reply before `process.exit()` runs. */
+ const SHUTDOWN_REPLY_GRACE_MS = 50;
+
+ /**
+ * The panel's transport wrapper. Two panel-only responsibilities live here
+ * rather than in the shared control layer, so the Android protocol keeps its
+ * existing semantics:
+ *
+ * - `shutdown` answers first and unwinds afterwards. Exiting inside the
+ * command would truncate the reply the page is waiting for, and it unwinds
+ * through the same path as SIGTERM/SIGINT, so it works whether or not the
+ * proxy is still running (issue #58 review, P2).
+ * - Every other successful command re-syncs the live credential with the
+ * store, and a logout while the proxy runs stops it. Otherwise `/v1` and
+ * auto-claim keep spending the account that was just logged out (issue #58
+ * review, P2).
+ */
+ const handleControl: ControlDispatcher = async (cmd) => {
+ if (cmd.cmd === "shutdown") {
+ setTimeout(shutdown, SHUTDOWN_REPLY_GRACE_MS);
+ return { ok: true, event: "shuttingDown" };
+ }
+ const res = await dispatchControl(cmd);
+ if (!res.ok) return res;
+ await syncAuthWithDisk();
+ if (cmd.cmd === "logout" && serverRef.current) {
+ await stopProxy();
+ controlState.proxyPort = 0;
+ console.log("panel: logout cleared the live credential — proxy stopped");
+ }
+ return res;
+ };
+
const panel = await startPanelServer({
port: settings.port,
token: settings.token,
@@ -319,17 +380,25 @@ async function serve(configPath: string | undefined, debug: boolean): Promise void } | null = null;
+ let captchaModule: { shutdownCaptcha: () => void } | null = null;
+
if (config.plan === "start-plan") {
// Pre-solve the captcha token pool in the background so first requests
// don't pay the full solve latency (in-process happy-dom backend).
import("./proxy/captcha.js")
- .then((m) => m.startCaptchaPool(config.identity.appVersion))
+ .then(async (m) => {
+ captchaModule = m;
+ await m.startCaptchaPool(config.identity.appVersion);
+ })
.catch((err) => console.error(`[captcha] pool warmup failed: ${(err as Error).message}`));
}
if (config.claim.enabled && config.claim.auto) {
import("./claim/runtime.js")
.then((m) => {
- m.startAutoClaim(config, auth);
+ claimScheduler = m.startAutoClaim(config, auth);
console.log(` claim: auto ON (poll ${Math.round(config.claim.pollIntervalMs / 1000)}s)`);
})
.catch((err) => console.error(`[claim] scheduler failed to start: ${(err as Error).message}`));
@@ -344,6 +413,52 @@ async function serve(configPath: string | undefined, debug: boolean): Promise {
+ if (!panelRuntime) return;
+ void panelRuntime.close().catch(() => {});
+ };
+
+ // Single shutdown path, shared by the signals and the panel's `shutdown`
+ // command. It must not depend on `serverRef`: the page can stop the proxy,
+ // and the timers below keep the event loop alive, so "the proxy is already
+ // stopped" is not the same as "there is nothing left to do" — without this,
+ // SIGTERM/SIGINT and `docker stop` hung until the kill timeout after a
+ // panel-side Stop proxy (issue #58 review, P2).
+ let shuttingDown = false;
+ const shutdown = (): void => {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ closePanel();
+ const cleared: string[] = [];
+ if (claimScheduler) {
+ try {
+ claimScheduler.stop();
+ cleared.push("auto-claim");
+ } catch {
+ /* already stopped */
+ }
+ claimScheduler = null;
+ }
+ if (captchaModule) {
+ try {
+ captchaModule.shutdownCaptcha();
+ cleared.push("captcha pool");
+ } catch {
+ /* pool never started */
+ }
+ captchaModule = null;
+ }
+ if (cleared.length > 0) console.log(`shutdown: cleared ${cleared.join(" + ")} timers`);
+ if (serverRef.current) {
+ // Closes the listener and exits the process (`stop(true)`).
+ serverRef.current.stop(true);
+ return;
+ }
+ console.log("shutdown: proxy already stopped — exiting");
+ process.exit(0);
+ };
+
if (panelSettings && panelLogBuffer) {
try {
panelRuntime = await startServePanel(panelSettings, {
@@ -352,6 +467,7 @@ async function serve(configPath: string | undefined, debug: boolean): Promise {
- if (!panelRuntime) return;
- void panelRuntime.close().catch(() => {});
- };
-
process.on("SIGINT", () => {
console.log("\nShutting down...");
- closePanel();
- serverRef.current?.stop(true);
+ shutdown();
});
process.on("SIGTERM", () => {
- closePanel();
- serverRef.current?.stop(true);
+ shutdown();
});
}
diff --git a/src/server/panel-page.txt b/src/server/panel-page.txt
index 0ce1c56..90f8877 100644
--- a/src/server/panel-page.txt
+++ b/src/server/panel-page.txt
@@ -391,7 +391,10 @@
$("login").onclick = () => startLogin("zai");
$("login-bm").onclick = () => startLogin("bigmodel");
$("logout").onclick = () => {
- if (confirm("Log out and delete the stored credential?")) guard("logout", { cmd: "logout" });
+ if (
+ confirm("Log out and delete the stored credential? The proxy stops using it immediately and is stopped if it is running.")
+ )
+ guard("logout", { cmd: "logout" });
};
$("logs-clear").onclick = () => { $("logs").textContent = ""; };