From 25433ec7f1acc81cfd0114b8a38005d28db61d5f Mon Sep 17 00:00:00 2001 From: tzhouam Date: Wed, 30 Sep 2026 16:48:40 +0800 Subject: [PATCH 1/2] feat(providers): add zcode as a Strict harness backend STRICT_BACKEND=zcode drives the ZCode CLI (Z.AI / GLM subscription) headless: --prompt= --output-format stream-json --mode plan, with --disallowed-tools stripping every non-read tool (measured 28 -> 11 on zcode 0.16.9) and a post-session audit against the read-only allowlist. The session cwd is a scratch dir we own, never the PR worktree: zcode loads .env from its cwd and connects stdio MCP servers declared in a project .zcode/config.json without a trust prompt, so a PR checkout as cwd would let the PR configure its reviewer. The MCP tool bridge rides that scratch dir's config (verified live: bridge read_file called and traced). zcode has no per-run model flag, so STRICT_BACKEND_MODEL is asserted against the served modelId under MODEL_MISMATCH_POLICY instead of selected; the host picks the model with /model in zcode. Co-Authored-By: Claude Opus 5.5 --- doc/architecture/CODE_TOUR.md | 11 +- doc/architecture/SPEC/README.md | 2 +- doc/architecture/SPEC/providers/registry.md | 6 +- doc/architecture/SPEC/providers/zcode.md | 51 +++ doc/features/provider-registry.md | 6 +- doc/guide/backends.md | 24 +- src/infermatrix_copilot/config.py | 5 +- src/infermatrix_copilot/providers/registry.py | 22 +- src/infermatrix_copilot/providers/zcode.py | 361 ++++++++++++++++++ test/test_provider_zcode.py | 307 +++++++++++++++ test/test_providers.py | 2 +- 11 files changed, 776 insertions(+), 21 deletions(-) create mode 100644 doc/architecture/SPEC/providers/zcode.md create mode 100644 src/infermatrix_copilot/providers/zcode.py create mode 100644 test/test_provider_zcode.py diff --git a/doc/architecture/CODE_TOUR.md b/doc/architecture/CODE_TOUR.md index 734ab8bfb..f6dde523a 100644 --- a/doc/architecture/CODE_TOUR.md +++ b/doc/architecture/CODE_TOUR.md @@ -417,8 +417,8 @@ infermatrix-copilot --resume # 从首个未完成 step **要么**整包委托给一个厂商 harness。两条腿共用同一个 prompt、同一份 scope、 同一套 trace 词汇——分叉点只在"谁拥有工具循环"。 -- **`providers/registry.py`**(107)——唯一的一张表:`PROVIDERS` 五条 - `ProviderSpec`(`api` / `cursor` / `claude-code` / `codex` / `deepseek`), +- **`providers/registry.py`**(107)——唯一的一张表:`PROVIDERS` 六条 + `ProviderSpec`(`api` / `cursor` / `claude-code` / `codex` / `deepseek` / `zcode`), `resolve_provider` 读 `STRICT_BACKEND`,`transport_for_id` 取传输实现。 未发布的后端登记进 `_UNSHIPPED`,于是 readiness/doctor 报"尚未发布", 而不是让一次 run 跑到一半才失败。 @@ -430,7 +430,7 @@ infermatrix-copilot --resume # 从首个未完成 step **`sanitized_env()`** 是安全要点:子进程环境是白名单,厂商 CLI 必须保住自己 HOME 里的订阅认证,但**绝不能继承我们的 `ANTHROPIC_BASE_URL`**——这类机器上 它往往指向某个网关,继承过去会悄悄把厂商流量改道。 -- 四个传输,各自的治理姿态不同,差别全在"内置工具能不能关": +- 各传输的治理姿态不同,差别全在"内置工具能不能关": **`claude_code.py`**(205)能关(`builtin_tools_off`),`--max-turns` 直接 承载我们的迭代预算,并回报 `modelUsage`/成本; **`codex.py`**(197)关不掉,改用 OS 级 `--sandbox read-only`; @@ -438,7 +438,10 @@ infermatrix-copilot --resume # 从首个未完成 step `cursor-agent --print --force --output-format stream-json`,**prompt 走 STDIN**(argv 单参数 128KiB 上限,证据包超得轻松),因此额外配事后审计; **`deepseek.py`**(442)是唯一 **api-keyed 的 harness**(SDK 驱动、PATH 上 - 没有二进制、要 `DEEPSEEK_HARNESS_API_KEY`),用最小 composition 保证沙箱干净。 + 没有二进制、要 `DEEPSEEK_HARNESS_API_KEY`),用最小 composition 保证沙箱干净; + **`zcode.py`** 用 `--mode plan` + `--disallowed-tools` 剥到只剩读工具并做白名单 + 事后审计,会话 cwd 是自有临时目录(zcode 会从 cwd 读 `.env` 和项目 MCP 配置), + 模型不能按次指定,`STRICT_BACKEND_MODEL` 在它这里是对实际服务模型的断言。 - **`providers/audit.py`**(109)——**侦测型**兜底,只服务于关不掉内置工具的 后端(今天是 cursor):事后检查文件读取是否越出容纳根(PR-time worktree + run 目录)、只读 scope 下是否出现 write/edit。从 Composer 评测臂产品化而来 diff --git a/doc/architecture/SPEC/README.md b/doc/architecture/SPEC/README.md index 76f8bf136..698e779ff 100644 --- a/doc/architecture/SPEC/README.md +++ b/doc/architecture/SPEC/README.md @@ -66,7 +66,7 @@ | 安全原语 | `scopes` `push` | | Profile | `profiles/store` `profiles/establish` `profiles/repo_map` `profiles/consolidate` | | 跨切 | `review/{diff_summary,triggers,reviewer}` `memory/{debug_memory,skills}` `run_trace` `notify` `metrics` | -| 后端 | `providers/{registry,base,claude_code,codex,cursor,deepseek,audit,harness_llm}` `tool_bridge` | +| 后端 | `providers/{registry,base,claude_code,codex,cursor,deepseek,zcode,audit,harness_llm}` `tool_bridge` | | MCP 面 | `thin_mcp_server` `mcp_server` `mcp_policy` `run_status` `knowledge_docs` `__main__` | 平凡的 `__init__.py` 再导出文件不单独出规范 diff --git a/doc/architecture/SPEC/providers/registry.md b/doc/architecture/SPEC/providers/registry.md index 35fa565a3..96e86c0a4 100644 --- a/doc/architecture/SPEC/providers/registry.md +++ b/doc/architecture/SPEC/providers/registry.md @@ -8,8 +8,8 @@ "够到一个模型"的所有方式的**唯一一张表**,以及从配置到 transport 的唯一解析路径。 ## 功能 -声明 `PROVIDERS`(五条 `ProviderSpec`:`api`、`cursor`、`claude-code`、`codex`、 -`deepseek`),从 `Settings.strict_backend` 解析出被选中的那个,并为 harness 类型返回 +声明 `PROVIDERS`(六条 `ProviderSpec`:`api`、`cursor`、`claude-code`、`codex`、 +`deepseek`、`zcode`),从 `Settings.strict_backend` 解析出被选中的那个,并为 harness 类型返回 一个 `HarnessTransport`。已声明但尚未发布的后端住在 `_UNSHIPPED` 里,抛出指向里程碑的 错误,而不是返回一个根本跑不起来的 transport。 @@ -48,7 +48,7 @@ transport 还没就绪时,先登记进 `_UNSHIPPED`。 ## 测试 `test_providers.py`;逐后端的 -`test_provider_{cursor,claude_code,codex,deepseek}.py`。 +`test_provider_{cursor,claude_code,codex,deepseek,zcode}.py`。 ## 重构备注 `transport_for_id` 的 if 链是唯一会随后端数量增长的地方;用 id → import 路径的 dict diff --git a/doc/architecture/SPEC/providers/zcode.md b/doc/architecture/SPEC/providers/zcode.md new file mode 100644 index 000000000..a38fa55a7 --- /dev/null +++ b/doc/architecture/SPEC/providers/zcode.md @@ -0,0 +1,51 @@ +# providers/zcode.py —— 规范 + + + +`LOC ~361 · harness transport(Z.AI / GLM 订阅) · refactor-status: ok` + +## 职责 +在 Z.AI OAuth 订阅认证下,通过 `zcode` CLI 跑完**一整个** agent step。 + +## 功能 +headless `zcode --prompt=... --output-format stream-json --mode plan --cwd <临时目录> +--disallowed-tools=<非读工具>`;工具桥写进临时目录的 `.zcode/config.json`;从事件流解析 +`result.response`、`result.usage`、`session.updated.modelId` 和 `tool.updated`(`kind: +scheduled`)。prompt 超过 100,000 字节时写成文件并 `--attach`(zcode 没有 stdin 通道, +argv 单参数上限 128KiB)。 + +## 公开契约 +`ZCodeTransport`(`auth_gap`、`run_session`、`complete`)、`spec = PROVIDERS["zcode"]`。 + +## 不变量(**C1**、**C2**) +- **会话 cwd 永远是自有临时目录,不是 PR worktree。** zcode 从 cwd 加载 `.env`,并从 cwd + 往上到 git 根发现 `zcode.json` / `.zcode/config.json`,项目配置里的 stdio MCP server 会被 + 直接连接。PR checkout 不能成为 cwd;仓库按绝对路径读取。 +- **非读工具靠剥离,不靠信任。** `--mode plan` 下仍有 node REPL MCP、子 agent(能拿到 + shell)、workflow、联网抓取;`_DISALLOWED` 把它们全部去掉(实测 28 → 11 个工具,全是读)。 + 每次会话事后对照 `_READ_TOOLS` + 工具桥前缀审计,越界记为 `audit:` refusal——zcode 新版本 + 加了而拒绝列表漏掉的工具会被**侦测到,不会静默**。保留下来的原生读工具 + (`Read`/`Grep`/`Glob`,读超长 prompt 附件要用)还要做容纳审计:路径(含绝对 glob 的根) + 落在 checkout、run 目录、会话临时目录之外即违规(复用 `audit.contained_in`;glob 的字面前缀 + 按搜索目录解析,`../../etc/*` 这类穿越也会被抓到)。这是侦测型;预防型的读取走工具桥。 +- **`complete()` 连原生读工具也去掉**(输入可能是不可信文本);只有 prompt 超长、必须靠 + `Read` 读附件时才保留 `Read`,且任何读到临时目录之外的调用都让这次调用失败。 +- **失败的运行不能读成空评审。** 非零退出或流里没有 `result` 事件(未超时时)直接抛 + `RuntimeError`,带上 zcode 自己的 stderr。 +- **`STRICT_BACKEND_MODEL` 是断言,不是选择。** zcode 没有按次选模型的开关(没有 `--model`; + 实测 `ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` 不改变实际服务的模型),服务的是宿主上 zcode + 配置的缺省模型。与流里的 `modelId` 不区分大小写比对,不一致时按 `MODEL_MISMATCH_POLICY` + 处理(缺省 `fail`)。因此注册表不声明 `default_model`。 +- `auth_gap()` 只检查 `~/.zcode/v2/credentials.json` 是否存在(zcode 没有登录状态命令); + 存在不代表 token 仍有效,过期会在第一次运行时大声失败。子进程保留 `ZCODE_DATA_BASE_DIR`, + 保证检查的目录与运行登录用的是同一个。 + +## 边界 —— 不属于这里 +不改 zcode 的全局配置(`~/.zcode/v2/provider_config.json` 属于宿主);不编造用量或模型。 + +## 依赖(允许) +stdlib + `.base` + `..agent_loop.AgentOutcome` + `..llm` 的类型与 `ModelMismatchError`。 + +## 测试 +`test_provider_zcode.py`(离线,事件形状取自 zcode 0.16.9 实跑);2026-09-30 用真实 zcode 做过 +一次端到端冒烟(工具桥 `read_file` 被调用,`bridge_trace.jsonl` 有记录)。 diff --git a/doc/features/provider-registry.md b/doc/features/provider-registry.md index 11a75e8ac..b41b63ac2 100644 --- a/doc/features/provider-registry.md +++ b/doc/features/provider-registry.md @@ -1,11 +1,11 @@ -# Provider registry —— Strict 的五种后端 +# Provider registry —— Strict 的六种后端 > **一览** > | | | > |---|---| -> | **状态** | ✅ 已实现。`api` / `cursor` / `claude-code` / `deepseek` 已实测;`codex` 仅离线测试(开发机无 ChatGPT 登录,readiness 会报出登录缺口) | +> | **状态** | ✅ 已实现。`api` / `cursor` / `claude-code` / `deepseek` / `zcode` 已实测;`codex` 仅离线测试(开发机无 ChatGPT 登录,readiness 会报出登录缺口) | > | **做什么** | 让只有编码 Agent 订阅、没有裸 API Key 的用户也能跑 Strict;顺带把原有 api 路径收编进同一张注册表 | -> | **怎么开关** | `STRICT_BACKEND=api\|cursor\|claude-code\|codex\|deepseek`(另有 `STRICT_BACKEND_MODEL` / `_CONCURRENCY` / `_CLI` / `_TIMEOUT_S`) | +> | **怎么开关** | `STRICT_BACKEND=api\|cursor\|claude-code\|codex\|deepseek\|zcode`(另有 `STRICT_BACKEND_MODEL` / `_CONCURRENCY` / `_CLI` / `_TIMEOUT_S`) | > | **怎么用** | 面向使用者的说明在 [`../guide/backends.md`](../guide/backends.md) | > | **实测** | 后端对比见 [`eval/dataset/results/model_comparison.md`](../../eval/dataset/results/model_comparison.md);结论摘要在 [`../GUIDE.md §8`](../GUIDE.md#8-性能对比) | > | **硬边界** | 用 `api` 时行为**逐字节不变**(平价棘轮);harness 会话的工具一律经 `tools.dispatch`;子进程环境是白名单 | diff --git a/doc/guide/backends.md b/doc/guide/backends.md index 92900817b..14833a035 100644 --- a/doc/guide/backends.md +++ b/doc/guide/backends.md @@ -10,6 +10,7 @@ > | Codex | ✅ `$imreview` → [`hosts/codex.md`](hosts/codex.md) | ✅ `STRICT_BACKEND=codex` | > | Cursor | ✅ `/imreview` → [`hosts/cursor.md`](hosts/cursor.md) | ✅ `STRICT_BACKEND=cursor` | > | DeepSeek(dsh) | ✗ | ✅ `STRICT_BACKEND=deepseek` | +> | ZCode(GLM) | ✗ | ✅ `STRICT_BACKEND=zcode` | > | api(Anthropic/OpenAI 兼容) | ✗ | ✅ 默认 | > > 一句话判据:**宿主提供模型给你用;后端是 copilot 拿去用的模型。** @@ -39,7 +40,7 @@ Agent 订阅(Claude Pro/Max、ChatGPT、Cursor)而没有裸 API Key 的用 两者住在同一张注册表 `providers/registry.py` 里,走同一条解析路径、同一套 trace 词汇。**用 `api` 时行为逐字节不变**(平价棘轮)。 -## 2. 五个后端 +## 2. 六个后端 | id | kind | 凭据 | 能力 | 验证程度 | |---|---|---|---|---| @@ -47,7 +48,8 @@ trace 词汇。**用 `api` 时行为逐字节不变**(平价棘轮)。 | `cursor` | harness | 订阅(`cursor-agent` CLI) | `mcp_tools` `usage_reporting` | 已实测 | | `claude-code` | harness | 订阅(`claude` CLI) | `mcp_tools` `builtin_tools_off` `max_turns` `system_prompt` `usage_reporting` `cost_reporting` | 订阅认证下实测 | | `codex` | harness | 订阅(`codex` CLI) | `mcp_tools` `sandbox_read_only` `usage_reporting` | **仅离线测试**(开发机无 ChatGPT 登录,readiness 会报出登录缺口) | -| `deepseek`(dsh) | harness | **API Key** | `sandbox_read_only` `system_prompt` `api_keyed`——**注意没有 `mcp_tools`**,见下 | 最新加入 | +| `deepseek`(dsh) | harness | **API Key** | `sandbox_read_only` `system_prompt` `api_keyed`——**注意没有 `mcp_tools`**,见下 | 已实测 | +| `zcode` | harness | 订阅(`zcode` CLI,Z.AI OAuth) | `mcp_tools` `builtin_tools_off` `usage_reporting`——**模型不能按次指定**,见下 | 2026-09-30 实测(zcode 0.16.9) | `claude-code` 的能力集最全;`codex` 与 `deepseek` 靠 OS 级只读沙箱而不是关闭 内置工具;`cursor` 两者都做不到,因此额外配了事后审计(见 §5)。 @@ -68,6 +70,19 @@ trace 词汇。**用 `api` 时行为逐字节不变**(平价棘轮)。 > **由此而来的一条硬规矩:跑在原生 bash 上的 arm,绝不能被标成"tools > bridged"。** 本轮战役已经测到过三个 arm 与其标签不符。 +**`zcode` 也有两处要记住**: + +1. **它没有按次选模型的开关。** 没有 `--model`;实测 `ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` + 也改不了实际服务的模型。它用宿主上 zcode 自己配置的缺省模型(在 zcode TUI 里用 + `/model` 设)。所以对 `zcode` 而言 `STRICT_BACKEND_MODEL` 是一条**断言**:会话结束后 + 与流里 `session.updated` 报出的 `modelId` 比对(不区分大小写),不一致时按 + `MODEL_MISMATCH_POLICY` 处理(缺省 `fail`,抛 `ModelMismatchError`)。 +2. **会话 cwd 是我们自己的临时目录,不是 PR worktree。** zcode 会从 cwd 加载 `.env`, + 并从 cwd 往上到 git 根发现 `zcode.json` / `.zcode/config.json`——项目配置能声明 + stdio MCP server(也就是命令),zcode 不经信任确认就会连上。在 PR checkout 里跑, + 等于让被审的 PR 配置审稿人。所以仓库按绝对路径读取,prompt 里写明 checkout 位置; + 工具桥写在临时目录的 `.zcode/config.json` 里。 + ## 3. 怎么选 **默认用 `api`。** 它是唯一行为完全确定、成本可测、且所有评测结论都建立其上的 @@ -85,7 +100,7 @@ trace 词汇。**用 `api` 时行为逐字节不变**(平价棘轮)。 ```bash # ~/.infermatrix-copilot/.env -STRICT_BACKEND=api # api | cursor | claude-code | codex | deepseek +STRICT_BACKEND=api # api | cursor | claude-code | codex | deepseek | zcode # STRICT_BACKEND_MODEL= # harness 内部的模型 id(可选,见下) # STRICT_BACKEND_CONCURRENCY=2 # 并发 harness 会话数 # STRICT_BACKEND_CLI= # 二进制路径覆盖(否则走 PATH 查找) @@ -126,7 +141,8 @@ REVIEW_LENS_BACKENDS=adversary=claude-code:...,round2=claude-code:... 桥提供内置工具 + `doc_search`/`doc_read` + 按需 `repo_map` + 考古工具组; **skill/memory 检索刻意不开放**——那两个能提知识 candidate,跨进程写入口没有开。 **例外:`deepseek` 用不了这座桥**(见 §2),它的调用落在审计轨迹之外。 -- **能关内置工具的就关**(`builtin_tools_off`,claude-code); +- **能关内置工具的就关**(`builtin_tools_off`,claude-code;zcode 用 `--mode plan` + + `--disallowed-tools` 剥到只剩读工具,并对照读工具白名单做事后审计); **关不掉的用 OS 级只读沙箱**(`sandbox_read_only`,codex / deepseek)。 - **两者都做不到的(cursor-agent)额外上事后审计**(`providers/audit.py`): 检查文件读取是否越出会话的容纳根(PR-time worktree + run 目录),只读 scope diff --git a/src/infermatrix_copilot/config.py b/src/infermatrix_copilot/config.py index 4327f3232..b2527ee46 100644 --- a/src/infermatrix_copilot/config.py +++ b/src/infermatrix_copilot/config.py @@ -166,7 +166,7 @@ class Settings(BaseSettings): # the CLI path treats empty as "api" so maintainer setups keep working. # Harness ids (cursor / claude-code / codex) need no API key: the vendor # CLI holds the subscription auth. - strict_backend: str = "" # "" | api | cursor | claude-code | codex | deepseek + strict_backend: str = "" # "" | api | cursor | claude-code | codex | deepseek | zcode strict_backend_model: str = "" # model id INSIDE the harness (optional) # dsh is the one API-keyed harness (providers/deepseek.py): the other # three hold subscription auth inside the vendor CLI, so `tier_target` @@ -488,7 +488,8 @@ def _parse_aliases(cls, v): def _validate_strict_backend(cls, v): """Unknown backend ids fail at startup, not mid-run: the selection is a routing decision and a typo must not silently mean 'api'.""" - allowed = {"", "api", "cursor", "claude-code", "codex", "deepseek"} + allowed = {"", "api", "cursor", "claude-code", "codex", "deepseek", + "zcode"} if v not in allowed: raise ValueError( f"STRICT_BACKEND must be one of {sorted(allowed - {''})} " diff --git a/src/infermatrix_copilot/providers/registry.py b/src/infermatrix_copilot/providers/registry.py index 407da2c52..e3431391f 100644 --- a/src/infermatrix_copilot/providers/registry.py +++ b/src/infermatrix_copilot/providers/registry.py @@ -1,8 +1,9 @@ """Provider registry — the one table of ways to reach a model. -All four ids resolve to shipped transports (M1 cursor, M2 claude-code, -M3 codex — doc/features/provider-registry.md). `_UNSHIPPED` remains the -mechanism for declaring a future backend before its transport lands: +Every id resolves to a shipped transport (M1 cursor, M2 claude-code, +M3 codex, then deepseek and zcode — doc/features/provider-registry.md). +`_UNSHIPPED` remains the mechanism for declaring a future backend before its +transport lands: `transport_for` raises its milestone pointer so `strict_readiness`/doctor report "not yet shipped" instead of a run failing mid-flight. """ @@ -60,6 +61,17 @@ capabilities=frozenset({ "sandbox_read_only", "system_prompt", "api_keyed"}), default_model="deepseek-v4-pro"), + # zcode serves the host's configured default model and has no + # per-run selector, so `default_model` stays empty and + # STRICT_BACKEND_MODEL is checked against the served model instead + # (providers/zcode.py). No `sandbox_read_only`: containment is the + # tool denylist plus the post-session allowlist audit. + ProviderSpec( + id="zcode", kind="harness", + display="Z.AI (GLM) subscription via zcode CLI", + cli_names=("zcode",), + capabilities=frozenset({ + "mcp_tools", "builtin_tools_off", "usage_reporting"})), ) } @@ -116,6 +128,10 @@ def transport_for_id(settings, provider_id: str) -> HarnessTransport: from .deepseek import DeepSeekHarnessTransport return DeepSeekHarnessTransport(settings) + if spec.id == "zcode": + from .zcode import ZCodeTransport + + return ZCodeTransport(settings) from .codex import CodexTransport return CodexTransport(settings) diff --git a/src/infermatrix_copilot/providers/zcode.py b/src/infermatrix_copilot/providers/zcode.py new file mode 100644 index 000000000..ba9cd5b73 --- /dev/null +++ b/src/infermatrix_copilot/providers/zcode.py @@ -0,0 +1,361 @@ +"""ZCode CLI harness transport — Strict on a Z.AI (GLM) subscription. + +Headless ``zcode --prompt=... --output-format stream-json --mode plan`` +emits JSONL events; the ``result`` event carries the final ``response`` and +token usage, ``session.updated`` events name the served ``providerId`` / +``modelId``, and ``tool.updated`` events with ``kind: scheduled`` are the +tool calls. Probed live on zcode 0.16.9 (2026-09-30, bigmodel coding plan). + +Governance (disclosed, per doc/features/provider-registry.md): + +- **Built-in tools are removed, not trusted.** ``--mode plan`` alone still + leaves a node REPL MCP, subagents (``Agent``, which can reach a shell), + workflows and web fetch/search. ``--disallowed-tools`` strips everything + outside the read-only set (measured: 28 tools → 11, all reads), and every + session is post-audited against that allowlist — a tool a future zcode + version adds and our denylist misses shows up as an ``audit:`` refusal, + never silently. The native reads that remain (``Read``/``Grep``/``Glob``, + needed to read an attached oversized prompt) are audited for containment + like cursor's (`audit.contained_in`): a path outside the checkout, the run + dir and the session dir is a violation. Detective, not preventive — the + bridge's own reads are the preventive path. +- **The session cwd is a scratch dir WE own, never the PR worktree.** zcode + loads ``.env`` from its cwd and discovers ``zcode.json`` / + ``.zcode/config.json`` from the cwd up to the git root — project config + can declare stdio MCP servers (i.e. commands) and zcode connects them + without a trust prompt. Running inside a PR checkout would let the PR + under review configure the reviewer. The worktree is read by absolute + path instead; the prompt names it. +- The MCP tool bridge rides that scratch dir's ``.zcode/config.json`` + (measured live: ``mcp__infermatrix-tools__read_file`` called, bridge + trace recorded), so scoped reads still pass ``tools.dispatch``. + +Model selection: zcode has NO per-run model flag (``--model`` does not +exist; ``ZCODE_PERSONAL_PROVIDER_CONFIG_FILE`` was measured not to change the +served model). It serves the host's configured default (``/model`` in the +zcode TUI). ``STRICT_BACKEND_MODEL`` is therefore an ASSERTION here, checked +against the served ``modelId`` under ``MODEL_MISMATCH_POLICY`` — a mislabeled +arm is the failure this campaign has already paid for three times. + +Env is the shared allowlist (`base.sanitized_env`); zcode keeps its Z.AI +OAuth login under HOME (``~/.zcode/v2``).""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from ..agent_loop import AgentOutcome +from ..llm import Block, ModelMismatchError, Reply +from .audit import contained_in +from .base import ( + AgentSessionRequest, + HarnessTransport, + SessionUsage, + flatten_messages, + sanitized_env, +) +from .registry import PROVIDERS + +logger = logging.getLogger(__name__) + +_BRIDGE_SERVER = "infermatrix-tools" +_BRIDGE_PREFIX = f"mcp__{_BRIDGE_SERVER}__" + +# Everything zcode 0.16.9 exposes in plan mode that is not a pure read. +_DISALLOWED = ( + "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit", "Agent", "Skill", + "ExitPlanMode", "EnterPlanMode", "AskUserQuestion", "SendMessage", + "WebFetch", "WebSearch", "CreateWorkflow", "AmendWorkflow", + "SaveWorkflow", "EvalWorkflowSnippet", "ResumeWorkflowRun", + "ResolveWorkflowQuestion", "TaskStop", "mcp__node_repl__js", + "mcp__web_reader__webReader", "mcp__4_5v_mcp__analyze_image", +) +# What remains after the denylist — the audit's allowlist. +_READ_TOOLS = frozenset({ + "Read", "Grep", "Glob", "TodoRead", "TodoWrite", "TaskOutput", + "ReadSessionContext", "ListWorkflowRuns", "GetWorkflowRun", + "ListSavedWorkflows", "ListModels", +}) + +# Env the zcode subprocess keeps on top of `sanitized_env()`: the data dir +# `auth_gap` checks must be the one the run logs in with. +_ZCODE_ENV_KEEP = ("ZCODE_DATA_BASE_DIR",) + +# Input keys under which zcode's native tools name a filesystem path. +_PATH_KEYS = ("file_path", "path", "notebook_path") + +# The prompt travels as ONE argv string (zcode has no stdin channel) and +# Linux caps a single argument at 128KiB. Past this budget the prompt is +# written to a file in the scratch dir and attached instead. +_ARG_BUDGET = 100_000 + + +class ZCodeTransport(HarnessTransport): + """zcode CLI (headless prompt mode) as a Strict backend.""" + + spec = PROVIDERS["zcode"] + + def auth_gap(self) -> str | None: + """zcode has no login-status command; the OAuth login writes + ``~/.zcode/v2/credentials.json``, so its absence is the cheap signal. + Presence does not prove the token is still valid — an expired login + surfaces loudly on the first run.""" + if not self.cli_path(): + return None # the CLI-missing gap is reported separately + base = os.environ.get("ZCODE_DATA_BASE_DIR") or str(Path.home()) + if not (Path(base) / ".zcode" / "v2" / "credentials.json").is_file(): + return "zcode is not logged in — run: zcode login" + return None + + # -- process plumbing ---------------------------------------------------- + @staticmethod + def _write_mcp_config(session: Path, spec_path: Path) -> None: + package_root = Path(__file__).resolve().parents[2] + config = session / ".zcode" / "config.json" + config.parent.mkdir() + config.write_text(json.dumps({"mcp": {"servers": {_BRIDGE_SERVER: { + "type": "stdio", + "command": sys.executable, + "args": ["-m", "infermatrix_copilot.tool_bridge", + "--spec", str(spec_path)], + "env": {"PYTHONPATH": str(package_root)}, + }}}}, indent=2), encoding="utf-8") + + def _run(self, text: str, *, session: Path, timeout_s: float, + tool_less: bool = False) -> tuple[list[dict], bool]: + """One CLI invocation → (parsed events, timed_out). A timeout kills + the process but keeps the partial stream as salvage material. + `tool_less` also removes the native reads unless an oversized prompt + needs `Read` to reach its attachment.""" + cmd = [self.require_cli()] + disallowed = list(_DISALLOWED) + oversized = len(text.encode("utf-8")) > _ARG_BUDGET + if tool_less: + disallowed += ["Grep", "Glob"] + ([] if oversized else ["Read"]) + if oversized: + prompt_file = session / "PROMPT.md" + prompt_file.write_text(text, encoding="utf-8") + cmd += [f"--prompt=Your complete instructions are in the attached " + f"file {prompt_file}. Read ALL of it (page through it with " + f"offset) before acting, then follow it exactly.", + "--attach", str(prompt_file)] + else: + # `--prompt=` form: a prompt starting with "-" must not be + # parsed as an option. + cmd += [f"--prompt={text}"] + cmd += ["--output-format", "stream-json", "--mode", "plan", + "--cwd", str(session), + f"--disallowed-tools={','.join(disallowed)}"] + env = sanitized_env() + env.update({k: os.environ[k] for k in _ZCODE_ENV_KEEP + if k in os.environ}) + timed_out = False + returncode, stderr = 0, "" + try: + proc = subprocess.run( + cmd, cwd=str(session), env=env, + stdin=subprocess.DEVNULL, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout_s, + check=False) + stdout = proc.stdout or "" + returncode, stderr = proc.returncode, proc.stderr or "" + except subprocess.TimeoutExpired as exc: + timed_out = True + raw = exc.stdout or b"" + stdout = raw.decode("utf-8", "replace") if isinstance(raw, bytes) \ + else str(raw) + events: list[dict] = [] + for line in stdout.splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + if not timed_out and (returncode != 0 or not any( + e.get("type") == "result" for e in events)): + # A failed run (expired login, bad flag, crashed runtime) must not + # read as an empty review: raise with zcode's own words. + detail = " ".join(stderr.strip().splitlines()[-3:])[:400] + raise RuntimeError( + f"zcode exited {returncode} without a result event" + + (f": {detail}" if detail else "") + + " — check `zcode login` and the CLI version") + return events, timed_out + + @staticmethod + def _final_text(events: list[dict]) -> str: + for event in reversed(events): + if event.get("type") == "result": + return str(event.get("response") or "").strip() + return "" + + @staticmethod + def _usage(events: list[dict]) -> SessionUsage: + usage = SessionUsage() + for event in events: + payload = event.get("payload") + if event.get("type") == "session.updated" \ + and isinstance(payload, dict) and payload.get("modelId"): + usage.served_model = str(payload["modelId"]) + if event.get("type") == "result" \ + and isinstance(event.get("usage"), dict): + raw = event["usage"] + usage.input_tokens = int(raw.get("inputTokens") or 0) + usage.output_tokens = int(raw.get("outputTokens") or 0) + return usage + + @staticmethod + def _tool_calls(events: list[dict]) -> list[tuple[str, dict]]: + """(name, input) of every tool the session scheduled, in order.""" + calls: list[tuple[str, dict]] = [] + for event in events: + payload = event.get("payload") + if event.get("type") == "tool.updated" \ + and isinstance(payload, dict) \ + and payload.get("kind") == "scheduled" \ + and payload.get("toolName"): + args = payload.get("input") + calls.append((str(payload["toolName"]), + args if isinstance(args, dict) else {})) + return calls + + @staticmethod + def _audit(calls: list[tuple[str, dict]], *, roots: tuple[str, ...], + cwd: str) -> list[str]: + """Allowlist + containment violations. Bridge calls are exempt: the + bridge enforces its own scope preventively.""" + violations: set[str] = set() + for name, args in calls: + if name.startswith(_BRIDGE_PREFIX): + continue + if name not in _READ_TOOLS: + violations.add( + f"zcode tool outside the read-only allowlist: {name}") + continue + paths = [os.path.join(cwd, str(args[key])) for key in _PATH_KEYS + if args.get(key)] + pattern = str(args.get("pattern") or "") + if name == "Glob" and pattern: + # a glob names part of its root in the pattern itself + # ("/etc/*", "../../etc/*"): resolve it against the search + # dir and check the literal prefix before the first wildcard + base = paths[0] if paths else cwd + literal = re.split(r"[*?\[{]", pattern, maxsplit=1)[0] + paths.append(os.path.normpath(os.path.join(base, literal))) + for path in paths: + if not contained_in(path, roots): + violations.add( + f"{name} outside session roots: {path[:160]}") + return sorted(violations) + + def _check_model(self, requested: str, served: str, trace=None) -> None: + """`STRICT_BACKEND_MODEL` is an assertion on zcode (see module + docstring): compare it with what was served.""" + if not requested: + return + if not served: + verdict = "unverified" + elif requested.casefold() == served.casefold(): + verdict = "ok" + else: + verdict = "mismatch" + if trace is not None: + trace.record("harness_model_check", provider=self.spec.id, + requested=requested, served=served, verdict=verdict) + if verdict == "unverified": + logger.warning("zcode stream named no model — served model " + "unverified (requested %s)", requested) + elif verdict == "mismatch": + if getattr(self.settings, "model_mismatch_policy", "fail") == "fail": + raise ModelMismatchError(requested=requested, served=served, + endpoint="zcode (host default model; " + "set it with /model in zcode)") + logger.warning("MODEL MISMATCH accepted by policy=warn: requested " + "%s, zcode served %s", requested, served) + + # -- transport contract -------------------------------------------------- + def run_session(self, req: AgentSessionRequest) -> AgentOutcome: + session = Path(tempfile.mkdtemp(prefix="imc-zcode-session-")) + try: + if req.bridge_spec_path is not None: + self._write_mcp_config(session, req.bridge_spec_path) + header = "" + if req.scope.root: + header = (f"The repository checkout is at {req.scope.root}. " + "Your working directory is a scratch directory: " + "read repository files by absolute path under " + "that checkout.\n\n") + events, timed_out = self._run( + f"{header}{req.system}\n\n{req.prompt}", session=session, + timeout_s=req.timeout_s) + finally: + shutil.rmtree(session, ignore_errors=True) + usage = self._usage(events) + calls = self._tool_calls(events) + used = [name for name, _ in calls] + violations = self._audit( + calls, roots=(req.scope.root or "", str(req.run_dir), + str(session)), cwd=str(session)) + bridge_calls = sum(1 for name in used + if name.startswith(_BRIDGE_PREFIX)) + if req.trace is not None: + req.trace.record( + "harness_session", provider=self.spec.id, step=req.step_name, + audit_ok=not violations, audit_violations=violations[:10], + tool_calls=len(used), bridge_calls=bridge_calls, + timed_out=timed_out, event_count=len(events), + served_model=usage.served_model) + self._check_model(req.model or self.settings.strict_backend_model, + usage.served_model, req.trace) + return AgentOutcome( + text=self._final_text(events), + iterations=0, # zcode does not expose a turn budget + tool_calls=len(used), + truncated=timed_out, + refusals=[f"audit: {v}" for v in violations], + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + tools_used=used[:40]) + + def complete(self, *, system: str, messages: list[dict], + model: str = "", max_tokens: int | None = None, + role: str = "", effort: str = "") -> Reply: + """Tool-less one-shot in an empty scratch cwd with no bridge. Every + native read is removed, except `Read` when the prompt is too big for + argv and rides an attachment — then any read outside the scratch dir + (the input here can be untrusted text) fails the call.""" + session = Path(tempfile.mkdtemp(prefix="imc-zcode-oneshot-")) + try: + events, timed_out = self._run( + flatten_messages(system, messages), session=session, + timeout_s=self.settings.strict_backend_timeout_s, + tool_less=True) + finally: + shutil.rmtree(session, ignore_errors=True) + violations = self._audit(self._tool_calls(events), + roots=(str(session),), cwd=str(session)) + if violations: + raise RuntimeError("zcode one-shot broke containment: " + + "; ".join(violations)) + usage = self._usage(events) + self._check_model(model or self.settings.strict_backend_model, + usage.served_model) + text = self._final_text(events) + return Reply( + blocks=[Block(type="text", text=text)] if text else [], + stop_reason="max_tokens" if timed_out else "end_turn", + usage={"input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0}, + model=usage.served_model) diff --git a/test/test_provider_zcode.py b/test/test_provider_zcode.py new file mode 100644 index 000000000..32490f465 --- /dev/null +++ b/test/test_provider_zcode.py @@ -0,0 +1,307 @@ +"""ZCode transport against a fake zcode CLI — offline. The stream-json event +shapes are recorded from a live zcode 0.16.9 run (2026-09-30).""" + +import json +import stat +from pathlib import Path + +import pytest + +from infermatrix_copilot.config import Settings +from infermatrix_copilot.llm import ModelMismatchError +from infermatrix_copilot.providers import zcode as zcode_mod +from infermatrix_copilot.providers.base import AgentSessionRequest +from infermatrix_copilot.providers.zcode import ZCodeTransport +from infermatrix_copilot.scopes import read_only_scope +from infermatrix_copilot.tool_bridge import write_bridge_spec + +_FAKE_CLI = """#!/usr/bin/env python3 +import json, os, sys, time +here = os.path.dirname(os.path.abspath(__file__)) +config = os.path.join(os.getcwd(), ".zcode", "config.json") +attach = sys.argv[sys.argv.index("--attach") + 1] \\ + if "--attach" in sys.argv else "" +with open(os.path.join(here, "capture.json"), "w") as f: + json.dump({"argv": sys.argv[1:], "cwd": os.getcwd(), + "config": open(config).read() if os.path.exists(config) else "", + "attached": open(attach).read() if attach else "", + "env_key": os.environ.get("ANTHROPIC_API_KEY", ""), + "data_dir": os.environ.get("ZCODE_DATA_BASE_DIR", "")}, f) +if os.path.exists(os.path.join(here, "sleep")): + time.sleep(10) +if os.path.exists(os.path.join(here, "fail")): + print("Error: not logged in to Z.AI", file=sys.stderr) + sys.exit(1) +model = open(os.path.join(here, "model")).read() \\ + if os.path.exists(os.path.join(here, "model")) else "GLM-5.3-Flash" +tools = [("mcp__infermatrix-tools__read_file", {"path": "/etc/hosts"}), + ("Read", {"file_path": "PROMPT.md"})] +if os.path.exists(os.path.join(here, "rogue")): + tools.append(("Bash", {"command": "ls"})) +if os.path.exists(os.path.join(here, "outside")): + tools.append(("Read", {"file_path": "/etc/passwd"})) + tools.append(("Glob", {"pattern": "/root/.ssh/*"})) +def emit(e): print(json.dumps(e)) +emit({"type": "turn.started", "payload": {"turnNumber": 0}}) +emit({"type": "session.updated", "payload": { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": model, "toolCount": 14, "iteration": 0}}) +for i, (name, args) in enumerate(tools): + emit({"type": "tool.updated", "payload": { + "toolCallId": f"c{i}", "toolName": name, "input": args, + "kind": "scheduled"}}) + emit({"type": "tool.updated", "payload": { + "toolCallId": f"c{i}", "toolName": name, "kind": "started"}}) +emit({"type": "model.streaming", "payload": {"delta": "REV", "kind": "delta"}}) +if not os.path.exists(os.path.join(here, "noresult")): + emit({"type": "result", "response": "REVIEW", "usage": { + "source": "provider", "inputTokens": 50, "outputTokens": 9}}) +""" + + +class FakeTrace: + def __init__(self): + self.events = [] + + def record(self, kind, **fields): + self.events.append({"kind": kind, **fields}) + + +def _transport(tmp_path: Path, **settings) -> ZCodeTransport: + cli = tmp_path / "bin" / "zcode" + cli.parent.mkdir(exist_ok=True) + cli.write_text(_FAKE_CLI, encoding="utf-8") + cli.chmod(cli.stat().st_mode | stat.S_IXUSR) + return ZCodeTransport(Settings( + _env_file=None, strict_backend="zcode", + strict_backend_cli=str(cli), **settings)) + + +def _request(tmp_path: Path, with_bridge: bool = True, + prompt: str = "PROMPT") -> AgentSessionRequest: + worktree = tmp_path / "worktree" + worktree.mkdir() + run_dir = tmp_path / "run" + run_dir.mkdir() + scope = read_only_scope() + scope = type(scope)(name=scope.name, allowed_tools=scope.allowed_tools, + read_only=True, root=str(worktree)) + bridge = write_bridge_spec(run_dir=run_dir, step_name="agent.review_diff", + scope=scope, repo="vllm-omni") \ + if with_bridge else None + return AgentSessionRequest( + system="SYS", prompt=prompt, scope=scope, model="", + max_iters=8, timeout_s=30.0, run_dir=run_dir, + step_name="agent.review_diff", bridge_spec_path=bridge, + trace=FakeTrace()) + + +def _capture(tmp_path: Path) -> dict: + return json.loads( + (tmp_path / "bin" / "capture.json").read_text(encoding="utf-8")) + + +def test_run_session_plan_mode_bridge_and_parse(tmp_path, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "should-not-leak") + transport = _transport(tmp_path) + req = _request(tmp_path) + + outcome = transport.run_session(req) + + assert outcome.text == "REVIEW" + assert (outcome.input_tokens, outcome.output_tokens) == (50, 9) + assert outcome.tools_used == ["mcp__infermatrix-tools__read_file", "Read"] + assert outcome.refusals == [] and outcome.truncated is False + + capture = _capture(tmp_path) + argv = capture["argv"] + assert argv[argv.index("--mode") + 1] == "plan" + assert argv[argv.index("--output-format") + 1] == "stream-json" + denied = next(a for a in argv if a.startswith("--disallowed-tools=")) + for tool in ("Bash", "Edit", "Write", "Agent", "WebFetch", + "mcp__node_repl__js"): + assert tool in denied.split("=", 1)[1].split(",") + prompt = next(a for a in argv if a.startswith("--prompt=")) + assert "SYS\n\nPROMPT" in prompt and str(req.scope.root) in prompt + # the cwd is a scratch dir we own, never the PR worktree, and it holds + # the bridge config + assert capture["cwd"] != req.scope.root + assert argv[argv.index("--cwd") + 1] == capture["cwd"] + server = json.loads(capture["config"])["mcp"]["servers"][ + "infermatrix-tools"] + assert server["type"] == "stdio" + assert "infermatrix_copilot.tool_bridge" in server["args"] + assert capture["env_key"] == "" # sanitized env + # the scratch dir is removed and the worktree untouched + assert not Path(capture["cwd"]).exists() + assert list(Path(req.scope.root).iterdir()) == [] + + session = next(e for e in req.trace.events + if e["kind"] == "harness_session") + assert session["audit_ok"] is True and session["bridge_calls"] == 1 + assert session["served_model"] == "GLM-5.3-Flash" + + +def test_tool_outside_allowlist_is_an_audit_refusal(tmp_path): + transport = _transport(tmp_path) + (tmp_path / "bin" / "rogue").write_text("", encoding="utf-8") + req = _request(tmp_path) + + outcome = transport.run_session(req) + + assert outcome.refusals == [ + "audit: zcode tool outside the read-only allowlist: Bash"] + session = next(e for e in req.trace.events + if e["kind"] == "harness_session") + assert session["audit_ok"] is False + + +def test_native_read_outside_roots_is_an_audit_refusal(tmp_path): + transport = _transport(tmp_path) + (tmp_path / "bin" / "outside").write_text("", encoding="utf-8") + + outcome = transport.run_session(_request(tmp_path)) + + # the in-session relative read and the bridge call (preventively scoped + # by the bridge itself) are not flagged; the two escapes are + assert outcome.refusals == [ + "audit: Glob outside session roots: /root/.ssh", + "audit: Read outside session roots: /etc/passwd"] + + +def test_relative_glob_traversal_is_flagged(tmp_path): + cwd = str(tmp_path / "session") + roots = (cwd,) + audit = ZCodeTransport._audit + assert audit([("Glob", {"pattern": "../../etc/*"})], + roots=roots, cwd=cwd) == [ + "Glob outside session roots: " + + str((tmp_path / "session" / "../../etc").resolve())] + assert audit([("Glob", {"pattern": "*.md", "path": "/etc"})], + roots=roots, cwd=cwd) != [] + assert audit([("Glob", {"pattern": "src/**/*.py"}), + ("Grep", {"pattern": "x"})], roots=roots, cwd=cwd) == [] + + +def test_complete_removes_native_reads(tmp_path): + transport = _transport(tmp_path) + transport.complete(system="S", messages=[]) + + argv = _capture(tmp_path)["argv"] + denied = next(a for a in argv if a.startswith("--disallowed-tools=")) + assert {"Read", "Grep", "Glob"} <= set(denied.split("=", 1)[1].split(",")) + + +def test_complete_out_of_root_read_fails_the_call(tmp_path): + transport = _transport(tmp_path) + (tmp_path / "bin" / "outside").write_text("", encoding="utf-8") + big = "x" * (zcode_mod._ARG_BUDGET + 10) + + with pytest.raises(RuntimeError, match="broke containment.*/etc/passwd"): + transport.complete(system="S", messages=[ + {"role": "user", "content": big}]) + argv = _capture(tmp_path)["argv"] + denied = next(a for a in argv if a.startswith("--disallowed-tools=")) + # the oversized prompt keeps Read for its attachment, nothing else + assert "Read" not in denied.split("=", 1)[1].split(",") + assert "Glob" in denied.split("=", 1)[1].split(",") + + +def test_failed_run_raises_with_stderr(tmp_path): + transport = _transport(tmp_path) + (tmp_path / "bin" / "fail").write_text("", encoding="utf-8") + + with pytest.raises(RuntimeError, match="exited 1.*not logged in"): + transport.run_session(_request(tmp_path)) + with pytest.raises(RuntimeError, match="not logged in"): + transport.complete(system="S", messages=[]) + + +def test_missing_result_event_raises(tmp_path): + transport = _transport(tmp_path) + (tmp_path / "bin" / "noresult").write_text("", encoding="utf-8") + + with pytest.raises(RuntimeError, match="without a result event"): + transport.run_session(_request(tmp_path)) + + +def test_oversized_prompt_is_attached_not_passed_in_argv(tmp_path): + transport = _transport(tmp_path) + big = "x" * (zcode_mod._ARG_BUDGET + 10) + transport.run_session(_request(tmp_path, prompt=big)) + + capture = _capture(tmp_path) + assert all(len(a) < zcode_mod._ARG_BUDGET for a in capture["argv"]) + assert "--attach" in capture["argv"] + assert capture["attached"].endswith(big) + + +def test_model_assertion_matches_case_insensitively(tmp_path): + transport = _transport(tmp_path, strict_backend_model="glm-5.3-flash") + req = _request(tmp_path) + + assert transport.run_session(req).text == "REVIEW" + check = next(e for e in req.trace.events + if e["kind"] == "harness_model_check") + assert check["verdict"] == "ok" + + +def test_model_mismatch_fails_by_default(tmp_path): + transport = _transport(tmp_path, strict_backend_model="GLM-5.3-Flash") + (tmp_path / "bin" / "model").write_text("GLM-5.3", encoding="utf-8") + + with pytest.raises(ModelMismatchError, match="GLM-5.3"): + transport.run_session(_request(tmp_path)) + + +def test_model_mismatch_warns_under_policy_warn(tmp_path): + transport = _transport(tmp_path, strict_backend_model="GLM-5.3-Flash", + model_mismatch_policy="warn") + (tmp_path / "bin" / "model").write_text("GLM-5.3", encoding="utf-8") + req = _request(tmp_path) + + assert transport.run_session(req).text == "REVIEW" + check = next(e for e in req.trace.events + if e["kind"] == "harness_model_check") + assert check["verdict"] == "mismatch" + + +def test_auth_gap_reports_login_fix(tmp_path, monkeypatch): + transport = _transport(tmp_path) + home = tmp_path / "home" + monkeypatch.setenv("ZCODE_DATA_BASE_DIR", str(home)) + gap = transport.auth_gap() + assert gap and "zcode login" in gap + + creds = home / ".zcode" / "v2" / "credentials.json" + creds.parent.mkdir(parents=True) + creds.write_text("{}", encoding="utf-8") + assert transport.auth_gap() is None + # the run logs in from the same data dir the readiness check inspected + transport.complete(system="S", messages=[]) + assert _capture(tmp_path)["data_dir"] == str(home) + + +def test_run_session_timeout_is_truncated(tmp_path): + transport = _transport(tmp_path) + req = _request(tmp_path, with_bridge=False) + req.timeout_s = 0.8 + (tmp_path / "bin" / "sleep").write_text("", encoding="utf-8") + + outcome = transport.run_session(req) + + assert outcome.truncated is True and outcome.text == "" + + +def test_complete_runs_in_scratch_without_bridge(tmp_path): + transport = _transport(tmp_path) + + reply = transport.complete( + system="CLASSIFY", messages=[{"role": "user", "content": "hi"}]) + + assert reply.text == "REVIEW" and reply.model == "GLM-5.3-Flash" + capture = _capture(tmp_path) + assert "imc-zcode-oneshot-" in capture["cwd"] + assert capture["config"] == "" + prompt = next(a for a in capture["argv"] if a.startswith("--prompt=")) + assert "CLASSIFY" in prompt and "[USER]\nhi" in prompt diff --git a/test/test_providers.py b/test/test_providers.py index 21bb919a1..b57538393 100644 --- a/test/test_providers.py +++ b/test/test_providers.py @@ -58,7 +58,7 @@ def test_unknown_backend_rejected_at_startup(): def test_every_harness_id_resolves_a_transport(): - for backend in ("cursor", "claude-code", "codex"): + for backend in ("cursor", "claude-code", "codex", "zcode"): transport = transport_for(_settings(strict_backend=backend)) assert transport.spec.id == backend assert transport.spec.kind == "harness" From 611a88cd6b6db921b8d6048961d7b3014f0e5277 Mon Sep 17 00:00:00 2001 From: tzhouam Date: Wed, 30 Sep 2026 16:59:10 +0800 Subject: [PATCH 2/2] SPEC: re-verify config and providers/registry against the zcode change Both pages already describe the zcode entry; bump verified-against so check_spec_freshness --strict passes. Co-Authored-By: Claude Opus 5.5 --- doc/architecture/SPEC/config.md | 2 +- doc/architecture/SPEC/providers/registry.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/architecture/SPEC/config.md b/doc/architecture/SPEC/config.md index e40a7411a..90d857447 100644 --- a/doc/architecture/SPEC/config.md +++ b/doc/architecture/SPEC/config.md @@ -1,6 +1,6 @@ # config.py —— 规范 - + `LOC ~658 · 配置 · refactor-status: oversized` diff --git a/doc/architecture/SPEC/providers/registry.md b/doc/architecture/SPEC/providers/registry.md index 96e86c0a4..fbd3013f5 100644 --- a/doc/architecture/SPEC/providers/registry.md +++ b/doc/architecture/SPEC/providers/registry.md @@ -1,6 +1,6 @@ # providers/registry.py —— 规范 - + `LOC ~107 · 后端解析(唯一那张表) · refactor-status: ok`