Skip to content
Merged
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ PR 自动审查闸门守护进程(第 4 组件,experimental):常驻轮

**canonical model id = `~/.zcode/v2/config.json` 里 `models` 的 key 原样**(如 `GLM-5.3`),**不加 provider 前缀**。`shared/credentials.py`、MCP server、ACP bridge、agent-help 四处统一用原始 id。实测(0.16.1 时代)`zai/GLM-5.2` 前缀形式也兼容,但非 canonical,本项目不使用。

模型面现状(App 3.12.3 的 0.16.5 构建实测,2026-09-17;0.16.9/App 3.14.0 于 2026-09-19 复测一致):当前 enabled provider(`builtin:zai-coding-plan`)的 models 为 `GLM-5.3` / `GLM-5.3-Flash`(`GLM-5-Turbo` 已由服务端移除;3.10.2 时代为三者)。
模型面现状(App 3.12.3 的 0.16.5 构建实测,2026-09-17;0.16.9/App 3.14.0 于 2026-09-19 在完整桌面配置机器上复测一致;最小 headless 环境的模型引导见下方新节):当前 enabled provider(`builtin:zai-coding-plan`)的 models 为 `GLM-5.3` / `GLM-5.3-Flash`(`GLM-5-Turbo` 已由服务端移除;3.10.2 时代为三者)。

### 凭证注入:显式环境变量优先

Expand All @@ -247,8 +247,26 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se
```bash
./packages/agent-help/zcode-agent-help --print-injected-env
# 输出每个 key 的来源(config vs env)+ 最终值(apiKey 脱敏)
# 末尾另附「模型解析面」段: config.json 注入模型 + provider_config.json 的
# defaultModelSelection/providerOrder 首位 (只读推断, runtime 不回报实际值)
```

> ⚠️ **env 注入的边界**:以上 env 覆盖在**有完整 `~/.zcode` 配置的机器上无法验证效果**(配置文件兜底掩盖了 env 是否真的被读取);而**最小环境实测中 env 完全不被读取**(0.16.9 官方 runtime 报 `Model creation failed`)。桥保留现有注入行为不变(对既有用户无害);服务器/容器等最小环境还需要完成模型引导,见下一节。

### 最小 headless 环境的模型引导(0.16.9 官方 runtime)

在有完整桌面配置的机器上,模型选择由 `~/.zcode` 下的配置文件兜底,桥的 env 注入是否生效不可分辨;但在**最小环境**(空 HOME、仅入口旁有内置 provider 文件)实测中,官方 runtime **完全不读取** `ZCODE_MODEL` / `ZCODE_BASE_URL` / `ANTHROPIC_API_KEY`,`--prompt` 直接失败退出。服务器/容器部署需要按本节准备:

**前置一步:内置 provider 配置文件**。官方 runtime 按**调用入口路径(不解析符号链接)**探测 `<入口目录>/provider/zcode-builtin.json`。部署时需把官方发行物中的该文件(连同 `provider/` 目录)放到调用入口旁,否则 `--prompt` 报 `无法定位 CLI ZCode Built-in Provider Config`。

**模型选择两条可行路径**:
1. **宿主机先完成一次模型选择**(推荐):桌面 App 登录并选择模型会生成 `~/.zcode/v2/provider_config.json` 注册表(含加密凭证),把整个 `~/.zcode` 配置带到服务器即可;也可以在该文件的 `config.defaultModelSelection` 写 `{"providerId": ..., "modelId": ...}` 指定默认模型。
2. **社区包装器**:`zcode-app-cli`(社区项目,非本项目维护/背书,仅供参考)+ legacy `~/.zcode/cli/config.json` 路径。

**桥的行为**:mcp-server 检测到 `Model creation failed` / `无法定位 CLI ZCode Built-in Provider Config` 两类特征(且既有错误分类为 unknown——限流/配额优先级不变)时,返回 `isError` + 顶层结构化键 `bootstrap.kind`(`model_selection` / `builtin_provider_config`),错误文本前置修复指引;review-gate 把它当**终态**处理(`gave_up` + 告警评论,同 head 不重试、新 head 自动复活),不再 5 次退避后静默放弃。

