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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup,
code style, commit conventions, and the PR checklist. Notable changes are
recorded in [CHANGELOG.md](CHANGELOG.md).

## Related Projects

- [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) — a community Python implementation that bridges ZCode to the MCP/ACP ecosystem. The design of this server references its bridge architecture and several handling strategies.

## Acknowledgements

- [Agent Client Protocol](https://agentclientprotocol.com/) (Apache-2.0) — the ACP specification
- [ZCode](https://zcode.z.ai) / [Zhipu Z.AI](https://z.ai) — the GLM model and ZCode CLI
- [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) — reference implementation that informed this server's design

## License

Apache-2.0. This project follows the same license as the upstream ACP specification.

## Disclaimer

This is an independent community project and is not affiliated with, endorsed
by, or sponsored by Zhipu Z.AI. ZCode is a product of Zhipu Z.AI.
14 changes: 14 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ CI 会在每次 push 和 pull request 时运行 `typecheck`、`lint`、`build`
欢迎贡献!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解环境搭建、代码风格、
commit 约定和 PR 检查清单。重要变更记录在 [CHANGELOG.md](CHANGELOG.md)。

## 相关项目

- [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) —— 一个社区 Python 实现,将 ZCode 接入 MCP/ACP 生态。本项目参考了它的桥接架构和若干处理策略。

## 致谢

- [Agent Client Protocol](https://agentclientprotocol.com/)(Apache 2.0)—— ACP 协议规范
- [ZCode](https://zcode.z.ai) / [智谱 Z.AI](https://z.ai) —— GLM 模型与 ZCode CLI
- [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) —— 参考实现,本项目的设计借鉴了它的桥接架构

## 许可证

Apache-2.0。本项目沿用上游 ACP 规范的同一许可证。

## 免责声明

本项目为独立的社区项目,与智谱 Z.AI 官方无任何隶属、认可或赞助关系。ZCode 是智谱 Z.AI 的产品。
40 changes: 40 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Security Policy

## What This Project Touches

This is a stdio ACP bridge — no network listener, no own auth. However, in
order to bridge the headless ZCode CLI to ACP-compatible editors, it does
read and write a small number of sensitive files owned by the ZCode app:

| Path | Read/Write | What it touches |
|------|-----------|-----------------|
| `~/.zcode/v2/config.json` | read | Reads the active provider's `baseURL` and `apiKey` (in `backend/credentials.ts`) and the model list (in `config/options.ts`, `config/runtime-model.ts`). The `apiKey` is forwarded to the ZCode subprocess via an environment variable (`ANTHROPIC_API_KEY`) and is never written to logs, stdout, or any other file. |
| `~/.zcode/v2/tasks-index.sqlite` | **read/write** | Inserts/updates rows in the `tasks` table (`tasks-index.ts`) so that sessions created via ACP appear in the ZCode app's UI. Only the `tasks` table is touched, using `INSERT OR IGNORE` / bounded `UPDATE`. |

The bridge does **not**:
- send credentials, tokens, or session data anywhere except the local ZCode subprocess;
- modify the ZCode CLI binary, the app, or any file outside `tasks-index.sqlite`;
- expose any network port.

## Reporting a Vulnerability

If you find a way this bridge leaks credentials, corrupts the tasks-index, or
escapes its stdio boundary, please report it privately rather than opening a
public issue:

- Open a private security advisory via GitHub's
[Report a vulnerability](https://github.com/william0wang/zcode-acp/security/advisories/new),
or
- email the maintainer (see the GitHub profile).

Please include the affected file/line, a description of impact, and a
reproduction if possible. You should hear back within **72 hours**.

## Out of Scope

Report these to the upstreams, not here:

- The ZCode CLI itself, the ZCode app, or the `config.json` format — these
are owned by [Zhipu Z.AI](https://z.ai).
- The editor (Zed, JetBrains) or the ACP specification.
- Issues that require already having code execution on the host.
49 changes: 45 additions & 4 deletions src/backend/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
* workers) with `process.kill(-pid)` and leave no orphans.
*/

import { spawn, type ChildProcess } from "node:child_process";

Check warning on line 18 in src/backend/client.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Member 'ChildProcess' of the import declaration should be sorted alphabetically
import { createInterface } from "node:readline";
import process from "node:process";

import { log } from "../utils.js";

Check warning on line 22 in src/backend/client.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Imports should be sorted alphabetically
import type {

Check warning on line 23 in src/backend/client.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Expected 'multiple' syntax before 'single' syntax
ZcodeEvent,
ZcodeInbound,
ZcodeInteractionPermissionParams,
Expand Down Expand Up @@ -53,6 +53,9 @@
private readonly serverRequests: ServerRequest[] = [];
private readonly listeners = new Map<string, EventListener>();
private readerDead = false;
/** Monotonic id for fire-and-forget sends (send()). Uses a high range to
* avoid collisions with the server's request ids (low range). */
private sendIdCounter = 1_000_000_000;
/** Watchdog process that kills the zcode group if this bridge dies (SIGKILL). */
private watchdog: ChildProcess | null = null;

Expand Down Expand Up @@ -213,7 +216,17 @@
log("backend: sendReply dropped (stdin closed)");
return;
}
stdin.write(JSON.stringify({ id, result }) + "\n");
try {
stdin.write(JSON.stringify({ id, result }) + "\n");
} catch (e) {
// Broken pipe: backend is gone. Mark reader dead so the rest of the
// bridge stops trying to talk to it; otherwise zcode reannounces would
// keep hitting a dead pipe and the dedup cache would spin forever.
this.readerDead = true;
log(
`backend: sendReply write failed (marked dead): ${e instanceof Error ? e.message : String(e)}`,
);
}
Comment on lines +219 to +229

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Node.js, stream.write() is asynchronous and does not throw synchronous exceptions for write errors like EPIPE (broken pipe). Instead, these errors are emitted as 'error' events on the stream (stdin).

The synchronous try/catch block here will not catch these errors, and if there is no 'error' event listener registered on this.proc.stdin, the entire Node.js process will crash with an unhandled 'error' event.

To fix this robustly:

  1. Register an 'error' event listener on this.proc.stdin (e.g., in the constructor of ZcodeBackend or during initialization) to handle write errors and mark the reader dead:
    this.proc.stdin?.on('error', (err) => {
      this.readerDead = true;
      log(`backend: stdin error: ${err.message}`);
    });
  2. Remove the redundant and ineffective try/catch blocks around stdin.write calls in sendReply and sendError.
    stdin.write(JSON.stringify({ id, result }) + "\n");

}

/** Reply to a zcode server→client request with an error. */
Expand All @@ -223,7 +236,14 @@
log("backend: sendError dropped (stdin closed)");
return;
}
stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n");
try {
stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n");
} catch (e) {
this.readerDead = true;
log(
`backend: sendError write failed (marked dead): ${e instanceof Error ? e.message : String(e)}`,
);
}
Comment on lines +239 to +246

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Node.js, stream.write() is asynchronous and does not throw synchronous exceptions for write errors like EPIPE (broken pipe). Instead, these errors are emitted as 'error' events on the stream (stdin).

The synchronous try/catch block here will not catch these errors, and if there is no 'error' event listener registered on this.proc.stdin, the entire Node.js process will crash with an unhandled 'error' event.

To fix this robustly:

  1. Register an 'error' event listener on this.proc.stdin (e.g., in the constructor of ZcodeBackend or during initialization) to handle write errors and mark the reader dead:
    this.proc.stdin?.on('error', (err) => {
      this.readerDead = true;
      log(`backend: stdin error: ${err.message}`);
    });
  2. Remove the redundant and ineffective try/catch blocks around stdin.write calls in sendReply and sendError.
    stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n");

}

// ---------- send / request ----------
Expand All @@ -238,6 +258,23 @@
stdin.write(JSON.stringify({ method, params }) + "\n");
}

/**
* Send a message with an id but WITHOUT registering a pending response
* (fire-and-forget). Mirrors Python's `_backend.send({"id": ..., ...})` for
* `session/stop`: some backends route by id presence, so carrying an id is
* more robust than a bare notify. If the backend replies, the reader's
* `resolvePending` finds no pending entry and safely discards it.
*/
send(method: string, params?: Record<string, unknown>): void {
const stdin = this.proc.stdin;
if (!stdin || stdin.destroyed) {
log("backend: send dropped (stdin closed)");
return;
}
const id = this.sendIdCounter++;
stdin.write(JSON.stringify({ id, method, params: params ?? {} }) + "\n");
}

/**
* Synchronous request/response: register a pending promise, send, await.
* Other notifications arriving during the wait are routed async by the
Expand Down Expand Up @@ -304,9 +341,13 @@
}
// Wait up to 3s for a clean exit.
const exited = await new Promise<boolean>((resolve) => {
const done = () => resolve(true);
let timer: ReturnType<typeof setTimeout>;
const done = () => {
clearTimeout(timer); // don't let the timeout keep the event loop alive
resolve(true);
};
proc.once("exit", done);
setTimeout(() => {
timer = setTimeout(() => {
proc.removeListener("exit", done);
resolve(false);
}, 3000);
Expand Down
Loading
Loading