Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 体验套餐)在面板里按 <kbd>t</kbd> 切换,会写回 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。

</details>

<details>
Expand All @@ -193,7 +220,7 @@ services:

**周末/体验套餐自动领取 (claim)** —— 默认开启。代理每 5 分钟探测一次官方的限量套餐活动页,上新瞬间自动帮你抢(`claim.enabled: false` 可关闭)。手动抢:`bun run src/index.ts claim`。

**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 <kbd>r</kbd> 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的剩余 / 总量与重置时间)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。
**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 <kbd>r</kbd> 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的**剩余额度**与重置时间——上游 `number` 不是可与剩余比较的总量,所以与 CLI/TUI 一致只显示剩余,只有上游给出百分比时才画比例条)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。

</details>

Expand Down
29 changes: 28 additions & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>t</kbd>, 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=<your own random string>`, 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.

</details>

<details>
Expand All @@ -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 <kbd>r</kbd>. 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 <kbd>r</kbd>. 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.

</details>

Expand Down
17 changes: 17 additions & 0 deletions src/android/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ControlResponse> {
return (cmd) => dispatch(cmd, state, ctx);
}

/** Context passed to `handleControlRequest` for hook wiring + log access. */
export interface HandlerContext {
onStartProxy?: () => Promise<LifecycleResult>;
Expand Down
15 changes: 15 additions & 0 deletions src/auth/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
12 changes: 12 additions & 0 deletions src/auth/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading
Loading