diff --git a/README.md b/README.md
index c488a9a..f501636 100644
--- a/README.md
+++ b/README.md
@@ -181,9 +181,36 @@ services:
| `ZCODE_PROXY_CONFIG` | `config.yaml` | 配置文件路径 |
| `ZCODE_PROXY_CREDENTIAL_SECRET` | 机器相关 | 登录凭据的加密种子(跨机器迁移/Docker 时需要固定它) |
| `ZCODE_LOG_FORMAT` | 桌面表格 | 设为 `compact` 可得到单行日志(适合窄屏) |
+| `ZCODE_PANEL_ENABLED` | 关 | 设为 `1`/`true` 后,无界面的 `serve` 模式(含 Docker)额外启动一个本机 Web 面板 |
+| `ZCODE_PANEL_TOKEN` | 无 | 面板的访问令牌,**开启面板时必填**(不填则面板不启动,避免裸奔的控制接口) |
+| `ZCODE_PANEL_PORT` | `8090` | 面板端口(只监听 `127.0.0.1`) |
套餐类型(`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 不启动;命令走进程内分发,不会再额外开一个控制端口。面板上的「Stop proxy」只停代理,进程本身仍能正常退出(SIGTERM/SIGINT 和面板的 shutdown 都会先清掉后台定时器——自动领取、验证码池——再退出);在面板里登出会同时清掉运行中的凭据并停掉代理,避免登出后新请求还继续花旧账号的额度。
+
+**Docker 里怎么连面板**:面板只监听**容器自己的** `127.0.0.1`,所以默认 bridge 网络下 `-p 8080:8080` 映射不出来,只补一个 `-p 8090:8090` 也连不上(端口映射到的是容器的非回环地址)。Linux 服务器上用 host 网络,让容器直接用宿主机回环:
+
+```yaml
+services:
+ zcode-proxy:
+ # 保留现有 image / volumes / restart 等配置
+ network_mode: host # host 模式下删掉原来的 ports:
+ environment:
+ ZCODE_PROXY_CREDENTIAL_SECRET: "一串只有你知道的口令"
+ ZCODE_PANEL_ENABLED: "1"
+ ZCODE_PANEL_TOKEN: "${ZCODE_PANEL_TOKEN:?请先在 .env 里设置面板 token}"
+ ZCODE_PANEL_PORT: "8090"
+```
+
+然后在本机建一条只转发的隧道(`-N` 不开 shell):
+
+```bash
+ssh -N -L 8090:127.0.0.1:8090 user@host
+```
+
+再打开 `http://127.0.0.1:8090`。host 网络下代理主端口也直接占用宿主机端口,安全组/防火墙照旧按原来放行 8080,**不要**对外放行 8090。
+
@@ -193,7 +220,7 @@ services:
**周末/体验套餐自动领取 (claim)** —— 默认开启。代理每 5 分钟探测一次官方的限量套餐活动页,上新瞬间自动帮你抢(`claim.enabled: false` 可关闭)。手动抢:`bun run src/index.ts claim`。
-**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 r 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的剩余 / 总量与重置时间)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。
+**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 r 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的**剩余额度**与重置时间——上游 `number` 不是可与剩余比较的总量,所以与 CLI/TUI 一致只显示剩余,只有上游给出百分比时才画比例条)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。
diff --git a/README_EN.md b/README_EN.md
index ac1da4b..c00c54b 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -182,9 +182,36 @@ The config file is `config.yaml` in the project root (auto-generated on first st
| `ZCODE_PROXY_CONFIG` | `config.yaml` | Config file path |
| `ZCODE_PROXY_CREDENTIAL_SECRET` | machine-specific | Encryption seed for login credentials (fix it when migrating across machines / using Docker) |
| `ZCODE_LOG_FORMAT` | desktop table | Set to `compact` for single-line logs (good for narrow screens) |
+| `ZCODE_PANEL_ENABLED` | off | Set to `1`/`true` to start a local web panel in headless `serve` mode (including Docker) |
+| `ZCODE_PANEL_TOKEN` | none | Access token for the panel, **required when the panel is enabled** (without it the panel does not start, so the control endpoints are never left open) |
+| `ZCODE_PANEL_PORT` | `8090` | Panel port (bound to `127.0.0.1` only) |
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. 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:
+
+```yaml
+services:
+ zcode-proxy:
+ # keep the existing image / volumes / restart settings
+ network_mode: host # and drop the original ports: block
+ environment:
+ ZCODE_PROXY_CREDENTIAL_SECRET: "a-passphrase-only-you-know"
+ ZCODE_PANEL_ENABLED: "1"
+ ZCODE_PANEL_TOKEN: "${ZCODE_PANEL_TOKEN:?set a panel token in .env first}"
+ ZCODE_PANEL_PORT: "8090"
+```
+
+Then forward-only tunnel from your machine (`-N` = no shell):
+
+```bash
+ssh -N -L 8090:127.0.0.1:8090 user@host
+```
+
+and open `http://127.0.0.1:8090`. With host networking the proxy port is the host port too, so keep the firewall rules for 8080 as they were and do **not** expose 8090 publicly.
+
@@ -194,7 +221,7 @@ The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggl
**Weekend/trial plan auto-claiming (claim)** — enabled by default. The proxy probes the official limited-plan campaign page every 5 minutes and grabs new drops for you the instant they appear (`claim.enabled: false` to disable). Manual run: `bun run src/index.ts claim`.
-**Quota display (quota)** — after login the panel fetches quota once automatically; refresh manually with r. Data comes from two upstream planes: trial/credits-plan buckets (`billing/balance`, remaining / total units, expiry) and individual coding-plan usage windows (`/api/monitor/usage/quota/limit`, same endpoint the official usage panel reads — 5-hour / weekly window remaining / total and reset time). CLI: `bun run src/index.ts quota` (HTTP: `GET /quota`). The upstream gateways rate-limit frequent queries, so the panel does not poll on a timer.
+**Quota display (quota)** — after login the panel fetches quota once automatically; refresh manually with r. Data comes from two upstream planes: trial/credits-plan buckets (`billing/balance`, remaining / total units, expiry) and individual coding-plan usage windows (`/api/monitor/usage/quota/limit`, same endpoint the official usage panel reads — 5-hour / weekly window **remaining** and reset time. Upstream `number` is not a total comparable with remaining, so like the CLI/TUI only remaining is shown, and a bar is drawn only when upstream reports a percentage). CLI: `bun run src/index.ts quota` (HTTP: `GET /quota`). The upstream gateways rate-limit frequent queries, so the panel does not poll on a timer.
diff --git a/src/android/control.ts b/src/android/control.ts
index d9102dc..07a25bd 100644
--- a/src/android/control.ts
+++ b/src/android/control.ts
@@ -181,6 +181,23 @@ export interface ControlHandlerResult {
body: ControlResponse;
}
+/**
+ * Build an in-process dispatcher for the control protocol: identical command
+ * semantics to `POST /control`, but no listener and no loopback check — the
+ * caller owns its transport and must guard it (token, origin, size limits).
+ *
+ * The Android shell keeps using {@link startControlListener}. Embedders that
+ * already expose their own authenticated HTTP surface (the `serve` web panel)
+ * use this instead, so a reachable panel does not also open a second,
+ * unauthenticated port that can run stopProxy / logout / shutdown.
+ */
+export function createControlDispatcher(
+ state: ControlState,
+ ctx: HandlerContext,
+): (cmd: ControlCommand) => Promise {
+ return (cmd) => dispatch(cmd, state, ctx);
+}
+
/** Context passed to `handleControlRequest` for hook wiring + log access. */
export interface HandlerContext {
onStartProxy?: () => Promise;
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 b604e88..32fea61 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,7 +5,12 @@
import { loadConfig } from "./config/loader.js";
import { AuthManager } from "./auth/manager.js";
import { startServer, type ProxyServer } from "./server/server.js";
-import { startControlListener, LogBuffer, type ControlState } from "./android/control.js";
+import {
+ startControlListener,
+ createControlDispatcher,
+ LogBuffer,
+ type ControlState,
+} from "./android/control.js";
import { collectQuotaSnapshot } from "./server/routes-quota.js";
import { loadCredential, saveCredential, clearCredential, getStorePath } from "./auth/store.js";
import { ZaiOAuthClient, BigmodelOAuthClient, BigmodelPollOAuthClient, LOGIN_TIMEOUT_MS, parsePastedCallbackUrl, type OAuthResult } from "./auth/oauth.js";
@@ -17,6 +22,13 @@ import { updateConfigYaml, ensureConfigFile } from "./config/edit.js";
import { openBrowser } from "./runtime/open-browser.js";
import { pasteLoginInstructions, readPastedLine, boldIfTTY } from "./runtime/paste-login.js";
import { buildServerOptions } from "./server/server-options.js";
+import {
+ resolvePanelSettings,
+ startPanelServer,
+ type ControlDispatcher,
+ type PanelServer,
+ type PanelSettings,
+} from "./server/panel.js";
import { readFileSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
@@ -166,6 +178,178 @@ Examples:
`);
}
+/**
+ * Mirror console output into a ring buffer so the panel's Logs card has data.
+ * Same tee the Android entry installs — the buffer is also what `getLogs` reads.
+ */
+function installLogTee(): LogBuffer {
+ const buffer = new LogBuffer();
+ const origLog = console.log;
+ const origErr = console.error;
+ const origWarn = console.warn;
+ console.log = (...args: unknown[]) => { buffer.push(args.join(" ")); origLog(...args); };
+ console.error = (...args: unknown[]) => { buffer.push("[error] " + args.join(" ")); origErr(...args); };
+ console.warn = (...args: unknown[]) => { buffer.push("[warn] " + args.join(" ")); origWarn(...args); };
+ return buffer;
+}
+
+/**
+ * Start the optional web panel for `serve`. `serve` has no TUI, so this is the
+ * only way to see quota, read live logs or switch provider/plan on a headless
+ * box without `docker exec`.
+ *
+ * Commands are dispatched in-process through `createControlDispatcher()` — the
+ * same protocol the Android shell drives over `POST /control`, without opening a
+ * second, unauthenticated loopback port. The panel token is therefore the only
+ * way in.
+ *
+ * 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,
+ ctx: {
+ config: ProxyConfig;
+ path: string;
+ 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, 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,
+ plan: config.plan,
+ proxyPort: serverRef.current?.port ?? 0,
+ };
+
+ async function startProxy(): Promise<{ ok: true; port: number } | { ok: false; error: string }> {
+ if (serverRef.current) return { ok: false, error: "already_running" };
+ 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;
+ console.log(`zcode-proxy listening on http://${s.hostname}:${s.port}`);
+ return { ok: true, port: s.port };
+ } catch (err) {
+ return { ok: false, error: (err as Error).message };
+ }
+ }
+
+ async function stopProxy(): Promise<{ ok: true } | { ok: false; error: string }> {
+ const s = serverRef.current;
+ if (!s) return { ok: false, error: "not_running" };
+ try {
+ s.stop(false);
+ serverRef.current = null;
+ console.log("zcode-proxy stopped");
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: (err as Error).message };
+ }
+ }
+
+ async function setConfig(changes: {
+ provider?: ProviderId;
+ plan?: "coding-plan" | "start-plan";
+ }): Promise<{ ok: true; provider: ProviderId; plan: "coding-plan" | "start-plan" } | { ok: false; error: string }> {
+ if (serverRef.current) return { ok: false, error: "stop_proxy_first" };
+ if (changes.provider) config.provider = changes.provider;
+ if (changes.plan) config.plan = changes.plan;
+ updateConfigYaml(path, { provider: config.provider, plan: config.plan });
+ console.log(`config updated: provider=${config.provider} plan=${config.plan}`);
+ return { ok: true, provider: config.provider, plan: config.plan };
+ }
+
+ // Dispatched in-process: the panel already guards its own transport with a
+ // 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 dispatchControl = createControlDispatcher(controlState, {
+ logBuffer,
+ onStartProxy: startProxy,
+ onStopProxy: stopProxy,
+ onSetConfig: setConfig,
+ onQuota: () => collectQuotaSnapshot(config),
+ });
+
+ /** 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,
+ handleControl,
+ });
+ console.log(`panel: http://${panel.hostname}:${panel.port} (token required)`);
+
+ return panel;
+}
+
async function serve(configPath: string | undefined, debug: boolean): Promise {
const path = configPath ?? process.env.ZCODE_PROXY_CONFIG ?? "config.yaml";
if (ensureConfigFile(path)) {
@@ -175,6 +359,11 @@ 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}`));
@@ -212,12 +412,75 @@ 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, {
+ config,
+ path,
+ auth,
+ serverRef,
+ logBuffer: panelLogBuffer,
+ shutdown,
+ });
+ } catch (err) {
+ // The panel is a convenience layer; it must never take the proxy down.
+ console.error(`[panel] failed to start: ${(err as Error).message}`);
+ }
+ }
+
process.on("SIGINT", () => {
console.log("\nShutting down...");
- server.stop(true);
+ shutdown();
});
process.on("SIGTERM", () => {
- server.stop(true);
+ shutdown();
});
}
diff --git a/src/server/panel-page.txt b/src/server/panel-page.txt
new file mode 100644
index 0000000..90f8877
--- /dev/null
+++ b/src/server/panel-page.txt
@@ -0,0 +1,414 @@
+
+
+
+
+
+
+ ZCode Proxy · Panel
+
+
+
+
+
ZCode Proxy · Panel
+
+ not connected
+
+
+
+
+
+
+
+
Status
+
+
Provider
—
+
Plan
—
+
Proxy port
—
+
Logged in
—
+
+
+
+
+
+
+
+
+
+
Quota (manual refresh — the billing gateway rate-limits polling)
+
+
+
+
+
No data yet.
+
+
+
+
Config
+
+
+
+
+
+
+
+
+
Writes back to config.yaml; the proxy must be stopped first.
+
+
+
+
Account
+
+
+
+
+
+
+
+
+
+
Logs (live, last 500 lines)
+
+
+
+
+
+
+
+
+
+ Last raw request / response
+
—
+
+
+
+
+
+
+
diff --git a/src/server/panel.test.ts b/src/server/panel.test.ts
new file mode 100644
index 0000000..dc87074
--- /dev/null
+++ b/src/server/panel.test.ts
@@ -0,0 +1,350 @@
+/**
+ * Tests for the optional web panel (issue #58): token enforcement, the
+ * 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 {
+ DEFAULT_PANEL_PORT,
+ PANEL_ENABLED_ENV,
+ PANEL_PORT_ENV,
+ PANEL_TOKEN_ENV,
+ isPanelEnabled,
+ resolvePanelSettings,
+ 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";
+
+/** Same shape the serve entry passes to `startPanelServer`. */
+type Dispatcher = (cmd: ControlCommand) => Promise;
+
+let panels: PanelServer[] = [];
+
+afterEach(async () => {
+ await Promise.all(panels.map((panel) => panel.close().catch(() => {})));
+ panels = [];
+});
+
+/** Panel bound to a free port; the token is always the test token. */
+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;
+}
+
+function panelUrl(panel: PanelServer, path: string): string {
+ return `http://127.0.0.1:${panel.port}${path}`;
+}
+
+function controlRequest(panel: PanelServer, init: RequestInit = {}): Promise {
+ return fetch(panelUrl(panel, "/api/control"), {
+ method: "POST",
+ body: JSON.stringify({ cmd: "status" }),
+ ...init,
+ });
+}
+
+describe("isPanelEnabled", () => {
+ it("stays off unless the flag is explicitly truthy", () => {
+ expect(isPanelEnabled({})).toBe(false);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "" })).toBe(false);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: " " })).toBe(false);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "0" })).toBe(false);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "false" })).toBe(false);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "FALSE" })).toBe(false);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "no" })).toBe(false);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "off" })).toBe(false);
+ });
+
+ it("accepts the usual truthy spellings, case-insensitively", () => {
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "1" })).toBe(true);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "true" })).toBe(true);
+ expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "ON" })).toBe(true);
+ });
+});
+
+describe("resolvePanelSettings", () => {
+ it("returns null while the panel is off, whatever else is set", () => {
+ expect(resolvePanelSettings({ [PANEL_TOKEN_ENV]: TOKEN })).toBeNull();
+ });
+
+ 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,
+ });
+ });
+
+ it("honours an explicit panel port", () => {
+ expect(
+ resolvePanelSettings({
+ [PANEL_ENABLED_ENV]: "true",
+ [PANEL_TOKEN_ENV]: TOKEN,
+ [PANEL_PORT_ENV]: "9100",
+ }),
+ ).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();
+ });
+});
+
+describe("startPanelServer", () => {
+ it("refuses to start without a token", async () => {
+ 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: " ", handleControl })).rejects.toThrow(
+ /panel token required/,
+ );
+ });
+
+ it("binds loopback on a free port and reports the real one", async () => {
+ 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();
+ 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();
+
+ for (const path of ["/", "/panel"]) {
+ const res = await fetch(panelUrl(panel, path));
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toContain("text/html");
+ const html = await res.text();
+ expect(html).toContain("ZCode Proxy");
+ expect(html).toContain("/api/control");
+ }
+
+ const health = await fetch(panelUrl(panel, "/healthz"));
+ expect(health.status).toBe(200);
+ 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();
+
+ const missing = await fetch(panelUrl(panel, "/nope"));
+ expect(missing.status).toBe(404);
+ expect(await missing.json()).toEqual({ ok: false, error: "not_found: GET /nope" });
+
+ const wrongMethod = await fetch(panelUrl(panel, "/api/control"));
+ expect(wrongMethod.status).toBe(405);
+ expect(await wrongMethod.json()).toEqual({ ok: false, error: "method_not_allowed" });
+ });
+});
+
+describe("panel control authentication", () => {
+ 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[] = [
+ {},
+ { headers: { authorization: "Bearer wrong" } },
+ { headers: { "x-panel-token": "wrong" } },
+ { headers: { authorization: TOKEN } }, // missing the "Bearer " prefix
+ ];
+ for (const init of attempts) {
+ const res = await controlRequest(panel, init);
+ expect(res.status).toBe(401);
+ expect(await res.json()).toEqual({ ok: false, error: "unauthorized" });
+ }
+ expect(calls).toBe(0);
+ });
+
+ 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" } });
+
+ const viaBearer = await controlRequest(panel, {
+ headers: { authorization: `Bearer ${TOKEN}` },
+ });
+ expect(viaBearer.status).toBe(200);
+
+ expect(seen).toEqual([{ cmd: "quota" }, { cmd: "status" }]);
+ });
+
+ 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: JSON.stringify({ cmd: "nope" }),
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: false, error: "unknown_cmd: nope" });
+ });
+
+ 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 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 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}` },
+ body: "x".repeat(70 * 1024),
+ });
+ expect(res.status).toBe(413);
+ expect(await res.json()).toEqual({ ok: false, error: "request_too_large" });
+ 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
new file mode 100644
index 0000000..96e038d
--- /dev/null
+++ b/src/server/panel.ts
@@ -0,0 +1,277 @@
+/**
+ * Optional web panel for headless deployments (issue #58).
+ *
+ * `serve` has no TUI, and inside Docker there is no terminal to render one
+ * into — today the only way to see quota, switch provider/plan or read live
+ * logs is `docker exec` plus hand-editing `config.yaml` and restarting. This
+ * module exposes the *existing* localhost control protocol
+ * (`src/android/control.ts`, already used by the Android shell) through a
+ * token-guarded HTTP surface plus one embedded page. It adds no new state and
+ * no new upstream calls:
+ *
+ * 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;
+ * - a non-empty `ZCODE_PANEL_TOKEN` is mandatory — no token, no listener;
+ * - 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`, 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. */
+export const PANEL_ENABLED_ENV = "ZCODE_PANEL_ENABLED";
+/** Shared secret for `/api/*`. Required whenever the panel is enabled. */
+export const PANEL_TOKEN_ENV = "ZCODE_PANEL_TOKEN";
+/** Panel listen port (loopback). */
+export const PANEL_PORT_ENV = "ZCODE_PANEL_PORT";
+
+/** Defaults mirror the Android entry's wiring so operators only set one thing. */
+export const DEFAULT_PANEL_PORT = 8090;
+
+/** Control commands are small JSON documents; anything bigger is a mistake. */
+const MAX_BODY_BYTES = 64 * 1024;
+
+/**
+ * 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;
+ /** In-process dispatcher backing `POST /api/control`. */
+ handleControl: ControlDispatcher;
+ /** Bind address. Loopback by default and intentionally not configurable. */
+ hostname?: string;
+}
+
+/** Handle for a running panel; `close()` releases the port. */
+export interface PanelServer {
+ hostname: string;
+ port: number;
+ close(): Promise;
+}
+
+/** Fully resolved panel settings; only produced when the panel should run. */
+export interface PanelSettings {
+ token: string;
+ port: number;
+}
+
+/** Request handler produced by {@link createPanelHandler}. */
+export type PanelHandler = (req: IncomingMessage, res: ServerResponse) => Promise;
+
+/**
+ * True when `ZCODE_PANEL_ENABLED` asks for the panel. Kept deliberately strict
+ * and case-insensitive so `ZCODE_PANEL_ENABLED=0`/`false` stay off.
+ */
+export function isPanelEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
+ const raw = (env[PANEL_ENABLED_ENV] ?? "").trim().toLowerCase();
+ return raw !== "" && raw !== "0" && raw !== "false" && raw !== "no" && raw !== "off";
+}
+
+/**
+ * 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, 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;
+
+ const token = (env[PANEL_TOKEN_ENV] ?? "").trim();
+ if (!token) {
+ console.error(`[panel] ${PANEL_ENABLED_ENV} is set but ${PANEL_TOKEN_ENV} is empty — panel not started`);
+ return null;
+ }
+
+ const port = Number(env[PANEL_PORT_ENV] ?? DEFAULT_PANEL_PORT) || DEFAULT_PANEL_PORT;
+ return { token, port };
+}
+
+/** First value of a possibly-repeated request header. */
+function headerValue(raw: string | string[] | undefined): string | undefined {
+ if (Array.isArray(raw)) return raw[0];
+ return raw;
+}
+
+/** Constant-time token comparison (only the length is revealed). */
+function tokenMatches(provided: string | undefined, expected: string): boolean {
+ if (!provided) return false;
+ const a = Buffer.from(provided, "utf8");
+ const b = Buffer.from(expected, "utf8");
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(a, b);
+}
+
+/** Accept either `Authorization: Bearer ` or `X-Panel-Token: `. */
+function extractToken(req: IncomingMessage): string | undefined {
+ const direct = headerValue(req.headers["x-panel-token"]);
+ if (direct) return direct.trim();
+ const auth = headerValue(req.headers["authorization"]);
+ if (auth && auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim();
+ return undefined;
+}
+
+function sendJson(res: ServerResponse, status: number, body: unknown): void {
+ const payload = JSON.stringify(body);
+ res.writeHead(status, {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": Buffer.byteLength(payload),
+ "cache-control": "no-store",
+ });
+ res.end(payload);
+}
+
+function sendHtml(res: ServerResponse, html: string): void {
+ res.writeHead(200, {
+ "content-type": "text/html; charset=utf-8",
+ "content-length": Buffer.byteLength(html),
+ "cache-control": "no-store",
+ });
+ res.end(html);
+}
+
+/**
+ * Read the request body, rejecting anything above {@link MAX_BODY_BYTES}.
+ * The oversized case keeps draining the socket before answering: replying
+ * while the client is still writing can reset the connection and lose the 413.
+ * Returns `null` when the body is too large.
+ */
+async function readBody(req: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ let tooLarge = false;
+ for await (const chunk of req) {
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
+ size += buf.length;
+ if (size > MAX_BODY_BYTES) {
+ tooLarge = true;
+ continue;
+ }
+ chunks.push(buf);
+ }
+ return tooLarge ? null : Buffer.concat(chunks).toString("utf8");
+}
+
+/**
+ * Build the panel request handler. Exported separately from
+ * {@link startPanelServer} so tests can drive it without binding a port.
+ */
+export function createPanelHandler(opts: PanelOptions): PanelHandler {
+ const token = opts.token;
+ return async (req: IncomingMessage, res: ServerResponse): Promise => {
+ const method = req.method ?? "GET";
+ const url = req.url ?? "/";
+ const path = url.split("?")[0] ?? "/";
+
+ // Static shell + liveness: tokenless by design (see the file header).
+ if (method === "GET" && (path === "/" || path === "/panel")) {
+ sendHtml(res, panelHtml);
+ return;
+ }
+ if (method === "GET" && (path === "/healthz" || path === "/api/panel/health")) {
+ sendJson(res, 200, { ok: true, service: "zcode-panel" });
+ return;
+ }
+
+ if (path !== "/api/control") {
+ sendJson(res, 404, { ok: false, error: `not_found: ${method} ${path}` });
+ return;
+ }
+ if (method !== "POST") {
+ 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;
+ }
+
+ const body = await readBody(req);
+ if (body === null) {
+ sendJson(res, 413, { ok: false, error: "request_too_large" });
+ 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 {
+ // 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) {
+ sendJson(res, 500, { ok: false, error: `internal_error: ${(err as Error).message}` });
+ }
+ };
+}
+
+/**
+ * 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. 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();
+ if (!token) throw new Error(`panel token required (set ${PANEL_TOKEN_ENV})`);
+ const hostname = opts.hostname ?? "127.0.0.1";
+ const handler = createPanelHandler({ ...opts, token });
+ const server: Server = createServer((req, res) => {
+ void handler(req, res);
+ });
+
+ await new Promise((resolve, reject) => {
+ const onError = (err: Error): void => reject(err);
+ server.once("error", onError);
+ server.listen(opts.port, hostname, () => {
+ server.removeListener("error", onError);
+ resolve();
+ });
+ });
+
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : opts.port;
+ return {
+ hostname,
+ port,
+ close: () =>
+ new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ }),
+ };
+}