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
11 changes: 7 additions & 4 deletions doc/architecture/CODE_TOUR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 跑到一半才失败。
Expand All @@ -430,15 +430,18 @@ 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`;
**`cursor.py`**(207)两样都做不到——headless
`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 评测臂产品化而来
Expand Down
2 changes: 1 addition & 1 deletion doc/architecture/SPEC/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 再导出文件不单独出规范
Expand Down
2 changes: 1 addition & 1 deletion doc/architecture/SPEC/config.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# config.py —— 规范

<!-- verified-against: 2026-09-19 -->
<!-- verified-against: 2026-09-30 -->

`LOC ~658 · 配置 · refactor-status: oversized`

Expand Down
8 changes: 4 additions & 4 deletions doc/architecture/SPEC/providers/registry.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# providers/registry.py —— 规范

<!-- verified-against: 2026-08-28 -->
<!-- verified-against: 2026-09-30 -->

`LOC ~107 · 后端解析(唯一那张表) · refactor-status: ok`

## 职责
"够到一个模型"的所有方式的**唯一一张表**,以及从配置到 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。

Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions doc/architecture/SPEC/providers/zcode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# providers/zcode.py —— 规范

<!-- verified-against: 2026-09-30 -->

`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` 有记录)。
6 changes: 3 additions & 3 deletions doc/features/provider-registry.md
Original file line number Diff line number Diff line change
@@ -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`;子进程环境是白名单 |
Expand Down
24 changes: 20 additions & 4 deletions doc/guide/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 拿去用的模型。**
Expand Down Expand Up @@ -39,15 +40,16 @@ Agent 订阅(Claude Pro/Max、ChatGPT、Cursor)而没有裸 API Key 的用
两者住在同一张注册表 `providers/registry.py` 里,走同一条解析路径、同一套
trace 词汇。**用 `api` 时行为逐字节不变**(平价棘轮)。

## 2. 五个后端
## 2. 六个后端

| id | kind | 凭据 | 能力 | 验证程度 |
|---|---|---|---|---|
| `api` | api | `.env` 里的 Key | 完整——我们自己的工具循环 | 基线路径 |
| `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)。
Expand All @@ -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`。** 它是唯一行为完全确定、成本可测、且所有评测结论都建立其上的
Expand All @@ -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 查找)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/infermatrix_copilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 - {''})} "
Expand Down
22 changes: 19 additions & 3 deletions src/infermatrix_copilot/providers/registry.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand Down Expand Up @@ -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"})),
)
}

Expand Down Expand Up @@ -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)
Loading
Loading