**ACP 路径**:最小环境 `session/create` 会成功但模型目录为空(`settings.model.available: []`),turn 静默停滞、不产出任何事件。bridge 对空目录打一行 stderr 告警(行为不变,session 照常返回);建议 ACP 调用方在 `session/new` 传 `model` 参数显式选模型——传字符串 model 且目录为空时会得到明确 `-32602`(对象形态不做 catalog 校验,拿到的是后端 `setModel` 错误),而不是静默停滞。

### ZCODE_BASE_URL 残留自动检测(切换过 plan 的用户)

**背景**:ZCode App 切换过订阅 plan 的用户,旧 plan 的 baseURL 会残留在子进程环境(即使该 plan 已失效)。例如 `ZCODE_BASE_URL=https://zcode.z.ai`(start-plan 残留)与 config 当前 enabled 的 `zai-coding-plan`(`api.z.ai`)不一致,会导致 headless 调用打到错误端点(404)。
Expand Down Expand Up @@ -315,7 +333,7 @@ ACP bridge 侧另有一个 env(不在上两表,仅 ACP 用):`ZCODE_ACP_D

| ZCode CLI 版本 | 支持情况 | ACP bridge 流式 | 扩展方法 |
|:--------------:|:--------:|:---------------:|:--------:|
| **0.16.9**(App 3.14.0) | ✅ 完整(核心面与 3.12.3 的 0.16.5 构建一致) | **真流式**(事件驱动) | ✅ session/*(workspace/* 仍删 7/8 仅 generateText 存活;CLI 旗标面增删:`--permission-mode`/`--allow-main-worktree-yolo` 已移除,新增 `--cwd`/`--target-replace` 等,见 agent-help) |
| **0.16.9**(App 3.14.0) | ✅ 完整(核心面与 3.12.3 的 0.16.5 构建一致;最小 headless 环境的模型引导需配置文件,见[^bootstrap]) | **真流式**(事件驱动) | ✅ session/*(workspace/* 仍删 7/8 仅 generateText 存活;CLI 旗标面增删:`--permission-mode`/`--allow-main-worktree-yolo` 已移除,新增 `--cwd`/`--target-replace` 等,见 agent-help) |
| **0.16.5**(App 3.12.3,同版本号构建漂移) | ✅ 完整(核心面) | **真流式**(事件驱动) | ✅ session/*(workspace/* 删 7/8 仅 generateText 存活;updateRuntimeModelConfig/updateInteractionPreferences 已删,透传优雅降级) |
| **0.16.5**(App 3.10.2) | ✅ 完整 | **真流式**(事件驱动) | ✅ session/* + workspace/*(与 0.16.1 同面;`automation/*` 未实现不受其删除影响) |
| **0.16.1**(App 3.6.5) | ✅ 完整 | **真流式**(事件驱动) | ✅ session/* + workspace/*(`steer`/`rewind*`/`prompt/enhance*` 已于 0.16 移除;`updateRuntimeModelConfig` 存活但 `runtimeModel.revision` 必填) |
Expand All @@ -331,6 +349,8 @@ ACP bridge 侧另有一个 env(不在上两表,仅 ACP 用):`ZCODE_ACP_D
>
> ⚠️ **`session/setModel` 对象形态的适用范围**:仅在 0.16.x 验证(0.16.9 实测可用);≤0.15 后端未验证该形态(历史 `{modelId}` 字符串形态在 0.16 schema 下必拒,故桥已切换为对象形态——这是破坏性变更,旧版调用方需注意)。

[^bootstrap]: 最小 headless 环境不能依赖 env 注入完成模型引导(0.16.9 官方 runtime 实测 env 不被读取),需按「最小 headless 环境的模型引导」准备 `provider/zcode-builtin.json` 与 `~/.zcode/v2/provider_config.json`;否则 mcp-server 返回结构化 `bootstrap` 键、review-gate 按终态告警。

**降级行为**:
- 轮询降级**仅限 legacy(< 0.16)协议模式**:旧版下 `session/subscribe` 不可用时自动切换到轮询 `session/read`(伪流式)。**0.16+ 不再自动降级**——新协议模式下 subscribe 失败直接报错 `-32603`("0.16+ 必须走事件订阅;轮询降级仅限旧协议模式")。
- 轮询(legacy)路径的失败检测有固有局限:该路径收不到 `turn.failed` 事件(projection/messages 无失败标志),turn 失败只能靠「status=idle 但本轮无任何实质输出(text/tool/patch)」的启发式检测,可能误报(成功但无实质输出的 turn 被判失败)或漏报(失败前已吐出部分内容的 turn 被当成功);0.16+ 事件路径无此局限(`turn.failed` 终止帧已能正确判失败)。
Expand Down
33 changes: 31 additions & 2 deletions packages/acp-bridge/zcode-acp-bridge
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,9 @@ class ACPBridge:
js_add_node_module_dir 与 mcp__node_repl__js*): 仅禁 Write/Edit/
Bash 会被其 execSync 打穿 (0.16.1 实测先例, 见 README「只读原理」
与「重要限制」#7)。
(#55) 未传 model 且 create 快照的 settings.model.available 为空列表
(最小/未引导环境) 时, 打一行 stderr 告警 — ACP 路径 turn 会静默
停滞, 空目录是唯一确定性本地信号; 行为不变 (session 照常登记返回)。
"""
self.ensure_backend()
cwd = params.get("cwd") or os.getcwd()
Expand Down Expand Up @@ -1425,6 +1428,26 @@ class ACPBridge:
# ACP sessionId: 用 zcode 的或生成一个
acp_sid = zcode_sid # 格式兼容, 直接用

# issue #55: 最小/未引导环境的确定性本地信号 — create 快照的模型目录
# 为空。ACP 路径 turn 会静默停滞 (无 turn.failed、无错误文本可匹配),
# 故在创建时打一行 stderr 告警; 行为不变 (session 照常登记返回)。
# 传了 model 参数的调用方由 _resolve_session_model 给出明确 -32602
# (目录为空报「不在本会话的模型目录中」), 不重复告警。
if model_param is None:
# settings 可能是真值非 dict (list/str): 先 isinstance 判空再取键,
# 防 AttributeError 把成功路径打成 -32603 (同 _resolve_session_model
# 的形态防御思路)
_settings = result.get("settings")
_settings_model = (_settings.get("model")
if isinstance(_settings, dict) else None)
_available = (_settings_model.get("available")
if isinstance(_settings_model, dict) else None)
if isinstance(_available, list) and not _available:
log("⚠ session/new: 模型目录为空 (settings.model.available=[]), "
"turn 可能停滞无法产出 — 请在宿主机完成模型引导 (见 README"
"「最小 headless 环境的模型引导」), 或 session/new 传 model "
"参数显式选模型")

# 可选 model: 归一后经 setModel 应用 (见 docstring 为何不走 create 形参)
if model_param is not None:
selection = self._resolve_session_model(model_param, result)
Expand Down Expand Up @@ -1511,8 +1534,14 @@ class ACPBridge:
if isinstance(model_param, dict):
return model_param
wanted = model_param.strip()
settings_model = ((create_result.get("settings") or {}).get("model") or {})
available = settings_model.get("available")
# settings/settings.model 可能是真值非 dict (list/str): 先 isinstance
# 判空再取键, 与 session/new 空目录守卫同款防御 — 否则调用方传字符串
# model 时会 AttributeError 打成 -32603, 且丢失已创建 sessionId。
settings = create_result.get("settings")
settings_model = (settings.get("model")
if isinstance(settings, dict) else None)
available = (settings_model.get("available")
if isinstance(settings_model, dict) else None)
# 容错: catalog 缺失/形态漂移按「不可用」报, 非 dict 条目跳过 (防
# AttributeError 把 -32602 变成 -32603 内部错)
if not isinstance(available, list):
Expand Down
84 changes: 78 additions & 6 deletions packages/agent-help/zcode-agent-help
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ zcode-agent-help — ZCode 开放生态能力说明书
zcode-agent-help # 完整能力清单 (JSON)
zcode-agent-help --pretty # 人类可读的格式化输出
zcode-agent-help --section <name> # 只看某一节
zcode-agent-help --print-injected-env # 诊断: 会注入哪些凭证 (脱敏, issue #3)
zcode-agent-help --print-injected-env # 诊断: 会注入哪些凭证 + 模型解析面 (脱敏, issue #3/#55)
zcode-agent-help --help # 本帮助

可用 section:
Expand All @@ -34,6 +34,12 @@ ZCODE_BIN = os.environ.get("ZCODE_BIN", "zcode")
# 实测 "zai/GLM-5.2" 也兼容, 但与 shared/credentials.py 权威实现不一致, 故统一用原始 id。
ZCODE_CREDS_PATH = Path.home() / ".zcode" / "v2" / "config.json"

# issue #55: 模型解析面只读诊断用的官方 runtime 模型注册表 (桌面 App 完成
# 模型选择后生成)。只读, 不写任何文件; 缺失/损坏按「未配置/不可解析」展示。
ZCODE_PROVIDER_CONFIG_PATH = (
Path.home() / ".zcode" / "v2" / "provider_config.json"
)


def _load_creds_internal(config_path=None):
"""内嵌的凭证读取 (源自 shared/credentials.py, 单文件可独立运行故内嵌)。
Expand Down Expand Up @@ -84,17 +90,76 @@ def _safe_host(url):
return None


def print_injected_env(config_path=None):
def _read_model_selection(provider_config_path):
"""只读 v2/provider_config.json (#55), 返回 (defaultModelSelection 串, providerOrder 首位)。

容错契约: 文件缺失/权限错误/JSON 损坏/config 形状不符/选择对象缺字段
→ "(未配置/不可解析)"; defaultModelSelection 键存在但值为 null → "(未设置)"。
不抛错、不写任何文件 (可观测性最小诚实版: 只反映配置面, 不探测 runtime)。
"""
unparsable = "(未配置/不可解析)"
try:
with open(provider_config_path) as f:
data = json.load(f)
except (OSError, ValueError):
# FileNotFoundError/PermissionError → OSError; JSONDecodeError → ValueError
return unparsable, None
config = data.get("config") if isinstance(data, dict) else None
if not isinstance(config, dict):
return unparsable, None
selection = config.get("defaultModelSelection")
if selection is None:
selection_text = "(未设置)"
elif isinstance(selection, dict):
pid = selection.get("providerId")
mid = selection.get("modelId")
if isinstance(pid, str) and pid and isinstance(mid, str) and mid:
selection_text = f"{pid}/{mid}"
else:
selection_text = unparsable
else:
selection_text = unparsable
order = config.get("providerOrder")
order_first = (order[0] if isinstance(order, list) and order
and isinstance(order[0], str) and order[0] else None)
return selection_text, order_first


def _print_model_resolution(creds, provider_config_path):
"""打印「模型解析面」段 (只读配置面推断; #55)。

层 1: v2/config.json enabled provider 的 model (桥的现注入口径);
层 2: v2/provider_config.json 的 defaultModelSelection + providerOrder 首位
(runtime 兜底解析顺序的第一个)。
固定免责: runtime 不回报实际使用的模型 id; 0.16.9 最小环境 env 注入不被
读取 — 不承诺做不到的事 (真实模型 id 事后不可得)。
"""
print("\n模型解析面 (只读推断, 不写任何文件):")
layer1 = creds.get("ZCODE_MODEL") or "(未配置)"
print(f" 1. config.json enabled provider model: {layer1} (桥的现注入口径)")
selection, order_first = _read_model_selection(provider_config_path)
print(f" 2. provider_config.json defaultModelSelection: {selection}")
print(f" providerOrder 首位 (runtime 兜底解析顺序): "
f"{order_first or '(未设置)'}")
print(" 免责: runtime 不回报实际使用的模型 id; 以上为配置面推断。"
"0.16.9 最小环境 env 注入不被读取 (见 README「最小 headless 环境的模型引导」)")


def print_injected_env(config_path=None, provider_config_path=None):
"""打印"如果现在注入凭证, 会用哪些值"(脱敏), 用于不改 config 诊断 model/baseURL 来源。

显示每个 key 的来源 (config vs 显式 env) + 最终值 (apiKey 脱敏),
体现"显式 env 优先"的合并语义 (issue #3 子项2)。
体现"显式 env 优先"的合并语义 (issue #3 子项2); 末尾附「模型解析面」段
(层 1 config.json 模型 + 层 2 provider_config.json 的默认选择/顺序, #55)。

Args:
config_path: 可选, 自定义配置文件路径 (测试用, 整体 review P2-3)。
默认 ZCODE_CREDS_PATH。
provider_config_path: 可选, 模型注册表路径 (测试用, #55)。
默认 ZCODE_PROVIDER_CONFIG_PATH。
"""
path = config_path or ZCODE_CREDS_PATH
provider_config_path = provider_config_path or ZCODE_PROVIDER_CONFIG_PATH
creds = _load_creds_internal(path)
keys = ["ZCODE_MODEL", "ZCODE_BASE_URL", "ANTHROPIC_API_KEY"]
if not creds:
Expand All @@ -103,13 +168,15 @@ def print_injected_env(config_path=None):
env_hits = {k: v for k in keys if (v := os.environ.get(k))}
if not env_hits:
print(f"❌ 未从 {path} 读取到凭证 (config 无 enabled provider 或文件缺失)")
_print_model_resolution(creds, provider_config_path)
return 0
print("=== 凭证注入诊断 (显式非空 env 优先于 config; 空串视为未设置) ===")
print(f"config 源: {path}")
print("⚠ config 无 enabled provider, 将直接使用 env 值:")
for k, v in env_hits.items():
shown = _mask_key(v) if k == "ANTHROPIC_API_KEY" else v
print(f" {k}: {shown} (来自 env)")
_print_model_resolution(creds, provider_config_path)
return 0
# 读 config 里所有 provider 的 baseURL (残留检测用, host 集合)
# 整体 review P1-1: 用与权威版逐字对齐的 _safe_host 构造, 消除诊断与真实注入的漂移
Expand Down Expand Up @@ -154,6 +221,7 @@ def print_injected_env(config_path=None):
else:
shown = _mask_key(config_val) if k == "ANTHROPIC_API_KEY" else config_val
print(f" {k}: {shown} (来自 config)")
_print_model_resolution(creds, provider_config_path)
return 0


Expand Down Expand Up @@ -209,8 +277,12 @@ NATIVE_CLI = {
"ANTHROPIC_API_KEY": {"description": "API 密钥 (provider kind 为 anthropic)", "example": "<your-key>"},
},
"config_note": (
"~/.zcode/cli/config.json 的 model 字段在 --prompt 模式下不被读取, "
"必须用上述环境变量配置。凭证可从 ~/.zcode/v2/config.json 动态读取注入。"
"~/.zcode/cli/config.json 的 model 字段在 --prompt 模式下不被读取。"
"环境变量注入是条件性生效的: 0.16.9 官方 runtime 的最小环境实测中 "
"ZCODE_MODEL 等 env 完全不被读取 (报 Model creation failed), 模型选择"
"需宿主机 v2/provider_config.json 配置; 完整桌面配置机器上 env 是否"
"生效亦未被隔离验证 (见 README「最小 headless 环境的模型引导」)。"
"凭证可从 ~/.zcode/v2/config.json 动态读取注入。"
),
"headless_modes": [
{
Expand Down Expand Up @@ -808,7 +880,7 @@ def print_pretty(cap):
# native_cli
nc = cap["native_cli"]
print("▼ 第一层: 原生 Headless CLI")
print(" 必需环境变量: ZCODE_MODEL / ZCODE_BASE_URL / ANTHROPIC_API_KEY")
print(" 凭证环境变量 (条件性生效, 详见下): ZCODE_MODEL / ZCODE_BASE_URL / ANTHROPIC_API_KEY")
print(f" ⚠ {nc['config_note'][:60]}...\n")
for m in nc["headless_modes"]:
print(f" [{m['name']}] {m['description']}")
Expand Down
Loading
Loading