Skip to content

feat(serve): 可选内置 Web 面板 —— 无头/Docker 部署也能看额度、看日志、切套餐(#58) - #59

Merged
TriDefender merged 3 commits into
TriDefender:masterfrom
Ma6302:panel-webui
Oct 1, 2026
Merged

TriDefender merged 3 commits into
TriDefender:masterfrom
Ma6302:panel-webui

Conversation

@Ma6302

@Ma6302 Ma6302 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

这个 PR 做什么

给 serve(Docker / VPS 无 TTY 的场景,也就是 #58 描述的场景)加一个可选的内置 Web 面板:复用已有的 Android 控制协议与 /quota,提供状态、额度、实时日志、登入登出、启停代理、切换服务商与套餐。

  • 默认关闭:只有 ZCODE_PANEL_ENABLED=1 且 ZCODE_PANEL_TOKEN 非空才启动;token 为空时打印一行提示,代理照常运行。
  • 只绑回环:面板 ZCODE_PANEL_PORT(默认 8090)与控制面 ZCODE_PANEL_CONTROL_PORT(默认 8091)都只监听 127.0.0.1;两个端口配成相同会拒绝启动并提示。
  • /api/* 全部要 token(Authorization: Bearer … 或 X-Panel-Token);静态外壳 GET / 与 GET /healthz 免 token —— 浏览器地址栏发不出自定义头,而这两个响应不含任何账号数据。没有沿用 /webui 的那条鉴权豁免(src/server/server.ts:80 的分支排在 :87 的 proxyApiKey 校验之前);/v1/* 与 proxyApiKey 逻辑一行未动。
  • 面板起不来只打错误日志,绝不拖垮代理;SIGINT/SIGTERM 会一并关掉面板与控制面,退出行为与改动前一致。

环境变量

变量 默认 说明
ZCODE_PANEL_ENABLED 关 设为 1/true 才启用
ZCODE_PANEL_TOKEN 无 启用时必填,用于校验 /api/*
ZCODE_PANEL_PORT 8090 面板端口,仅监听 127.0.0.1
ZCODE_PANEL_CONTROL_PORT 8091 控制面端口(与 Android 同一协议),仅监听 127.0.0.1

无头服务器用法:ZCODE_PANEL_ENABLED=1 ZCODE_PANEL_TOKEN=… docker compose up -d,然后 ssh -L 8090:127.0.0.1:8090 user@host,浏览器打开 http://127.0.0.1:8090。

实现

  • 新增 src/server/panel.ts:resolvePanelSettings() 解析并校验环境变量;startPanelServer() 起面板;页面路由与 POST /api/control。转发是原样的:请求体直接送给 127.0.0.1:<controlPort>/control,状态码与响应体原样回传(控制面的 400 invalid_json、不可达时的 502 都照传,面板不重写协议)。
  • 新增 src/server/panel-page.txt:单页、零外部依赖、<html lang="en">(与 webui.txt 保持一致,项目页面目前都是英文)。额度是手动刷新(按钮 + 60s 冷却):billing 网关会限流频繁查询,和 src/tui/app.ts:249 同一条理由,页面里刻意没有额度轮询定时器;日志用 getLogs 的 since 游标做增量轮询。
  • src/index.ts 的 serve():新增 installLogTee()(与 runAndroid 同构的 console tee)与 startServePanel(),把 startProxy/stopProxy/setConfig/quota/shutdown 接上;runAndroid 与 Android 打包路径未改。

测试

  • 新增 src/server/panel.test.ts:17 个用例 —— 开关真值表、无 token 拒绝启动、未授权请求不触达控制面、两种 token 头都可用、响应原样透传、控制面不可达返回 502、404/405、超 64KB 返回 413、close() 释放端口。
  • 本机:bun x tsc --noEmit 通过;bun test 全量 928 pass / 1 fail,唯一失败是 Windows 本机既有的 captcha worker dispatch … an unloadable worker entry degrades to in-process solving(改动前基线同样 1 fail)。
  • VPS 上 Docker 端到端:GET / 200、/healthz ok、无 token 401、status 正确显示 proxyPort、quota 返回真实快照、getLogs 增量游标推进、运行中 setConfig → stop_proxy_first、stopProxy/startProxy 正常、随后 /v1/chat/completions 200;docker stop 0.15s 优雅退出;不设 ZCODE_PANEL_ENABLED 时日志与端口与上游完全一致。

关联 #58。默认端口、路由风格、页面语言如需调整我再改。

Adds an opt-in panel for `serve` (Docker/VPS boxes without a TTY), the case
raised in TriDefender#58: a loopback-only control listener plus a token-guarded single
page to see status and quota, read live logs and switch provider/plan without
`docker exec` or hand-editing config.yaml.

- Off by default. `ZCODE_PANEL_ENABLED=1` plus a non-empty
  `ZCODE_PANEL_TOKEN` are required; an empty token is refused and the proxy
  keeps running as before.
- The panel listens on 127.0.0.1 only (`ZCODE_PANEL_PORT`, default 8090) and
  drives the existing Android control protocol on 127.0.0.1
  (`ZCODE_PANEL_CONTROL_PORT`, default 8091).
- Every `/api/*` request needs the token. The static shell and `/healthz`
  carry no account data and stay reachable, because a browser navigation
  cannot send a custom header.
- Reuses `POST /control`, `GET /quota` and the YAML writer unchanged. No new
  dependencies, no change to the Android path or to `/v1/*`.
- A panel that fails to start never blocks the proxy.

Tests: `src/server/panel.test.ts` (17 cases), `bun x tsc --noEmit` clean.
Verified end-to-end in Docker on a VPS: quota snapshot, incremental logs,
stop/start, a real `/v1/chat/completions` call, graceful SIGTERM, and
unchanged behaviour when the panel is off.

Refs TriDefender#58

Signed-off-by: Ma6302 <143102004+Ma6302@users.noreply.github.com>

Copy link
Copy Markdown
Owner

审查意见与修复示例(基于已审查提交 0eedcc8d5b5ccbd55a3ef5b7191484f348473c7d)。

整体影响:中等。主要影响主动开启 Web 面板的用户,涉及代理控制权限、服务退出、Docker 部署及额度准确性;默认关闭限制了影响范围。以下代码是实现方向示例,需按现有接口调整,尚未应用或验证为完整补丁。

1. [P1] 新增控制监听器缺少鉴权

位置:src/index.ts:264(上述提交)。

面板 token 只保护 /api/control,新增 /control 监听器仅检查回环地址,没有认证。能访问该地址的调用方可以直接执行 stopProxy、logout 或 shutdown,绕过面板 token。在浏览器能够访问该回环地址的环境下,其他源也可以发送 text/plain 简单 POST,无需读取响应。审查使用无副作用 hook 验证,无鉴权、携带其他 Origin 的 text/plain 请求会执行停止 hook。

建议在面板鉴权后直接调用进程内控制分发,取消额外 HTTP 控制端口;或者为内部监听器添加独立鉴权。

// 示意接口:复用进程内控制分发,不额外开放监听端口。
const dispatchControl = createControlDispatcher(hooks);

const panel = await startPanelServer({
  ...settings,
  handleControl: dispatchControl,
});

面板处理顺序:

if (!isAuthorized(request, settings.token)) {
  return new Response("Unauthorized", { status: 401 });
}

// 保留请求大小限制、JSON 校验和原有控制协议响应语义。
return handleControlRequest(request, dispatchControl);

如保留内部 HTTP 端口,应使用独立随机凭据,并仅对 serve 的控制监听器强制认证,避免破坏 Android 既有调用协议。

2. [P2] 面板启动失败会遗留控制监听器

位置:src/index.ts:278(上述提交)。

控制监听器启动后,如果面板端口被占用,startPanelServer 抛错,调用方的 panelRuntime 保持 null,无法清理已启动的控制监听器。日志报告面板启动失败,但控制端口仍可执行命令;经它停止代理后再发送 SIGTERM,也可能无法退出。

如果继续保留两个监听器,应在第二步失败时回滚第一步:

const control = await startControlListener(controlOptions);

try {
  const panel = await startPanelServer(panelOptions);
  return { control, panel };
} catch (error) {
  await control.close();
  throw error;
}

close() 需替换为项目实际关闭接口并等待释放。采用第 1 项的进程内分发方案也可消除此双监听器清理问题。

3. [P2] Docker 部署说明无法连接容器回环面板

位置:README.md:191(上述提交)。

Docker/Compose 示例使用 bridge 网络且只发布 8080,面板却监听容器自己的 127.0.0.1。文档中的 SSH 隧道连接宿主机 127.0.0.1,因此即使将面板环境变量正确传入容器,也无法按说明连接;仅增加 -p 8090:8090 同样无法访问容器回环监听。

建议提供明确可用的 Docker 接入配置并同步英文说明。例如 Linux VPS 上的 host 网络方式:

services:
  zcode:
    # 保留现有 image、command、volumes 等配置。
    network_mode: host
    environment:
      ZCODE_PANEL_ENABLED: "1"
      ZCODE_PANEL_TOKEN: "${ZCODE_PANEL_TOKEN:?请设置面板 token}"
      ZCODE_PANEL_PORT: "8090"
    # host 网络模式下移除原有 ports 配置。

本地建立隧道:

ssh -N -L 8090:127.0.0.1:8090 user@host

需明确这是 Linux host 网络部署方式,代理主端口也直接使用宿主机网络,应相应核对监听地址和防火墙配置。

4. [P2] 将上游 number 错当成额度总数

位置:src/server/panel-page.txt:284(上述提交)。

collectQuotaSnapshot 将上游 number 原样映射到 limit.total,但这不是与 remaining 可比较的总额度。既有 CLI 明确记录真实数据 remaining=3894, number=1,因此仅显示剩余额度。新面板会显示 3894 left of 1,通过 used / total 回退计算的进度也会误导用户。

建议沿用现有 CLI/TUI 语义,仅展示 remaining,并只在上游提供有效 percentage 时显示比例:

remainingElement.textContent =
  typeof limit.remaining === "number"
    ? `${limit.remaining.toLocaleString()} left`
    : "Unavailable";

const percentage = limit.percentage;
const hasPercentage =
  typeof percentage === "number" &&
  Number.isFinite(percentage) &&
  percentage >= 0 &&
  percentage <= 100;

progressElement.hidden = !hasPercentage;

if (hasPercentage) {
  progressElement.value = percentage;
}

比例文案及方向应遵循上游定义,不自行假定代表“已用”或“剩余”。

建议验证:无凭据控制请求不能触发 hook;面板启动失败后控制端口被释放;Docker 文档步骤可连通;remaining=3894 / number=1 不再显示错误总额。

按 @TriDefender 的评审修正四处:

- P1:面板不再额外开一个"只靠回环地址保护"的控制监听器。
  `src/android/control.ts` 仅新增 `createControlDispatcher(state, ctx)` 导出
  (内部 dispatch/Android 路径不变),`serve` 把它交给面板,
  `/api/control` 的顺序仍是「鉴权 401 → 体积 413 → invalid_json /
  invalid_command → 分发」,少一个绕过 token 的 HTTP 入口。
- P2:面板启动失败不再遗留可执行命令的监听器(该监听器已不存在);
  端口被占时只打一行日志,代理照常服务。
- P2(文档):Docker 说明改为 host 网络 + `network_mode: host` 的 compose
  片段,写清 bridge / `-p 8090:8090` 都到不了容器回环,并附
  `ssh -N -L 8090:127.0.0.1:8090 user@host` 隧道;README_EN.md 同步。
- P2(额度):面板不再把上游 `number` 当总额度。编码套餐窗口只显示
  `<remaining> <unit> remaining`,只有上游给出有效 `percentage`(0–100)
  时才按 `1 - pct/100` 画剩余比例条,与 CLI/TUI 的既有语义一致。

验证:`bun x tsc --noEmit` 通过;`bun test src/server/panel.test.ts` 22 pass
(含"未带 token 不分发""端口被占不影响已在跑的实例");
全量 `bun test` 932 pass / 1 fail(唯一失败为既有的 Windows captcha worker 用例)。

Signed-off-by: Ma6302 <143102004+Ma6302@users.noreply.github.com>
@Ma6302

Ma6302 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

已按评审全部改完,修复放在第二个提交 f594c5a(分支 panel-webui:0eedcc8d → f594c5aa,PR 累计 7 files / +1268 / -5)。

1. [P1] 控制监听器缺少鉴权 —— 采纳方案一,取消额外 HTTP 控制口

serve 的启动路径里不再有第二个监听器:面板通过 createControlDispatcher 在进程内直接调用控制分发。src/android/control.ts 只新增一个导出,内部 dispatch 与 Android 路径一字未动:

export function createControlDispatcher(state: ControlState, ctx: HandlerContext): (cmd: ControlCommand) => Promise<ControlResponse> {
  return (cmd) => dispatch(cmd, state, ctx);
}

serve 侧把它和现有 hook 接起来交给面板:

const handleControl = createControlDispatcher(controlState, {
  logBuffer, onStartProxy, onStopProxy, onSetConfig, onQuota, onShutdown,
});
const panel = await startPanelServer({ port: settings.port, token: settings.token, handleControl });

/api/control 的顺序保持「鉴权(401)→ 体积上限(413)→ invalid_json / invalid_command → 分发」,响应仍是控制层原信封(未知命令原样 200 {ok:false,error:"unknown_cmd: …"},分发抛错 500 internal_error: …),请求大小限制与 JSON 校验都留着。PanelSettings 现在只有 {token, port},ZCODE_PANEL_CONTROL_PORT 与其端口冲突检查一并删除。

服务器复验(阿里云北京,host 网络,镜像基于本 PR 提交构建):

  • 新实例只监听 0.0.0.0:8098(代理)与 127.0.0.1:8092(面板)。8091 上唯一的监听者仍是评审前就在跑的旧原型进程 —— 新进程没有开任何控制口。
  • 不带 token:POST /api/control {"cmd":"stopProxy"} → 401 {"ok":false,"error":"unauthorized"},同一时刻 GET /v1/models 仍是 200(hook 未被触发)。
  • 带 token:同一命令 → {"ok":true,"event":"proxyStopped"},8098 立即不再监听,status 回报 proxyPort: 0;随后 startProxy → {"ok":true,"event":"proxyStarted","port":8098},/v1/models 恢复 200。
  • 面板关闭(不设 ZCODE_PANEL_ENABLED)与"开了但 token 为空"两种情形仍与之前一致:前者无 panel/control 日志、无面板端口、代理 200;后者只打一行 [panel] ZCODE_PANEL_ENABLED is set but ZCODE_PANEL_TOKEN is empty — panel not started。
  • 面板开启时 docker stop 用时 0.139s(优雅退出,不挂起)。

2. [P2] 启动失败遗留控制监听器

P1 之后启动路径里已不存在第二个监听器,该问题随之消失。复验:另起一个实例、故意把面板端口设成已被占用的 8092 —— 日志只有一行 [panel] failed to start: Failed to start server. Is port 8092 in use?,代理继续在 8097 正常服务,没有冒出新的控制口,原实例面板 /healthz 仍 200。测试里也留了一条 fails on a taken port without disturbing the panel already there。

3. [P2] Docker 说明连不上容器回环面板

README.md 与 README_EN.md 同步改写:说明 bridge 网络与 -p 8090:8090 都到不了容器的回环地址,给出 network_mode: host 的 compose 片段(host 模式删掉 ports:,环境变量 ZCODE_PANEL_ENABLED / ZCODE_PANEL_TOKEN / ZCODE_PANEL_PORT),以及 ssh -N -L 8090:127.0.0.1:8090 user@host 隧道,并提醒 host 网络下代理主端口也直接占用宿主机端口、照旧只放行 8080、不要对外开 8090。上面这次复验就是按这套步骤在服务器上跑通的。

4. [P2] 把上游 number 当总额度

面板的编码套餐窗口现在只渲染 <remaining> <unit> remaining(拿不到 remaining 时显示 unavailable),比例条仅在 percentage 为有限数且落在 0–100 时按 1 - pct/100(剩余比例)绘制;left of <total> 与 used/total 回退已删除,方向与文案沿用上游定义,没有自行贴"已用 / 剩余"标签。积分桶那一路用的是真实的 remainingUnits / totalUnits,保持原样。测试 never fabricates a total for a coding-plan limit 断言页面里不再出现 left of 与 limit.total;线上 GET / 返回的 15,629 字节中两者均为 0 次。

测试

新增/覆盖:未带 token 时连超大请求体也只回 401(不分发、不读体)、invalid_json、invalid_command、413、500 internal_error、unknown_cmd 原样透传、端口被占的回退,以及用真实 createControlDispatcher + LogBuffer 端到端跑一次 stopProxy(不带 token → 401 且 hook 不触发;带 token → 真正停掉代理、state.proxyPort 归零)。

bun x tsc --noEmit 通过;bun test src/server/panel.test.ts 22 pass;全量 bun test 932 pass / 1 fail,唯一失败是 Windows 上既有的 captcha worker 用例(与本 PR 无关)。

需要我再调整的地方说一声,我随时改。

Copy link
Copy Markdown
Owner

复审基于提交 f594c5aa686f67ef38a79cbd925c5c149c54f5ba,完整核对 7 个文件(+1268 / −5)后,发现以下两项需要修复的问题。

[P2] 代理停止后仍须执行进程退出

位置:src/index.ts:374。

在面板点击 Stop proxy 后,stopProxy 将 serverRef.current 设为 null,此时 SIGTERM/SIGINT 处理器中的可选调用不再执行退出操作。默认启用的 startAutoClaim 存在持续重新安排、未 unref 的定时器,start-plan 验证码池也有定时器,因此只关闭面板监听器无法保证进程自然结束。Docker stop 会等待超时后强杀,终端 Ctrl+C 也无法正常退出;新增 onShutdown 在这一状态下同样可能只返回成功而不关闭进程。

建议将进程退出及后台任务清理与当前代理句柄是否存在解耦。验证场景:启动面板 → Stop proxy → 分别执行 SIGTERM、SIGINT 和 shutdown,确认后台任务被清理且进程及时退出。

[P2] 将面板登出同步到运行中的认证状态

位置:src/server/panel-page.txt:391。

代理运行时点击 Logout 仅派发已有的 logout 命令;该命令删除磁盘凭据,但不停止代理,也不清除 serve 持有的 AuthManager.oauthCred。因此页面显示未登录后,新的 /v1 请求仍使用原账号,自动领取任务也仍优先读取内存凭据。随后通过面板登录另一账号,status/quota 会显示新账号,而实际代理继续使用旧账号,直到手动停止并启动代理。

建议在登出及切换账号时同步更新或失效运行中的认证状态,并协调代理生命周期,避免登出成功后继续消费旧账号额度。验证场景:账号 A 运行代理 → 登出 → 确认新请求不再使用 A;登录 B → 确认状态、额度、代理请求及自动领取所用账号一致。

整体影响评估:中等。主要影响主动启用 Web 面板的 Docker/VPS 用户,涉及服务退出和账号使用;默认关闭面板时未发现请求转发路径受到普遍影响。

按 @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>
@Ma6302

Ma6302 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

两条都改了,修复放在第三个提交 a23794b(分支 panel-webui:0eedcc8d → f594c5aa → a23794be,PR 累计 9 files / +1409 / -7)。下面按你给的两个验证场景说明,并在 VPS(Ubuntu,host 网络,plan: start-plan,claim.auto 与验证码池都处于启用状态)上实测。

[P2] 代理停止后仍须执行进程退出 — 已改

根因与你描述的一致:stopProxy 把 serverRef.current 置 null 后,信号处理器里那条可选调用不再触发,而未 unref 的自动领取定时器(src/claim/scheduler.ts 的 scheduleNext)与验证码池的 setInterval(src/proxy/captcha-pool.ts 的 startBackgroundRefill)继续吊着事件循环,所以进程不会自然结束。

按你的建议把退出与后台清理从代理句柄上解耦:

  • serve() 留住动态 import 的句柄:claimScheduler = startAutoClaim(...)、captchaModule = await import("./proxy/captcha.js")(这两个返回值之前是丢掉的,导致没有可用于清理的引用);
  • 新增幂等的 shutdown()(shuttingDown 守卫,重复调用无副作用):关面板 → claimScheduler.stop() → captchaModule.shutdownCaptcha() → 若有代理则 stop(true)(其内部 process.exit(0)),否则直接 process.exit(0);
  • SIGINT / SIGTERM 与面板的 shutdown 命令现在共用这一条路径,是否退出不再取决于 serverRef.current 是否存在;
  • 面板回 shutdown 时先写响应、setTimeout(shutdown, 50) 之后再退出,避免在 hook 里 process.exit() 截断 HTTP 应答(control.ts 的 onShutdown 是在响应之前执行的,所以没有走它)。

实测(每个场景单独一个容器,测前 docker rm -f 并确认端口空闲):

场景 结果
面板 Stop proxy → SIGTERM 95ms 退出,exit 0,日志 shutdown: cleared auto-claim + captcha pool timers
面板 Stop proxy → SIGINT 114ms 退出,exit 0,日志 Shutting down...
代理仍在跑 → 面板 shutdown 先收到完整 {"ok":true,"event":"shuttingDown"},297ms 后容器自行退出,exit 0

第三条同时说明 50ms 的写出延迟够用;三条都能在日志里看到定时器被显式清掉。

[P2] 将面板登出同步到运行中的认证状态 — 已改

AuthManager 新增 clearOAuthCredential();control.ts 与 Android 控制协议未改动。面板在每条成功命令之后比对凭据文件指纹,与内存不一致就同步:

  • 磁盘上有凭据 → auth.setOAuthCredential(onDisk),日志 auth: switched to the account now on disk;
  • 磁盘上没有 → auth.clearOAuthCredential(),日志 auth: credential cleared (logged out);
  • logout 且代理在跑 → 清掉内存凭据后 stopProxy(),status 回到 proxyPort: 0 / loggedIn: false,日志 panel: logout cleared the live credential — proxy stopped。登出的语义就是"立刻不再消费这个账号",代理停在原地等下次 startProxy,不做"先停代理再让用户手动启动"的绕路。

实测(代理运行中,/v1/models 基线 200):

  • 把磁盘凭据删掉、再让面板跑一条命令 → 下一个 POST /v1/chat/completions 返回 503 {"error":{"type":"credential_unavailable","message":"OAuth credential not available — run: zcode-proxy auth login"}}。这条是内存凭据确实被清掉的证据——不是只改了磁盘;恢复凭据后再跑一条命令,日志出现 auth: switched to the account now on disk,请求恢复 200;
  • 面板 logout:{"ok":true,"event":"loggedOut"},8098 立刻不再监听,status 为 proxyPort: 0 / loggedIn: false,startProxy 返回 {"ok":false,"error":"not_logged_in"};
  • 删除失败时(凭据目录不可写)logout 返回 internal_error: EACCES ...,代理继续 200、内存凭据保持原样——失败即安全,不会留下半截状态。

需要说明一个边界:没有真的用第二个账号做 A→B 换号(手上只有一个可用账号),上面是用"凭据文件删除 / 恢复"驱动同一条指纹路径来验证的。自动领取拿到的是同一个 AuthManager 实例(startAutoClaim(config, auth)),换号后它读到的是同一份内存凭据、无需重启进程——但这一条只是代码路径上的判断,没有第二个账号做端到端证实,如果你有更好的验证办法我照做。

其他

  • 页面的登出确认文案改成 "Log out and delete the stored credential? The proxy stops using it immediately and is stopped if it is running.";
  • src/auth/manager.test.ts 新增两个用例(清除后 getCredential() 抛 not available;清除后可以再写入新凭据);
  • bun x tsc --noEmit 通过;全量 bun test 935 pass / 1 fail(唯一失败是既有的 Windows captcha worker 用例,与本改动无关);
  • README.md / README_EN.md 各加了一句说明这两处行为(停止代理不再吊住进程、登出会同步运行中的凭据并停代理);
  • 补丁按上次的约定重生成(LF、无 CRLF),在干净的上游 v4.7.5 树上 git apply --check 与 git apply 均通过,git write-tree = f61808dcc664def3f79c5bad465aacee19942d86,与 GitHub 上该提交的 tree 逐字节一致。

@TriDefender

Copy link
Copy Markdown
Owner

lgtm
先merge了

@TriDefender
TriDefender merged commit ddc1847 into TriDefender:master Oct 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants