Compare commits

..
Author SHA1 Message Date
anandh8x fbfc2a07fa fix: add android platform support for Termux npm install
`npm install -g @gitlawb/zero` failed on Android/Termux with EBADPLATFORM
because `package.json` `os` array lacked `"android"`. Termux provides a
Linux userspace — the existing `linux-arm64` binary works natively.

- Add `"android"` to the `os` array in package.json.
- Map `android` platform to `linux` in postinstall.mjs resolvePlatform().

Fixes #449
2026-07-03 19:17:51 +05:30
CengSinandzhangzetao b63fe696fd modify readme.md and add readme_zh.md (#436)
Co-authored-by: zhangzetao <[email protected]>
2026-07-03 17:16:41 +08:00
Baran3575andBaran3575 3fbbd28e4c fix(provider-wizard): allow multiple custom OpenAI-compatible providers (#403)
* fix(provider-wizard): allow multiple custom OpenAI-compatible providers

Generic/custom providers (custom-openai-compatible, custom-anthropic-compatible)
all share the same CatalogID. wizardProviderStoredKey matched by CatalogID,
which blocked creating more than one custom provider.

Fix: in advanceProviderWizard, skip the ManageKey diversion for custom
providers and route directly to the endpoint step instead. The user can
overwrite an existing instance by re-entering the same name, or create a
new one with a different name.

Named providers (OpenAI, Anthropic, etc.) are unaffected — ManageKey is
still shown for them.

Fixes #375

* refactor(provider-wizard): deduplicate advance/discovery branch, add anthropic-compatible test

---------

Co-authored-by: Baran3575 <[email protected]>
2026-07-03 17:03:37 +08:00
euxaristiaandClaude Fable 5 b9ddd6f421 feat(sandbox): unelevated Windows fallback tier instead of prompts-only degrade (#427)
Before the one-time elevated `zero sandbox setup` has run, Windows dropped
straight from the native sandbox to the in-process policy gate: commands ran
with no OS enforcement at all. But only the WFP network filters actually need
Administrator rights - the write-restricted token and the workspace ACL grants
(DACL edits on user-owned roots) work unelevated.

Add an `unelevated` enforcement tier between native and degraded: when the
elevated setup marker is missing and the profile restricts the filesystem, the
command still wraps through the command runner at a new `unelevated` sandbox
level. The runner applies the workspace ACL plan itself (recording applied
plans by hash in windows-unelevated-setup.json so repeat commands skip the
apply) and launches under the same write-restricted token, so the OS-level
write-jail holds. Network enforcement stays with the per-command approval gate
at this tier, and the downgrade reason says so; profiles that need the sandbox
only for network isolation keep the old degrade behavior, and `--sandbox
require` still errors with the setup hint.

Verified end to end on Windows 11 without elevation: a write inside the
workspace succeeds under the restricted token, a write outside every granted
root is denied by the OS (new ZERO_SANDBOX_REAL_SMOKE integration test, using
cmd.exe because managed PowerShell cannot initialize its crypto provider under
a write-restricted token on some hosts).

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-03 16:38:28 +08:00
Jochen aaad4d2712 feat(providers): split minimax zai into intl cn (#398)
* feat(providers): split minimax zai into intl cn

- Add minimaxi-cn (api.minimaxi.com) sharing model catalog with minimax
- Add zai-cn (open.bigmodel.cn) splitting zai from china to intl
- Decouple env vars: ZAI_API_KEY vs ZHIPU_API_KEY for zai/zai-cn,
  MINIMAX_API_KEY vs MINIMAXI_API_KEY for minimax/minimaxi-cn
- Map zai-cn and minimaxi-cn to models.dev providers "zai" and "minimax"
  so the model catalog hydrates for both endpoints

* chore(providers): address review feedback on intl/cn split

- Run gofmt on internal/providermodelcatalog/catalog.go (map-value
  alignment on the \"zai\" entry was off by one space, fails CI lint)
- Update stale comment in TestResolveAppliesZaiInternationalCatalogDefaults
  to say glm-4.5 default (live discovery upgrades to glm-5.2)
- Add missing ProviderKind, APIKeyEnv, and APIKey assertions to
  TestResolveAppliesZaiInternationalCatalogDefaults so it would catch
  a silent regression in ZAI_API_KEY routing
2026-07-03 16:33:11 +08:00
KRATOS b4275e3504 feat(providers): add Meituan LongCat catalog preset (#424)
Add LongCat-2.0 as a first-class OpenAI-compatible provider preset so it can be
selected by name in the setup wizard / `zero providers setup longcat` instead of
hand-entering a custom endpoint.

LongCat-2.0 is Meituan's open, OpenAI- and Anthropic-compatible model. This
wires the OpenAI-compatible transport:
  base URL: https://api.longcat.chat/openai  (Zero appends /chat/completions)
  model:    LongCat-2.0
  auth:     Bearer via LONGCAT_API_KEY

Purely additive — one descriptor plus its catalog/onboarding tests and a README
env-var line; no shared plumbing is touched, so existing providers are
unaffected. Placed with the other OpenAI-compatible third parties (after
moonshot), below the first-party providers.

Verified: go build / vet / test green (catalog + cli onboarding), gofmt clean,
and `zero providers catalog` lists the descriptor with the correct fields.
Endpoint is wired by-spec from LongCat's published docs; a live completion still
needs a real LONGCAT_API_KEY to confirm.
2026-07-03 12:44:11 +08:00
Alaa Elsamouly 5b2b4dea1a fix(tui): compose help overlay through the viewport overlay pipeline (#421)
* fix(tui): compose help overlay through the viewport overlay pipeline

exactly like how other popups works.

* fix for the CI/CD of formatting

* Added regression tests for the help overlay

* formatted code
2026-07-03 11:33:17 +08:00
Abdias J b5a78dca6f docs(install): document Bun install and improve missing-binary error (#418)
Bun does not run dependency lifecycle scripts by default, so the npm
wrapper's postinstall (which fetches the native Zero binary from GitHub
Releases) is silently skipped. The first run then fails with an
unhelpful "No native binary found" message that points at a source-only
build command, leaving Bun users stuck.

Add a Bun section to README.md and docs/INSTALL.md with the two working
install paths (run postinstall.mjs manually, or add the package to the
project's trustedDependencies). Rewrite the wrapper's missing-binary
error to give an actionable fix for everyone and a Bun-specific hint
when run under Bun, instead of only suggesting a source checkout.
2026-07-03 07:38:25 +08:00
KRATOS 952788f72d fix(tui): resolve every permission request so the agent can't deadlock (#397)
A goroutine dump of a run "stuck for 33 minutes" showed the agent parked
forever in executeToolCall -> requestPermission, blocked on the TUI's
decision channel. Root cause: a shell command whose sandbox decision was
Allow but which requested additional sandbox permissions
(sandbox_permissions: with_additional_permissions) made shouldRequestPermission
block on OnPermissionRequest, yet buildPermissionEvent stamped the event
Action=allow. The TUI's permissionRequestMsg handler only rendered/resolved
Action=prompt requests and silently dropped everything else — so the decide
callback was never called, the agent waited on a channel nothing would signal
(no ctx cancellation on the active run), and the UI showed "Working" with no
prompt and zero network activity. Intermittent and model-dependent: gpt-5.5's
folder-creation command set the additional-permissions flag, glm's did not,
which is exactly why one hung and the other ran clean.

Two-part fix:

- agent (buildPermissionEvent): an additional-permissions request is an
  ELEVATION the user must consent to, even over a sandbox Allow, so mark it
  Action=prompt (matching shouldRequestPermission's own condition). It now
  surfaces as a real prompt instead of a dropped auto-allow.

- tui (permissionRequestMsg handler): enforce the contract that a blocked
  agent ALWAYS gets a decision. A non-prompt action resolves immediately and
  FAILS CLOSED (deny; cancel stays cancel) so the UI can never silently grant
  an unsurfaced permission; a superseded-run (runID mismatch) resolves as
  cancel so its parked goroutine unblocks without depending on ctx-cancel
  ordering. Matches the ACP handler's always-resolve, fail-closed contract.

Red-green: reverting the tui branch reproduces the deadlock (decide never
called); reverting the agent override reproduces Action=allow. End-to-end
against a mock backend emitting the exact triggering tool call, the real TUI
now renders the permission prompt and the run completes on approval, where
before it parked forever.
2026-07-03 06:02:57 +08:00
Gautam ManchandaniandGautam Manchandani f915f70e5a fix(mcp): block cross-origin credential redirects (#396)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-03 06:02:05 +08:00
Vasanth T cdf9d839ae fix(tui): title /model rows by model name, not the catalog description (#395)
The model picker rendered each row's title from the model's description via
modelPickerDisplayName, which returned the description whenever it was non-empty
and not a known-generic placeholder. Catalog/discovery descriptions come from
models.dev as full sentences (e.g. "DeepSeek chat model for instruction
following, coding, and analysis"), and several models share the same sentence —
so the picker showed sentence-long titles and rows that looked like exact
duplicates, with the real model id surviving only in the meta line.

Prefer the (prettified) model id as the row title; fall back to a non-generic
description only when there is no id to name the row. Distinct ids that share a
catalog description now render as distinct rows.
2026-07-02 23:29:12 +08:00
46 changed files with 1979 additions and 544 deletions

No files matched your search

+30
View File
@@ -8,6 +8,8 @@
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
Zero is an AI coding agent for your local terminal. It can inspect a repository,
@@ -51,6 +53,27 @@ The npm package installs a small wrapper plus the matching Zero binary for your
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
arm64.
### Bun
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
Install with Bun, then fetch the binary in one of two ways:
```bash
# Option A: run the installer manually
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# Option B: allow the postinstall to run on install
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
global install path, or prefer the install scripts below.
### Install scripts
Linux/macOS:
@@ -121,6 +144,13 @@ the key in the wizard:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
```
To configure Meituan LongCat (LongCat-2.0) directly, run:
```bash
zero providers setup longcat --set-active
```
For local models, run Ollama or LM Studio and then use `zero setup` or
+297
View File
@@ -0,0 +1,297 @@
<p align="center">
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
</p>
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
```bash
zero
zero exec "修复 ./pkg 中失败的测试"
zero exec --output-format stream-json < turns.jsonl
```
## 为什么选择 Zero
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
## 安装
### npm
```bash
npm install -g @gitlawb/zero
zero
```
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
使用 Bun 安装后,通过以下两种方式之一获取二进制文件:
```bash
# 选项 A:手动运行安装程序
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# 选项 B:允许 postinstall 在安装时运行
# 添加到你的 package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
对于全局安装(`bun add -g @gitlawb/zero`),请对全局安装路径使用选项 A,或使用下面的安装脚本。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.25+。
```bash
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
发布安装器和 npm 包需要已发布的 GitHub Release 资源。如果你在首次公开发布之前进行测试,请从源码构建:
```bash
go build -o zero ./cmd/zero
```
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
## 首次运行
启动 TUI:
```bash
zero
```
设置向导帮助你选择提供商和模型。你也可以从命令行配置提供商:
```bash
zero setup
zero providers list
zero models list
zero doctor
```
对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:
```bash
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
```bash
zero providers setup longcat --set-active
```
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
## 日常使用
### 交互式 TUI
```bash
zero
```
常用控制:
| 控制 | 操作 |
|---|---|
| `Enter` | 发送提示 |
| `/` | 打开斜杠命令建议 |
| `Shift+Tab` | 切换权限模式 |
| `Ctrl+B` | 显示/隐藏侧边栏 |
| `Ctrl+C` | 取消或退出 |
常用斜杠命令:
| 命令 | 用途 |
|---|---|
| `/model`、`/provider` | 切换活动模型/提供商 |
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
| `/image` | 为视觉模型附加图片 |
| `/resume`、`/rewind` | 继续或回滚本地会话 |
| `/compact`、`/context` | 管理上下文使用 |
| `/permissions`、`/tools` | 检查可用工具和策略 |
| `/add-dir` | 为此会话授予额外的写入目录 |
| `/theme`、`/doctor`、`/config` | 调整外观和检查设置 |
### 无头 `exec` 模式
```bash
zero exec "解释 internal/agent/loop.go"
zero exec --model claude-sonnet-4.5 "重构配置加载器"
zero exec --use-spec "为 API 客户端添加速率限制"
zero exec --worktree "在隔离的工作树中尝试迁移"
zero exec --resume
zero exec --fork <session-id> "尝试另一种方法"
```
编程使用:
```bash
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
```
Stream-JSON 协议文档在 [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md)。
## 安全模型
Zero 旨在使副作用可见。
- 工作区读取默认允许。
- 文件写入限制在工作区内,除非你授予其他目录。
- Shell 命令、网络访问、破坏性命令和提权操作需要权限授权。
- `--add-dir <path>` 和 `/add-dir <path>` 授予额外的写入根目录,而不会给智能体整个文件系统。
- 不安全/自主模式是显式选择加入。
- 在 Zero 控制的界面上,密钥会从工具输出和日志中被脱敏。
示例:
```bash
zero --add-dir ../docs-site
zero exec --add-dir ../shared "更新两个仓库"
```
沙箱行为可以通过以下方式检查:
```bash
zero sandbox policy
zero sandbox grants list
```
## Web 和本地控制
Zero 包含本地文件/搜索/编辑/Shell 工具、用于公共 URL 的 `web_fetch`,以及用于额外工具的 MCP 支持。
对于本地开发服务器,使用 Shell 命令(如通过 `exec_command` 的 `curl`),这样正常的沙箱和权限策略就会生效。长时间运行的命令保持附加到后台终端会话,可以从 TUI 中列出或停止。
npm 包还包含本地浏览器/终端工具使用的浏览器和终端辅助包。源码构建可以在它们位于 `PATH` 上或在 Zero 的本地控制设置中配置时使用相同的辅助工具。
## 常用命令
```text
zero 交互式 TUI
zero exec 一次性或脚本化智能体运行
zero setup 首次运行提供商设置
zero auth 支持提供商的 OAuth/登录辅助
zero models 模型注册表和能力
zero providers 提供商配置和检测
zero doctor 设置、密钥和连接检查
zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉和回滚会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
zero plugins 管理插件
zero hooks 管理生命周期钩子
zero mcp 管理 MCP 服务器和工具
zero serve --mcp 通过 MCP stdio 暴露 Zero 工具
zero sandbox 检查沙箱策略和授权
zero worktrees 准备隔离的 Git 工作树
zero verify 检测和运行本地验证检查
zero changes 检查和提交本地 Git 变更
zero usage Token 使用量和估算成本
zero cron 定时智能体任务
zero update 检查更新版本
```
## 外观和无障碍
| 控制 | 效果 |
|---|---|
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
含义不仅仅依赖于颜色;差异、权限和状态也使用文本或标记符号。
## 开发
```bash
go test ./...
go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
交叉编译示例:
```bash
go run ./cmd/zero-release build --goos linux --goarch amd64
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
```
## 文档
- [安装](docs/INSTALL.md)
- [更新流程](docs/UPDATE.md)
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
- [专家](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
- [基准测试](docs/BENCHMARK.md)
- [性能](docs/PERFORMANCE.md)
- [智能体评估](docs/AGENT_EVALS.md)
## 贡献
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
安全报告应遵循 [SECURITY.md](SECURITY.md)。
## 许可证
Zero 基于 [MIT 许可证](LICENSE) 发布。
+19 -1
View File
@@ -54,8 +54,26 @@ const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
if (!existsSync(nativePath)) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
console.error(
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
'[zero] No native binary found next to the npm wrapper.\n' +
'The platform binary is fetched at install time by a postinstall script,\n' +
'which did not run (or was skipped) for this install.\n' +
'\n' +
'Fix it now by running the installer manually:\n' +
` node "${postinstallScript}"\n` +
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. To let it run on future installs, add zero to your\n' +
'project\'s trustedDependencies:\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'then reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
'(go run ./cmd/zero, requires Go 1.25+).',
);
process.exit(1);
}
+32
View File
@@ -24,6 +24,38 @@ Requirements:
- Node.js 18+
- network access to npm and GitHub Releases
## Bun
Bun is "default-secure" and does not run lifecycle scripts of installed
dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
To install with Bun, either run the installer manually after installing:
```bash
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Or allow the postinstall to run by adding the package to your project's
`trustedDependencies` before installing:
```json
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
against the global install path, or use the install scripts below.
Reference: <https://bun.sh/docs/pm/lifecycle>
## Linux And macOS Script
Install the latest release:
-76
View File
@@ -2,7 +2,6 @@ package agent
import (
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -285,67 +284,12 @@ const (
noOutputStopSuffix = "to avoid consuming tokens without making progress."
)
// providerEmptyStream reports that a cleanly-completed turn carried NOTHING —
// no visible text, no tool calls (real or dropped), and no reasoning signal.
// That shape is a transport/backend fault (e.g. the ollama cloud relay
// answering 200 with an instantly-empty SSE under load), not model behavior:
// a model that is generating always leaves SOME trace (text, a tool call
// attempt, or reasoning deltas).
func providerEmptyStream(collected zeroruntime.CollectedStream) bool {
return strings.TrimSpace(collected.Text) == "" &&
len(collected.ToolCalls) == 0 &&
collected.DroppedToolCalls == 0 &&
len(collected.ReasoningBlocks) == 0 &&
!collected.ReasoningEmitted
}
// allEmptyTurnsProviderEmpty reports that every strike toward the no-output
// guard was a provider-empty stream, so the stop message should name the
// backend fault rather than generic agent no-progress.
func (state *guardState) allEmptyTurnsProviderEmpty() bool {
return state.emptyTurns > 0 && state.providerEmptyTurns == state.emptyTurns
}
// noOutputStopAnswer is the final answer returned when the no-output guard
// stops the run. The turn count is interpolated at the call site.
func noOutputStopAnswer(turns int) string {
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
}
// providerEmptyStopAnswer is the final answer when every guard strike was a
// provider-empty stream: the backend repeatedly answered with contentless
// completions (already retried with backoff inside each turn). It tells the
// user the truth — the provider failed, not the agent — and what to do next.
// detail carries wire-level diagnostics from the last empty attempt
// (finish reason, token counts) so the condition is debuggable straight from
// the session log — this exact failure class previously took a live traffic
// capture to diagnose because nothing about the response shape was recorded.
func providerEmptyStopAnswer(turns int, detail string) string {
answer := providerEmptyStopPrefix +
strconv.Itoa(turns) + " consecutive attempts, each already retried with backoff. " +
"The backend is likely rate-limiting or degraded right now — try again shortly, or switch providers/models (zero providers use <name>)."
if detail != "" {
answer += " [last attempt: " + detail + "]"
}
return answer
}
// emptyStreamDetail renders the wire-level shape of an empty attempt for
// providerEmptyStopAnswer.
func emptyStreamDetail(collected zeroruntime.CollectedStream) string {
finish := collected.FinishReason
if finish == "" {
finish = "stop"
}
return fmt.Sprintf("finish_reason=%s, output_tokens=%d, reasoning_tokens=%d",
finish, collected.Usage.OutputTokens, collected.Usage.ReasoningTokens)
}
// providerEmptyStopPrefix is the stable head of providerEmptyStopAnswer, used
// by IsNoProgressStop so session titling / resume filtering treat the
// provider-empty stop like the other guard answers (not real content).
const providerEmptyStopPrefix = "The model provider returned an empty response (no text, no tool calls, no reasoning) on "
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
// (a run that produced no visible text and no tool calls). It matches the EXACT
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
@@ -356,15 +300,6 @@ const providerEmptyStopPrefix = "The model provider returned an empty response (
// and skip its title generation.
func IsNoProgressStop(content string) bool {
trimmed := strings.TrimSpace(content)
// The provider-empty variant: stable prefix + a bare integer count.
if rest, ok := strings.CutPrefix(trimmed, providerEmptyStopPrefix); ok {
if sep := strings.Index(rest, " consecutive attempts"); sep > 0 {
if _, err := strconv.Atoi(rest[:sep]); err == nil {
return true
}
}
return false
}
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
return false
}
@@ -417,7 +352,6 @@ func toolOnlyProgressReminder(turns int) string {
// purely from tool-call names and per-turn output, matching what the loop holds.
type guardState struct {
emptyTurns int
providerEmptyTurns int
totalToolCalls int
toolCallsSincePlanUpdate int
planEverCalled bool
@@ -483,18 +417,8 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
if hasToolCalls || hasVisibleText {
state.emptyTurns = 0
state.providerEmptyTurns = 0
} else {
state.emptyTurns++
// Track separately whether the empty turn was a PROVIDER-empty stream
// (nothing at all came back — not even reasoning) vs a behavioral empty
// (the model thought/streamed but committed no answer). When every
// strike was provider-empty, the stop message names the backend fault
// instead of blaming the agent — the difference between the user
// retrying/switching providers and filing a "the agent gives up" bug.
if providerEmptyStream(collected) {
state.providerEmptyTurns++
}
}
if hasToolCalls && !hasVisibleText {
state.toolOnlyTurns++
+11 -116
View File
@@ -9,23 +9,11 @@ import (
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// emptyTurn is a PROVIDER-empty stream: it completes cleanly carrying nothing
// at all (the ollama-cloud 200+[DONE] shape). The loop retries these in-turn
// before counting a no-output strike.
// emptyTurn is a stream that produces no visible text and no tool calls.
func emptyTurn() []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
}
// reasoningOnlyTurn is a BEHAVIORAL empty turn: the model streamed reasoning
// (so the provider clearly worked) but committed no text and no tool calls.
// These are NOT retried in-turn; they count directly toward the no-output guard.
func reasoningOnlyTurn() []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventReasoning, Content: "thinking…"},
{Type: zeroruntime.StreamEventDone},
}
}
// textTurn produces a turn with visible assistant text.
func textTurn(content string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -57,9 +45,9 @@ func countUserMessagesContaining(messages []zeroruntime.Message, needle string)
func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
emptyTurn(),
emptyTurn(),
emptyTurn(),
// A 4th turn exists but must never be requested.
textTurn("should never reach here"),
},
@@ -89,10 +77,10 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(),
reasoningOnlyTurn(),
emptyTurn(),
emptyTurn(),
textTurn("here is real progress"), // resets the counter and is the final answer
reasoningOnlyTurn(),
emptyTurn(),
},
}
@@ -121,11 +109,11 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(),
reasoningOnlyTurn(),
emptyTurn(),
emptyTurn(),
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // resets counter
reasoningOnlyTurn(),
reasoningOnlyTurn(),
emptyTurn(),
emptyTurn(),
textTurn("done"),
},
}
@@ -467,96 +455,3 @@ func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
t.Fatalf("expected a tool-failure hint on the 3rd turn, messages: %+v", provider.requests[2].Messages)
}
}
// A provider-empty stream (clean completion carrying nothing at all) is
// retried in-turn with backoff before it may count as a no-output strike; when
// the backend keeps answering empty, the run stops with a message that names
// the PROVIDER fault — not the generic "agent made no progress" text. This is
// the exact live failure observed on the ollama cloud relay (HTTP 200 in ~1s,
// SSE straight to [DONE]), which silently killed 56% of sessions on the
// reporting machine.
func TestRunRetriesProviderEmptyStreamsThenStopsWithProviderMessage(t *testing.T) {
turns := make([][]zeroruntime.StreamEvent, 0, 9)
for i := 0; i < 9; i++ { // 3 strikes × (1 initial + 2 in-turn retries)
turns = append(turns, emptyTurn())
}
provider := &mockProvider{turns: turns}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
wantRequests := maxEmptyTurns * (1 + maxEmptyStreamRetries)
if len(provider.requests) != wantRequests {
t.Fatalf("expected %d requests (%d strikes × %d attempts), got %d",
wantRequests, maxEmptyTurns, 1+maxEmptyStreamRetries, len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "provider returned an empty response") {
t.Fatalf("expected the provider-empty stop message, got %q", result.FinalAnswer)
}
if strings.Contains(result.FinalAnswer, noOutputStopMarker) {
t.Fatalf("provider fault must not be reported as generic agent no-progress: %q", result.FinalAnswer)
}
if !IsNoProgressStop(result.FinalAnswer) {
t.Fatal("the provider-empty stop answer must be recognized by IsNoProgressStop (titling/resume filters)")
}
// Wire-level diagnostics must land in the message (and therefore the
// session log) so this failure class is debuggable without a traffic capture.
if !strings.Contains(result.FinalAnswer, "output_tokens=0") || !strings.Contains(result.FinalAnswer, "finish_reason=") {
t.Fatalf("stop answer must carry last-attempt diagnostics, got %q", result.FinalAnswer)
}
}
// A transient empty response recovers on the in-turn retry: no strike, no stop,
// the retried turn's real output is the answer.
func TestRunRecoversWhenEmptyStreamRetrySucceeds(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(), // initial attempt: backend hiccup
textTurn("recovered"), // in-turn retry gets the real answer
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests (initial + one retry), got %d", len(provider.requests))
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected the retried turn's text as final answer, got %q", result.FinalAnswer)
}
if result.Turns != 1 {
t.Fatalf("an in-turn retry is the SAME turn, want 1 turn, got %d", result.Turns)
}
}
// Mixed strikes (provider-empty + behavioral reasoning-only) must NOT claim a
// provider fault — the generic no-output message stays.
func TestRunMixedEmptyTurnsKeepGenericStopMessage(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(), // strike 1: behavioral
emptyTurn(), emptyTurn(), emptyTurn(), // strike 2: provider-empty ×(1+2 retries)
reasoningOnlyTurn(), // strike 3: behavioral
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.FinalAnswer, noOutputStopMarker) {
t.Fatalf("mixed strikes must use the generic no-output message, got %q", result.FinalAnswer)
}
}
+13 -52
View File
@@ -25,20 +25,7 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
// maxStreamStallRetries: 3 in-turn re-issues (4 attempts total) approaches
// Codex's stream_max_retries=5 posture while staying hard-capped (opencode's
// unbounded session retry has produced infinite loops on this same failure).
// Affordable now that a mid-stream death is detected at the gap timeout
// (~2m) instead of the full 5m idle window.
const maxStreamStallRetries = 3
// maxEmptyStreamRetries caps in-turn re-issues of a request whose stream
// completed cleanly but carried nothing at all (providerEmptyStream). Observed
// live on the ollama cloud relay under load: HTTP 200 in ~1s with an SSE that
// goes straight to [DONE]. The condition is transient backend state, so a
// short backoff-retry usually recovers; without it three such responses
// arrived inside two seconds and killed the run via the no-output guard.
const maxEmptyStreamRetries = 2
const maxStreamStallRetries = 2
const (
toolResultMetaControl = "control"
@@ -337,35 +324,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
// Provider-empty retry: the stream completed CLEANLY but carried nothing
// — no text, no tool calls, no reasoning. That is not model behavior (a
// generating model always leaves some trace); it is a backend answering
// with a contentless completion (seen on the ollama cloud relay under
// load: 200 + instant [DONE]). Transient, so re-issue with backoff before
// letting it count as a no-output strike. Nothing from the empty stream
// was forwarded or committed to history, so the retry is clean.
for attempt := 1; attempt <= maxEmptyStreamRetries &&
collected.Error == "" && !forwardedVisibleText &&
providerEmptyStream(collected); attempt++ {
if notify := emptyRetryNoticeFor(options); notify != nil {
notify(attempt, maxEmptyStreamRetries)
}
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
result.Messages = copyMessages(messages)
return result, err
}
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
return result, retryErr
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
if collected.Error != "" {
// Route a reissued stream's non-stall error through the SAME recovery as
// the initial stream (image-rejection wrapping / context-limit compaction)
@@ -412,15 +370,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// A truly-empty turn (no text, no tool calls, no dropped calls) is
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
// Every strike being a provider-empty stream means the BACKEND
// failed repeatedly (each attempt already retried with backoff
// above) — say so, instead of the generic no-progress message
// that reads as the agent giving up.
if guards.allEmptyTurnsProviderEmpty() {
result.FinalAnswer = providerEmptyStopAnswer(result.Turns, emptyStreamDetail(collected))
result.Messages = copyMessages(messages)
return result, nil
}
result.FinalAnswer = noOutputStopAnswer(result.Turns)
result.Messages = copyMessages(messages)
return result, nil
@@ -2210,6 +2159,18 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}
}
// A command that explicitly requests additional sandbox permissions
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
// must consent to, even when the base command's sandbox decision was Allow.
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
// so the event MUST be a prompt — otherwise it carried Action=allow while the
// loop waited on a decision, and a UI that renders only prompts (the TUI)
// dropped it, deadlocking the run. Keep this consistent with
// shouldRequestPermission's own condition.
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
action = PermissionActionPrompt
}
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
return PermissionEvent{}, false
}
@@ -0,0 +1,70 @@
package agent
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
// standing in for bash so buildPermissionEvent can be exercised directly.
type promptShellTool struct{}
func (promptShellTool) Name() string { return "bash" }
func (promptShellTool) Description() string { return "run a shell command" }
func (promptShellTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (promptShellTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
}
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// A command whose sandbox decision is Allow but that explicitly requests
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
// prompt, never Action=allow. Previously it carried allow while the loop still
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
// folder-creation shape captured in the reported hang.
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "mkdir foo && cd foo && go mod init foo",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
}
// Base command allowed by the sandbox…
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
}
if event.Action != PermissionActionPrompt {
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
}
// Sanity: shouldRequestPermission agrees the loop blocks on this.
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
}
}
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
// prompt (shouldRequestPermission is false; it just runs) — the override must
// not over-reach and prompt for ordinary allowed commands.
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "ls"}
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if event.Action == PermissionActionPrompt {
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
}
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
-16
View File
@@ -55,22 +55,6 @@ func stallRetryNoticeFor(options Options) reconnectNotifier {
}
}
// emptyRetryNoticeFor builds a notifier for the loop's provider-empty retry
// (a stream that completed cleanly but carried NOTHING — no text, no tool
// calls, no reasoning). Distinct wording from the stall notice because nothing
// timed out: the backend answered instantly with an empty completion (observed
// on the ollama cloud relay under load, and possible on any gateway having a
// bad moment). Surfaced through OnReasoning, the non-content channel. Nil when
// there is no reasoning sink (the retry still happens silently).
func emptyRetryNoticeFor(options Options) reconnectNotifier {
if options.OnReasoning == nil {
return nil
}
return func(attempt, max int) {
options.OnReasoning(fmt.Sprintf("\n[provider returned an empty response — retrying %d/%d…]\n", attempt, max))
}
}
// streamWithReconnect issues request via provider.StreamCompletion and, on a
// transient disconnect error, retries the connect up to maxStreamReconnects
// times with exponential backoff. It returns the live stream on success, or the
@@ -18,6 +18,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}{
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
@@ -46,6 +47,21 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}
}
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "longcat")
for _, want := range []string{
"requiresAuth=true",
"authEnvVars=LONGCAT_API_KEY",
"setup: zero providers setup longcat --set-active",
} {
if !strings.Contains(block, want) {
t.Fatalf("expected LongCat catalog block to contain %q, got:\n%s", want, block)
}
}
}
func TestProviderCatalogOllamaCloudRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "ollama-cloud")
+124
View File
@@ -1059,6 +1059,130 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
}
}
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "mini-cn",
"providers": [{
"name": "mini-cn",
"catalog_id": "minimaxi-cn"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"MINIMAXI_API_KEY": "sk-cn-mini",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "minimaxi-cn" {
t.Fatalf("CatalogID = %q, want minimaxi-cn", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindAnthropicCompat)
}
if resolved.Provider.BaseURL != "https://api.minimaxi.com/anthropic" {
t.Fatalf("BaseURL = %q, want MiniMax CN default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "MiniMax-M3" {
t.Fatalf("Model = %q, want MiniMax-M3", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "MINIMAXI_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want MINIMAXI_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-cn-mini" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveAppliesZaiCNCatalogDefaults(t *testing.T) {
// The "zai-cn" catalog entry preserves the legacy open.bigmodel.cn endpoint
// for users who still target the China hosting; "zai" itself now points at
// api.z.ai (international). The two env-vars are intentionally distinct
// (ZHIPU_API_KEY for China, ZAI_API_KEY for international) so an account on
// one side cannot be silently used against the other.
path := writeConfig(t, `{
"activeProvider": "zai-cn",
"providers": [{
"name": "zai-cn",
"catalog_id": "zai-cn"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZHIPU_API_KEY": "sk-zhipu-cn",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "zai-cn" {
t.Fatalf("CatalogID = %q, want zai-cn", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
}
if resolved.Provider.BaseURL != "https://open.bigmodel.cn/api/paas/v4" {
t.Fatalf("BaseURL = %q, want Z.ai China default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "glm-4.5" {
t.Fatalf("Model = %q, want glm-4.5 (China default)", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "ZHIPU_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want ZHIPU_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-zhipu-cn" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveAppliesZaiInternationalCatalogDefaults(t *testing.T) {
// "zai" now resolves to the international endpoint; the catalog default
// model stays glm-4.5 (live discovery upgrades to glm-5.2 at runtime).
path := writeConfig(t, `{
"activeProvider": "zai-intl",
"providers": [{
"name": "zai-intl",
"catalog_id": "zai"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZAI_API_KEY": "sk-zai-intl",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "zai" {
t.Fatalf("CatalogID = %q, want zai", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
}
if resolved.Provider.BaseURL != "https://api.z.ai/api/paas/v4" {
t.Fatalf("BaseURL = %q, want Z.ai international default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "glm-4.5" {
t.Fatalf("Model = %q, want glm-4.5 (preserved default; live discovery upgrades to glm-5.2 at runtime)", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "ZAI_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want ZAI_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-zai-intl" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveProviderCatalogAliasAndLocalNoAuth(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "local",
+127
View File
@@ -11,6 +11,7 @@ import (
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -211,6 +212,85 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
}
}
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/mcp" {
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
return
}
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
message := readHTTPRPCMessage(t, request)
switch message.Method {
case "initialize":
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
case "notifications/initialized":
response.WriteHeader(http.StatusAccepted)
default:
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
}
}))
defer testServer.Close()
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: testServer.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("redirect target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: redirector.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -340,6 +420,53 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
}
}
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusAccepted)
}))
defer target.Close()
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
response.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
if flusher, ok := response.(http.Flusher); ok {
flusher.Flush()
}
}))
defer stream.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeSSE,
URL: stream.URL + "/sse",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
}
}
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+8 -4
View File
@@ -733,7 +733,11 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
if err != nil {
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
}
return baseURL.ResolveReference(endpointURL).String(), nil
resolvedURL := baseURL.ResolveReference(endpointURL)
if !sameMCPOrigin(baseURL, resolvedURL) {
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
}
return resolvedURL.String(), nil
}
func rpcResponseKey(id any) string {
@@ -873,10 +877,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
// not declare OAuth get the default client unchanged.
// not declare OAuth use the default transport with the same MCP redirect guard.
func oauthHTTPClient(server Server) (*http.Client, error) {
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
return http.DefaultClient, nil
return mcpHTTPClient(server, nil), nil
}
store, err := NewTokenStore(TokenStoreOptions{})
if err != nil {
@@ -888,5 +892,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
httpClient: http.DefaultClient,
now: time.Now,
}
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
}
+136
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -149,6 +150,141 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
}
}
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
source := &fakeTokenSource{}
source.access.Store("access-secret")
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("redirect target received Authorization = %q", got)
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
client := mcpHTTPClient(
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
)
resp, err := client.Get(redirector.URL + "/mcp")
if resp != nil {
_ = resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
for _, tc := range []struct {
name string
left string
right string
want bool
}{
{
name: "https default port",
left: "https://example.test/mcp",
right: "https://EXAMPLE.test:443/other",
want: true,
},
{
name: "http default port",
left: "http://example.test/mcp",
right: "http://example.test:80/other",
want: true,
},
{
name: "different scheme",
left: "https://example.test/mcp",
right: "http://example.test:443/other",
want: false,
},
{
name: "different port",
left: "https://example.test/mcp",
right: "https://example.test:8443/other",
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
left, err := url.Parse(tc.left)
if err != nil {
t.Fatal(err)
}
right, err := url.Parse(tc.right)
if err != nil {
t.Fatal(err)
}
if got := sameMCPOrigin(left, right); got != tc.want {
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
}
})
}
}
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
for _, tc := range []struct {
name string
base string
endpoint string
want string
wantErr string
}{
{
name: "relative endpoint",
base: "https://example.test/sse",
endpoint: "/messages",
want: "https://example.test/messages",
},
{
name: "absolute same origin",
base: "https://example.test/sse",
endpoint: "https://example.test/messages",
want: "https://example.test/messages",
},
{
name: "default port equivalent",
base: "https://example.test/sse",
endpoint: "https://example.test:443/messages",
want: "https://example.test:443/messages",
},
{
name: "cross origin",
base: "https://example.test/sse",
endpoint: "https://evil.test/messages",
wantErr: "differs from configured server origin",
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
}
if got != tc.want {
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestNonOAuthServerIsUnaffected(t *testing.T) {
// Regression: a server without auth must not get a bearer header or any
// OAuth round tripper, and must connect normally.
+74
View File
@@ -0,0 +1,74 @@
package mcp
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
const maxMCPRedirects = 10
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
return &http.Client{
Transport: transport,
CheckRedirect: checkMCPRedirect(server),
}
}
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
return func(request *http.Request, via []*http.Request) error {
if len(via) >= maxMCPRedirects {
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
}
if len(via) == 0 {
return nil
}
if !sameMCPOrigin(via[0].URL, request.URL) {
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
}
return nil
}
}
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
effectiveMCPPort(left) == effectiveMCPPort(right)
}
func effectiveMCPPort(value *url.URL) string {
if value == nil {
return ""
}
if port := value.Port(); port != "" {
return port
}
switch strings.ToLower(value.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func mcpOrigin(value *url.URL) string {
if value == nil {
return "<nil>"
}
host := value.Hostname()
port := effectiveMCPPort(value)
if port != "" {
host = net.JoinHostPort(host, port)
}
if value.Scheme == "" {
return host
}
return strings.ToLower(value.Scheme) + "://" + host
}
+4 -1
View File
@@ -123,8 +123,10 @@ var descriptors = []Descriptor{
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
anthropicCompat("minimaxi-cn", "MiniMax CN", "https://api.minimaxi.com/anthropic", "MiniMax-M3", []string{"MINIMAXI_API_KEY"}, "minimax cn", "minimax-cn"),
openAICompat("mistral", "Mistral", "https://api.mistral.ai/v1", "mistral-large-latest", []string{"MISTRAL_API_KEY"}),
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
@@ -133,7 +135,8 @@ var descriptors = []Descriptor{
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
openAICompat("zai", "Z.ai", "https://api.z.ai/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY"}, "z.ai"),
openAICompat("zai-cn", "Z.ai CN", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZHIPU_API_KEY"}, "z.ai cn"),
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
+27 -2
View File
@@ -23,8 +23,10 @@ var expectedCatalogIDs = []string{
"together",
"dashscope",
"moonshot",
"longcat",
"nvidia-nim",
"minimax",
"minimaxi-cn",
"mistral",
"github",
"bedrock",
@@ -34,6 +36,7 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"zai-cn",
"kilocode",
"opencode",
"opencode-go",
@@ -99,6 +102,28 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
}
}
func TestLongCatDescriptor(t *testing.T) {
descriptor, err := Require("longcat")
if err != nil {
t.Fatalf("Require(longcat) error = %v", err)
}
if descriptor.Name != "LongCat" {
t.Fatalf("Name = %q, want LongCat", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.longcat.chat/openai" {
t.Fatalf("DefaultBaseURL = %q, want LongCat OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "LongCat-2.0" {
t.Fatalf("DefaultModel = %q, want LongCat-2.0", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"LONGCAT_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want LONGCAT_API_KEY", descriptor.AuthEnvVars)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -266,8 +291,8 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportGoogle: {"google"},
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
+20 -10
View File
@@ -21,6 +21,22 @@ type Model struct {
Source string
}
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
// per call, so the shared backing slice cannot be mutated by callers.
var minimaxCuratedModels = []Model{
{ID: "MiniMax-M3", Description: "catalog default"},
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
}
// Shared by both "zai" (international, api.z.ai) and "zai-cn" (China,
// open.bigmodel.cn); same model lineup on both endpoints.
var zaiCuratedModels = []Model{
{ID: "glm-4.5", Description: "catalog default"},
{ID: "glm-4.5-air", Description: "fast model"},
{ID: "glm-4.6", Description: "latest general model"},
{ID: "glm-z1-air", Description: "reasoning model"},
}
var curatedModels = map[string][]Model{
"ollama-cloud": {
{ID: "qwen3-coder:480b", Description: "catalog default"},
@@ -88,10 +104,8 @@ var curatedModels = map[string][]Model{
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
{ID: "mistralai/mixtral-8x7b-instruct-v0.1", Description: "mixture model"},
},
"minimax": {
{ID: "MiniMax-M3", Description: "catalog default"},
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
},
"minimax": minimaxCuratedModels,
"minimaxi-cn": minimaxCuratedModels,
"mistral": {
{ID: "mistral-large-latest", Description: "catalog default"},
{ID: "codestral-latest", Description: "coding model"},
@@ -123,12 +137,8 @@ var curatedModels = map[string][]Model{
"bankr": {
{ID: "bankr-large", Description: "catalog default"},
},
"zai": {
{ID: "glm-4.5", Description: "catalog default"},
{ID: "glm-4.5-air", Description: "fast model"},
{ID: "glm-4.6", Description: "latest general model"},
{ID: "glm-z1-air", Description: "reasoning model"},
},
"zai": zaiCuratedModels,
"zai-cn": zaiCuratedModels,
// OpenGateway smart-routes by model id across its upstream providers
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
// the curated coding defaults; the gateway accepts any model its upstreams
@@ -37,6 +37,16 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"mistral-large-latest", "codestral-latest"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "minimaxi-cn",
want: []string{"MiniMax-M3", "MiniMax-M2.1"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "zai-cn",
want: []string{"glm-4.5", "glm-4.6", "glm-4.5-air", "glm-z1-air"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
}
for _, tt := range tests {
@@ -88,3 +98,66 @@ func modelIDs(models []Model) []string {
}
return ids
}
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
// international provider; a divergence here is almost always a mistake.
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
intl, ok := providercatalog.Get("minimax")
if !ok {
t.Fatal("provider minimax missing from catalog")
}
cn, ok := providercatalog.Get("minimaxi-cn")
if !ok {
t.Fatal("provider minimaxi-cn missing from catalog")
}
intlModels := Models(intl)
cnModels := Models(cn)
if len(intlModels) != len(cnModels) {
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
}
intlIDs := map[string]string{}
for _, m := range intlModels {
intlIDs[m.ID] = m.Description
}
for _, m := range cnModels {
wantDesc, ok := intlIDs[m.ID]
if !ok {
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
}
if m.Description != wantDesc {
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
}
}
}
// Same contract as TestMiniMaxCNModelsMirrorInternational, for Z.ai: the
// international (api.z.ai) and China (open.bigmodel.cn) endpoints expose the
// same model lineup.
func TestZaiCNModelsMirrorInternational(t *testing.T) {
intl, ok := providercatalog.Get("zai")
if !ok {
t.Fatal("provider zai missing from catalog")
}
cn, ok := providercatalog.Get("zai-cn")
if !ok {
t.Fatal("provider zai-cn missing from catalog")
}
intlModels := Models(intl)
cnModels := Models(cn)
if len(intlModels) != len(cnModels) {
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
}
intlIDs := map[string]string{}
for _, m := range intlModels {
intlIDs[m.ID] = m.Description
}
for _, m := range cnModels {
wantDesc, ok := intlIDs[m.ID]
if !ok {
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
}
if m.Description != wantDesc {
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
}
}
}
+4
View File
@@ -128,6 +128,10 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
return "nvidia"
case "xiaomi-mimo":
return "xiaomi"
case "zai-cn":
return "zai"
case "minimaxi-cn":
return "minimax"
default:
return strings.TrimSpace(provider.ID)
}
@@ -118,6 +118,8 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
"xiaomi-mimo": "xiaomi",
"dashscope": "alibaba",
"ollama-cloud": "ollama-cloud",
"zai-cn": "zai",
"minimaxi-cn": "minimax",
}
for zeroID, want := range tests {
provider, ok := providercatalog.Get(zeroID)
+2 -14
View File
@@ -276,16 +276,6 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t
return true
}
// firstNonEmptyString returns the first argument that is not empty.
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func (provider *Provider) emitChunk(
ctx context.Context,
chunk streamChunk,
@@ -293,12 +283,10 @@ func (provider *Provider) emitChunk(
events chan<- zeroruntime.StreamEvent,
) {
for _, choice := range chunk.Choices {
// Two dialects for the same thing (see streamDelta): prefer
// `reasoning_content`, fall back to `reasoning`. No backend sends both.
if reasoning := firstNonEmptyString(choice.Delta.ReasoningContent, choice.Delta.Reasoning); reasoning != "" {
if choice.Delta.ReasoningContent != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: reasoning,
Content: choice.Delta.ReasoningContent,
})
}
if choice.Delta.Content != "" {
@@ -227,33 +227,6 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
}
}
// Ollama's OpenAI-compat endpoint, OpenRouter, and most gateways stream
// thinking under `reasoning` (not DeepSeek's `reasoning_content`). Dropping it
// made every thinking token on those backends vanish: the model looked frozen
// for minutes and reasoning-only turns were indistinguishable from a dead
// provider. The exact shape below is a live capture from ollama glm-5.2:cloud.
func TestStreamCompletionEmitsOllamaStyleReasoningDeltas(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"The user"},"finish_reason":null}]}`)
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":" wants hello"},"finish_reason":null}]}`)
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
if len(reasoning) != 2 {
t.Fatalf("reasoning events = %#v, want two reasoning deltas (ollama `reasoning` field regression)", reasoning)
}
if reasoning[0].Content != "The user" || reasoning[1].Content != " wants hello" {
t.Fatalf("unexpected reasoning events: %#v", reasoning)
}
text := eventsOfType(events, zeroruntime.StreamEventText)
if len(text) != 1 || text[0].Content != "hello" {
t.Fatalf("content after thinking must still stream as text, got %#v", text)
}
}
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
+1 -9
View File
@@ -76,16 +76,8 @@ type streamChoice struct {
}
type streamDelta struct {
Content string `json:"content"`
// Reasoning/thinking deltas arrive under DIFFERENT keys depending on the
// backend dialect: DeepSeek-style servers emit `reasoning_content`, while
// ollama's OpenAI-compat endpoint, OpenRouter, and most gateways emit
// `reasoning`. Parse both — dropping `reasoning` made every thinking token
// on those backends silently vanish (the model looked frozen for minutes,
// its thinking was never replayed into the next turn's context, and a
// reasoning-only turn was indistinguishable from a dead provider).
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []streamToolCallDelta `json:"tool_calls"`
}
+1 -55
View File
@@ -82,53 +82,6 @@ func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
return idleTimeout + extra
}
// DefaultStreamGapTimeout bounds the byte-silent gap AFTER a stream has
// started delivering payloads. Once ANY payload (data or keep-alive) has
// arrived, the backend has proven it heartbeats/streams — so a subsequent gap
// with zero bytes is almost always a dead connection (live capture of the
// reported "stuck for 33 minutes": ~119KB streamed, then the socket went
// byte-frozen forever). Detecting that at 2 minutes instead of the 5-minute
// idle default turns a ~15-minute blind wait (3 × 5m attempts) into a ~6-minute
// bounded recovery, without touching the pre-first-byte window that slow cloud
// proxies legitimately need (they may withhold output until the upstream model
// produces its first token). Override with ZERO_STREAM_GAP_TIMEOUT. For
// comparison: Codex ships the same 5m idle with 5 stream retries; opencode
// suffers this exact freeze (idle HTTPS socket in "working" state) with no
// mid-stream gap detection at all.
const DefaultStreamGapTimeout = 2 * time.Minute
// streamGapTimeoutEnv overrides the post-first-payload gap timeout. Accepts the
// same forms as ZERO_STREAM_IDLE_TIMEOUT; "0"/"off" disables the tightened gap
// (the plain idle timeout then applies for the whole stream).
const streamGapTimeoutEnv = "ZERO_STREAM_GAP_TIMEOUT"
// ResolveStreamGapTimeout selects the effective post-first-payload gap timeout
// for a stream whose idle timeout resolved to idleTimeout. It is clamped to
// never EXCEED the idle timeout (a gap larger than idle would be dead config),
// and disabled entirely when the idle watchdog is disabled.
func ResolveStreamGapTimeout(idleTimeout time.Duration) time.Duration {
if idleTimeout <= 0 {
return 0
}
gap := DefaultStreamGapTimeout
if raw := strings.TrimSpace(os.Getenv(streamGapTimeoutEnv)); raw != "" {
switch strings.ToLower(raw) {
case "0", "off", "none", "disabled":
return idleTimeout // no tightened gap: idle applies throughout
default:
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
gap = d
} else if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
gap = time.Duration(secs) * time.Second
}
}
}
if gap > idleTimeout {
return idleTimeout
}
return gap
}
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
// ("300"). A value of "0", "off", "none", or "disabled" turns the watchdog off
@@ -364,14 +317,7 @@ func ScanSSEDataWithContext(
idle := time.NewTimer(idleTimeout)
defer idle.Stop()
idleC = idle.C
// The timer was armed above with the FULL idle window, which governs
// until the first payload (slow cloud proxies may withhold output until
// the upstream model produces its first token). resetIdle only runs when
// a payload HAS arrived — the backend has proven it streams/heartbeats —
// so every re-arm uses the tighter gap window: a byte-frozen socket
// mid-stream is a dead connection, and waiting the full idle for it just
// multiplies the user's blind wait per retry.
resetIdle = reset(idle, ResolveStreamGapTimeout(idleTimeout))
resetIdle = reset(idle, idleTimeout)
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
@@ -371,86 +371,3 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
t.Fatal("an explicit client must be returned unchanged")
}
}
// Once a stream has delivered its first payload, a byte-silent gap is bounded
// by the TIGHTER gap timeout, not the full idle window — a mid-stream frozen
// socket (live-captured "stuck" shape: bytes flowed, then nothing forever)
// must be detected fast so retries aren't multiplied by five-minute waits.
func TestScanSSETightensGapAfterFirstPayload(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "80ms")
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
go func() {
_, _ = io.WriteString(pw, "data: first\n\n")
// then: byte-frozen forever
}()
start := time.Now()
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 2*time.Second, func(string) bool { return true })
}()
select {
case err := <-done:
if !errors.Is(err, ErrStreamIdle) {
t.Fatalf("err = %v, want ErrStreamIdle", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("gap abort took %s — the tightened window (80ms) did not apply after the first payload", elapsed)
}
case <-time.After(5 * time.Second):
t.Fatal("stream not aborted")
}
}
// BEFORE the first payload the FULL idle window applies: slow cloud proxies may
// withhold output until the upstream model emits its first token, so the gap
// timeout must NOT govern the wait for the first byte.
func TestScanSSEKeepsFullIdleBeforeFirstPayload(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "30ms")
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
// never write anything
start := time.Now()
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 300*time.Millisecond, func(string) bool { return true })
}()
select {
case err := <-done:
if !errors.Is(err, ErrStreamIdle) {
t.Fatalf("err = %v, want ErrStreamIdle", err)
}
if elapsed := time.Since(start); elapsed < 250*time.Millisecond {
t.Fatalf("aborted after %s — the 30ms gap timeout must not apply before the first payload", elapsed)
}
case <-time.After(5 * time.Second):
t.Fatal("stream not aborted")
}
}
func TestResolveStreamGapTimeout(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != DefaultStreamGapTimeout {
t.Fatalf("default gap = %s, want %s", got, DefaultStreamGapTimeout)
}
// Clamped: the gap never exceeds the idle window.
if got := ResolveStreamGapTimeout(time.Minute); got != time.Minute {
t.Fatalf("gap = %s, want clamp to the 1m idle", got)
}
// Disabled idle disables the gap too.
if got := ResolveStreamGapTimeout(0); got != 0 {
t.Fatalf("gap with idle off = %s, want 0", got)
}
// Env override wins; "off" falls back to the idle window for the whole stream.
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "45s")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != 45*time.Second {
t.Fatalf("env gap = %s, want 45s", got)
}
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "off")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != 5*time.Minute {
t.Fatalf("off gap = %s, want the idle window", got)
}
}
+28 -8
View File
@@ -19,6 +19,13 @@ var errWindowsSandboxNotInitialized = errors.New(
const windowsSetupDowngradeReason = "Windows OS sandbox inactive — commands run with workspace path-confinement and " +
"per-command approval only. Run `zero sandbox setup` from an elevated (Administrator) terminal for full filesystem and network isolation."
// windowsUnelevatedDowngradeReason is surfaced when Windows runs the sandbox in
// the unelevated tier: the restricted-token write-jail is enforced (the command
// runner applies the workspace ACLs itself, which needs no Administrator
// rights), but the WFP network filters are Administrator-only and absent.
const windowsUnelevatedDowngradeReason = "Windows sandbox running unelevated — the filesystem write-jail is enforced, but network " +
"isolation still relies on per-command approval. Run `zero sandbox setup` from an elevated (Administrator) terminal for OS-level network enforcement."
// windowsSandboxInitialized reports whether the per-host Windows sandbox setup
// marker exists. A missing marker is the common fresh-install state, so the
// caller degrades rather than bricking the command. Indirected through a var so
@@ -174,19 +181,28 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
// Windows: the OS sandbox needs a one-time elevated `zero sandbox setup` (it
// applies WFP network filters + workspace ACLs and writes a marker). Without
// it the command runner hard-fails — so on the default preference, DEGRADE to
// the in-process policy gate + per-command approval (matching Linux/macOS when
// their sandbox is unavailable) instead of bricking every command. A strict
// caller (--sandbox require) still gets a clear error pointing at setup.
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
// (it applies WFP network filters + workspace ACLs and writes a marker).
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
// tier: the command runner applies the workspace ACL plan itself (DACL edits
// on user-owned roots need no Administrator rights) and wraps the command in
// the write-restricted token, so the filesystem write-jail holds. Only the
// WFP network filters are Administrator-only, so network stays with the
// in-process approval gate at that tier. A profile that needs the sandbox
// solely for network isolation gains nothing from the unelevated tier and
// DEGRADES to the policy gate as before. A strict caller (--sandbox require)
// still gets a clear error pointing at setup.
windowsNeedsSetup := false
if manager.goos == "windows" && requiresPlatformSandbox && enforcementLevel == EnforcementNative && !windowsSandboxInitialized() {
if preference == SandboxPreferenceRequire {
return SandboxExecutionRequest{}, errWindowsSandboxNotInitialized
}
enforcementLevel = EnforcementDegraded
windowsNeedsSetup = true
if profile.FileSystem.Kind == FileSystemRestricted {
enforcementLevel = EnforcementUnelevated
} else {
enforcementLevel = EnforcementDegraded
}
}
targetBackend := manager.targetBackend(preference, policy, requiresPlatformSandbox)
downgradeReason := ""
@@ -196,7 +212,11 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
downgradeReason = windowsSetupDowngradeReason
}
}
wrapped := backend.CommandWrapping && backend.Available && enforcementLevel == EnforcementNative
if enforcementLevel == EnforcementUnelevated {
downgradeReason = windowsUnelevatedDowngradeReason
}
wrapped := backend.CommandWrapping && backend.Available &&
(enforcementLevel == EnforcementNative || enforcementLevel == EnforcementUnelevated)
markers := backend.SandboxEnvMarkers(policy)
if !wrapped && backend.Name != BackendWSL {
markers = nil
@@ -132,6 +132,64 @@ exit 0
}
}
// TestWindowsUnelevatedRealSandboxSmoke exercises the unelevated tier end to
// end WITHOUT running the elevated setup helper: the command runner applies
// the workspace ACLs itself, a write inside the workspace succeeds under the
// restricted token, and a write outside every granted root is denied. Unlike
// the elevated smoke above it needs no Administrator terminal.
func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
}
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
root := t.TempDir()
outside := t.TempDir()
sandboxHome := filepath.Join(root, ".zero-sandbox")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
config := WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: profile,
SandboxLevel: WindowsSandboxLevelUnelevated,
}
// cmd.exe rather than powershell.exe: managed PowerShell cannot initialize
// its crypto provider under a write-restricted token on some hosts
// (0x8009001d, the same restricted-token limitation the runner documents for
// Schannel), and the write-jail assertion only needs a native shell.
insideMarker := filepath.Join(root, "unelevated-write-ok.txt")
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", "echo ok>" + insideMarker,
}, 0)
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "ok" {
t.Fatalf("unelevated sandboxed write marker = %q, %v; want ok", bytes, err)
}
if _, err := os.Stat(WindowsUnelevatedSetupMarkerPath(sandboxHome)); err != nil {
t.Fatalf("expected the unelevated setup marker to be recorded: %v", err)
}
outsideMarker := filepath.Join(outside, "unelevated-write-denied.txt")
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", "echo leaked>" + outsideMarker,
}, 1)
if _, err := os.Stat(outsideMarker); err == nil {
t.Fatalf("unelevated sandbox allowed a write outside every granted root")
} else if !os.IsNotExist(err) {
t.Fatalf("stat outside marker: %v", err)
}
}
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
t.Helper()
if path := os.Getenv(envKey); path != "" {
+10 -3
View File
@@ -143,9 +143,16 @@ const (
)
const (
EnforcementNative EnforcementLevel = "native"
EnforcementDegraded EnforcementLevel = "degraded"
EnforcementDisabled EnforcementLevel = "disabled"
EnforcementNative EnforcementLevel = "native"
// EnforcementUnelevated is the Windows-only middle tier used when the
// elevated `zero sandbox setup` has not run: commands still wrap through the
// command runner with a write-restricted token and workspace ACLs (which the
// runner can apply without Administrator rights), but the WFP network
// filters are absent, so network isolation stays with the in-process
// approval gate.
EnforcementUnelevated EnforcementLevel = "unelevated"
EnforcementDegraded EnforcementLevel = "degraded"
EnforcementDisabled EnforcementLevel = "disabled"
)
type Policy struct {
@@ -8,14 +8,21 @@ import (
)
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
if config.SandboxLevel != WindowsSandboxLevelRestrictedToken {
switch config.SandboxLevel {
case WindowsSandboxLevelRestrictedToken:
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
case WindowsSandboxLevelUnelevated:
if err := ensureWindowsUnelevatedSetup(config); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
default:
fmt.Fprintf(stderr, "%s: unsupported Windows sandbox level %q\n", WindowsSandboxCommandRunnerName, config.SandboxLevel)
return 1
}
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
if err := ValidateWindowsNetworkPolicy(config.PermissionProfile.Network); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
@@ -57,3 +64,32 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
}
return exitCode
}
// ensureWindowsUnelevatedSetup applies the workspace ACL plan from the current
// (non-elevated) process so the write-restricted token has somewhere its
// capability SIDs are granted. DACL edits on user-owned workspace and temp
// roots need no Administrator rights; the WFP network filters DO, so this tier
// provisions no network enforcement — the offline-marker SID composed into the
// token stays inert until an elevated `zero sandbox setup` installs the block
// filters. Applied plans are recorded by hash so repeat commands skip the
// re-apply; like the elevated setup, grants are left in place (the rollback is
// deliberately discarded) because they only name synthetic capability SIDs
// that no other token carries.
func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
applied, plan, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
return err
}
marker, err := loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
return err
}
if marker.contains(applied) {
return nil
}
if _, err := applyWindowsACLPlan(plan); err != nil {
return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+
"run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err)
}
return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied)
}
+66 -24
View File
@@ -2,14 +2,16 @@ package sandbox
import (
"errors"
"strings"
"testing"
)
// On Windows, a not-yet-set-up sandbox must DEGRADE (run unwrapped with a
// downgrade reason) on the default preference — never brick the command — while
// a strict --sandbox require still errors with a setup hint. Once the marker
// exists, enforcement is native and the command wraps.
func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
// On Windows, a not-yet-set-up sandbox must fall back to the UNELEVATED tier
// (wrapped through the runner with the write-restricted token, network left to
// the approval gate) on the default preference — never brick the command —
// while a strict --sandbox require still errors with a setup hint. Once the
// marker exists, enforcement is native and the command wraps.
func TestWindowsFallsBackUnelevatedWhenSandboxNotInitialized(t *testing.T) {
restore := windowsSandboxInitialized
t.Cleanup(func() { windowsSandboxInitialized = restore })
@@ -24,13 +26,70 @@ func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
ValidateExecution: true,
}
// Marker missing + auto -> degrade (unwrapped, with a reason), no error.
// Marker missing + auto -> unelevated tier: still wrapped, with a reason
// documenting the missing network enforcement, no error.
windowsSandboxInitialized = func() bool { return false }
auto := base
auto.Preference = SandboxPreferenceAuto
req, err := mgr.BuildExecutionRequest(auto)
if err != nil {
t.Fatalf("auto must degrade, not error: %v", err)
t.Fatalf("auto must fall back, not error: %v", err)
}
if req.EnforcementLevel != EnforcementUnelevated {
t.Fatalf("enforcement = %v, want unelevated", req.EnforcementLevel)
}
if !req.CommandWrapped {
t.Fatal("unelevated command must still be wrapped through the sandbox runner")
}
if req.DowngradeReason == "" || !strings.Contains(req.DowngradeReason, "network") {
t.Fatalf("downgrade reason = %q, want an explanation of the missing network enforcement", req.DowngradeReason)
}
// Marker missing + require -> hard error pointing at setup.
require := base
require.Preference = SandboxPreferenceRequire
if _, err := mgr.BuildExecutionRequest(require); !errors.Is(err, errWindowsSandboxNotInitialized) {
t.Fatalf("require must error with the setup hint, got %v", err)
}
// Marker present -> native, wrapped, no downgrade reason.
windowsSandboxInitialized = func() bool { return true }
req, err = mgr.BuildExecutionRequest(auto)
if err != nil {
t.Fatalf("initialized auto: %v", err)
}
if req.EnforcementLevel != EnforcementNative || !req.CommandWrapped {
t.Fatalf("initialized -> native wrapped, got enforcement=%v wrapped=%v", req.EnforcementLevel, req.CommandWrapped)
}
if req.DowngradeReason != "" {
t.Fatalf("native downgrade reason = %q, want empty", req.DowngradeReason)
}
}
// A profile that needs the platform sandbox ONLY for network isolation gains
// nothing from the unelevated tier (which cannot enforce network), so it must
// keep the old degrade-to-policy-gate behavior.
func TestWindowsNetworkOnlyProfileStillDegradesWhenNotInitialized(t *testing.T) {
restore := windowsSandboxInitialized
t.Cleanup(func() { windowsSandboxInitialized = restore })
windowsSandboxInitialized = func() bool { return false }
mgr := NewSandboxManager(SandboxManagerOptions{
GOOS: "windows",
Backend: Backend{Name: BackendWindowsRestrictedToken, Available: true, Executable: "zero.exe", Platform: "windows"},
})
req, err := mgr.BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: `C:\ws`,
Command: CommandSpec{Name: "bash", Args: []string{"-c", "echo hi"}},
Policy: DefaultPolicy(),
Profile: PermissionProfile{
FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted, IncludePlatformRoots: true, AllowTemp: true},
Network: NetworkPolicy{Mode: NetworkDeny},
},
Preference: SandboxPreferenceAuto,
})
if err != nil {
t.Fatalf("network-only profile must degrade, not error: %v", err)
}
if req.EnforcementLevel != EnforcementDegraded {
t.Fatalf("enforcement = %v, want degraded", req.EnforcementLevel)
@@ -41,21 +100,4 @@ func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
if req.DowngradeReason == "" {
t.Fatal("expected a downgrade reason explaining the missing setup")
}
// Marker missing + require -> hard error pointing at setup.
require := base
require.Preference = SandboxPreferenceRequire
if _, err := mgr.BuildExecutionRequest(require); !errors.Is(err, errWindowsSandboxNotInitialized) {
t.Fatalf("require must error with the setup hint, got %v", err)
}
// Marker present -> native, wrapped.
windowsSandboxInitialized = func() bool { return true }
req, err = mgr.BuildExecutionRequest(auto)
if err != nil {
t.Fatalf("initialized auto: %v", err)
}
if req.EnforcementLevel != EnforcementNative || !req.CommandWrapped {
t.Fatalf("initialized -> native wrapped, got enforcement=%v wrapped=%v", req.EnforcementLevel, req.CommandWrapped)
}
}
+17 -4
View File
@@ -95,8 +95,14 @@ type WindowsSandboxLevel string
const (
WindowsSandboxLevelRestrictedToken WindowsSandboxLevel = "restricted-token"
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
// WindowsSandboxLevelUnelevated is the restricted-token tier without the
// elevated setup: the runner applies the workspace ACL plan itself before
// launching (no Administrator rights needed) and skips the elevated setup
// marker check. Network is NOT enforced at this level — the WFP filters need
// the elevated setup — so callers must keep network behind the approval gate.
WindowsSandboxLevelUnelevated WindowsSandboxLevel = "unelevated"
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
)
type WindowsSandboxCommandArgsOptions struct {
@@ -271,13 +277,20 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
return CommandPlan{}, err
}
childEnv := windowsSandboxChildEnv(spec.Env, policy, execRequest.WorkspaceRoot)
// The unelevated enforcement tier maps to the runner's unelevated level: same
// restricted token, but the runner applies the workspace ACLs itself instead
// of requiring the elevated setup marker.
level := WindowsSandboxLevelRestrictedToken
if execRequest.EnforcementLevel == EnforcementUnelevated {
level = WindowsSandboxLevelUnelevated
}
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: spec.Dir,
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
PermissionProfile: execRequest.PermissionProfile,
Env: childEnv,
SandboxLevel: WindowsSandboxLevelRestrictedToken,
SandboxLevel: level,
Command: append([]string{spec.Name}, spec.Args...),
})
if err != nil {
@@ -381,7 +394,7 @@ func nextWindowsSandboxFlagValue(args []string, index int) (string, int, error)
func validWindowsSandboxLevel(level WindowsSandboxLevel) bool {
switch level {
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelUnelevated, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
return true
default:
return false
+144
View File
@@ -0,0 +1,144 @@
package sandbox
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const windowsUnelevatedSetupMarkerSchemaVersion = 1
// windowsUnelevatedSetupMarkerMaxPlans bounds the applied-plan history so the
// marker cannot grow without limit when a user hops between many workspaces.
// Oldest entries are dropped first; re-applying an already-granted plan is
// harmless, so eviction only costs a redundant ACL apply.
const windowsUnelevatedSetupMarkerMaxPlans = 64
// WindowsUnelevatedAppliedPlan fingerprints one workspace ACL plan the
// unelevated tier has applied. Unlike the elevated setup marker it carries no
// network infrastructure hash: the unelevated tier never provisions WFP
// filters, so there is nothing network-side to fingerprint.
type WindowsUnelevatedAppliedPlan struct {
ACLPlanHash string `json:"aclPlanHash"`
ACLPlanEntries int `json:"aclPlanEntries"`
}
// WindowsUnelevatedSetupMarker records which workspace ACL plans the command
// runner has applied without elevation, keyed by plan hash, so repeat commands
// in a known workspace skip the re-apply. One file serves every workspace under
// a sandbox home (the elevated marker is instead a single-plan snapshot,
// because its WFP half genuinely is machine-global).
type WindowsUnelevatedSetupMarker struct {
SchemaVersion int `json:"schemaVersion"`
AppliedPlans []WindowsUnelevatedAppliedPlan `json:"appliedPlans,omitempty"`
}
func WindowsUnelevatedSetupMarkerPath(sandboxHome string) string {
return filepath.Join(filepath.Clean(sandboxHome), "windows-unelevated-setup.json")
}
// buildWindowsUnelevatedAppliedPlan derives the ACL plan for the command's
// permission profile plus its marker fingerprint. The same BuildWindowsACLPlan
// output later feeds the apply step, so the fingerprint and the applied grants
// can never drift apart.
func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (WindowsUnelevatedAppliedPlan, WindowsACLPlan, error) {
plan, err := BuildWindowsACLPlan(config)
if err != nil {
return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err
}
hash, err := WindowsACLPlanHash(plan)
if err != nil {
return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err
}
return WindowsUnelevatedAppliedPlan{
ACLPlanHash: hash,
ACLPlanEntries: len(plan.Entries),
}, plan, nil
}
// loadWindowsUnelevatedSetupMarker reads the marker; a missing file is the
// common first-run state and returns an empty marker, not an error. A marker
// with an unexpected schema version is treated as empty so the runner re-applies
// (and rewrites) rather than failing.
func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetupMarker, error) {
sandboxHome = strings.TrimSpace(sandboxHome)
if sandboxHome == "" {
return WindowsUnelevatedSetupMarker{}, errors.New("windows sandbox home is required")
}
path := WindowsUnelevatedSetupMarkerPath(sandboxHome)
bytes, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
}
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("read windows unelevated setup marker: %w", err)
}
var marker WindowsUnelevatedSetupMarker
if err := json.Unmarshal(bytes, &marker); err != nil {
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("parse windows unelevated setup marker %s: %w", path, err)
}
if marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
}
return marker, nil
}
func (marker WindowsUnelevatedSetupMarker) contains(applied WindowsUnelevatedAppliedPlan) bool {
if strings.TrimSpace(applied.ACLPlanHash) == "" {
return false
}
for _, plan := range marker.AppliedPlans {
if plan.ACLPlanHash == applied.ACLPlanHash && plan.ACLPlanEntries == applied.ACLPlanEntries {
return true
}
}
return false
}
// recordWindowsUnelevatedAppliedPlan appends the plan to the marker (dropping
// the oldest entries past the cap) and writes it atomically, following the
// temp-file-then-rename pattern of the sibling marker writers.
func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnelevatedAppliedPlan) error {
marker, err := loadWindowsUnelevatedSetupMarker(sandboxHome)
if err != nil {
return err
}
if marker.contains(applied) {
return nil
}
marker.SchemaVersion = windowsUnelevatedSetupMarkerSchemaVersion
marker.AppliedPlans = append(marker.AppliedPlans, applied)
if overflow := len(marker.AppliedPlans) - windowsUnelevatedSetupMarkerMaxPlans; overflow > 0 {
marker.AppliedPlans = append([]WindowsUnelevatedAppliedPlan(nil), marker.AppliedPlans[overflow:]...)
}
path := WindowsUnelevatedSetupMarkerPath(sandboxHome)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create windows unelevated setup marker dir: %w", err)
}
bytes, err := json.MarshalIndent(marker, "", " ")
if err != nil {
return fmt.Errorf("marshal windows unelevated setup marker: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-unelevated-setup-*.tmp")
if err != nil {
return fmt.Errorf("create windows unelevated setup marker temp file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(bytes); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("write windows unelevated setup marker temp file: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("close windows unelevated setup marker temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("replace windows unelevated setup marker: %w", err)
}
return nil
}
+157
View File
@@ -0,0 +1,157 @@
package sandbox
import (
"fmt"
"os"
"path/filepath"
"testing"
)
func testWindowsUnelevatedCommandConfig(t *testing.T) WindowsSandboxCommandConfig {
t.Helper()
root := t.TempDir()
return WindowsSandboxCommandConfig{
SandboxHome: filepath.Join(root, ".zero-sandbox"),
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
},
SandboxLevel: WindowsSandboxLevelUnelevated,
Command: []string{"cmd.exe", "/c", "echo hi"},
}
}
func TestWindowsSandboxCommandArgsRoundTripUnelevatedLevel(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
SandboxHome: config.SandboxHome,
CommandCWD: config.CommandCWD,
WorkspaceRoots: config.WorkspaceRoots,
PermissionProfile: config.PermissionProfile,
SandboxLevel: WindowsSandboxLevelUnelevated,
Command: config.Command,
})
if err != nil {
t.Fatalf("BuildWindowsSandboxCommandArgs: %v", err)
}
parsed, err := ParseWindowsSandboxCommandArgs(args)
if err != nil {
t.Fatalf("ParseWindowsSandboxCommandArgs: %v", err)
}
if parsed.SandboxLevel != WindowsSandboxLevelUnelevated {
t.Fatalf("round-tripped sandbox level = %q, want %q", parsed.SandboxLevel, WindowsSandboxLevelUnelevated)
}
}
func TestBuildWindowsUnelevatedAppliedPlanFingerprint(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
applied, plan, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
}
if applied.ACLPlanHash == "" || applied.ACLPlanEntries == 0 || applied.ACLPlanEntries != len(plan.Entries) {
t.Fatalf("applied plan = %+v with %d plan entries, want a non-empty consistent fingerprint", applied, len(plan.Entries))
}
// Same config -> same fingerprint (the marker's dedupe key).
again, _, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if again != applied {
t.Fatalf("fingerprint not stable: %+v then %+v", applied, again)
}
// An unrestricted profile has no ACL plan to apply and must error.
config.PermissionProfile.FileSystem.Kind = FileSystemUnrestricted
if _, _, err := buildWindowsUnelevatedAppliedPlan(config); err == nil {
t.Fatal("expected an error for an unrestricted filesystem profile")
}
}
func TestWindowsUnelevatedSetupMarkerRecordAndLoad(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
applied, _, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
}
// Missing marker file is the fresh-install state: empty, no error, no match.
marker, err := loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("load missing marker: %v", err)
}
if marker.contains(applied) {
t.Fatal("empty marker must not contain the plan")
}
if err := recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied); err != nil {
t.Fatalf("record: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("load recorded marker: %v", err)
}
if !marker.contains(applied) || len(marker.AppliedPlans) != 1 {
t.Fatalf("marker = %+v, want exactly the recorded plan", marker)
}
// Recording the same plan again must not duplicate it.
if err := recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied); err != nil {
t.Fatalf("re-record: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("reload marker: %v", err)
}
if len(marker.AppliedPlans) != 1 {
t.Fatalf("marker has %d plans after duplicate record, want 1", len(marker.AppliedPlans))
}
}
func TestWindowsUnelevatedSetupMarkerEvictsOldestPastCap(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
first := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-0", ACLPlanEntries: 1}
if err := recordWindowsUnelevatedAppliedPlan(home, first); err != nil {
t.Fatalf("record first: %v", err)
}
for index := 1; index <= windowsUnelevatedSetupMarkerMaxPlans; index++ {
plan := WindowsUnelevatedAppliedPlan{ACLPlanHash: fmt.Sprintf("hash-%d", index), ACLPlanEntries: index}
if err := recordWindowsUnelevatedAppliedPlan(home, plan); err != nil {
t.Fatalf("record %d: %v", index, err)
}
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(marker.AppliedPlans) != windowsUnelevatedSetupMarkerMaxPlans {
t.Fatalf("marker has %d plans, want the cap of %d", len(marker.AppliedPlans), windowsUnelevatedSetupMarkerMaxPlans)
}
if marker.contains(first) {
t.Fatal("oldest plan should have been evicted past the cap")
}
}
func TestLoadWindowsUnelevatedSetupMarkerResetsUnknownSchema(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
if err := os.MkdirAll(home, 0o700); err != nil {
t.Fatalf("mkdir sandbox home: %v", err)
}
path := WindowsUnelevatedSetupMarkerPath(home)
if err := os.WriteFile(path, []byte(`{"schemaVersion":99,"appliedPlans":[{"aclPlanHash":"h","aclPlanEntries":1}]}`), 0o600); err != nil {
t.Fatalf("write marker: %v", err)
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
t.Fatalf("unknown schema must reset to empty current-schema marker, got %+v", marker)
}
}
+3 -3
View File
@@ -124,9 +124,9 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
return " " + keyCell + " " + desc
}
// renderKeybindingHelpOverlay renders the framed, centered `?` help overlay for
// the given terminal dimensions.
func (m model) renderKeybindingHelpOverlay(width int, height int) string {
// the given terminal width. Vertical centering is handled by the caller's
// overlay compositing pipeline (overlayViewportLines in transcriptView).
func (m model) renderKeybindingHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
+52
View File
@@ -105,3 +105,55 @@ func TestKeybindingGroupsAreWellFormed(t *testing.T) {
}
}
}
// #419: the `?` help overlay must render ON TOP of the chat (like the model
// picker), not REPLACE the whole screen. The old full-screen replace produced
// only the centered shortcut block on a blank canvas — no title bar, no
// composer. With the overlay open, surrounding chat chrome that is NOT covered
// by the centered box (the model title bar at top, the composer at bottom) must
// still be present alongside "Keyboard Shortcuts".
func TestHelpOverlayCompositesOverChatNotReplacingIt(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
m.height = 40
m.altScreen = true
base := plainRender(t, m.View()) // no overlay: baseline chrome
m.helpOverlay = true
over := plainRender(t, m.View())
if !strings.Contains(over, "Keyboard Shortcuts") {
t.Fatalf("help overlay not rendered:\n%s", over)
}
// Chrome that renders in the baseline (and sits outside the centered overlay
// box) must survive behind the overlay. The full-screen replace showed none.
for _, marker := range []string{"gpt-4o", "describe a task"} {
if !strings.Contains(base, marker) {
t.Fatalf("precondition: baseline chat should contain %q:\n%s", marker, base)
}
if !strings.Contains(over, marker) {
t.Fatalf("#419: help replaced the chat instead of overlaying it; %q is gone:\n%s", marker, over)
}
}
}
// A populated transcript row also survives behind the overlay (peeking out to
// the left of the centered box), proving the chat body — not just the chrome —
// is composited under the overlay rather than discarded.
func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 120
m.height = 40
m.altScreen = true
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello there this is a chat line"})
m.helpOverlay = true
view := plainRender(t, m.View())
if !strings.Contains(view, "Keyboard Shortcuts") {
t.Fatalf("help overlay not rendered:\n%s", view)
}
// The start of the transcript line peeks to the left of the centered box.
if !strings.Contains(view, "hello") {
t.Fatalf("#419: transcript body was replaced by the help overlay:\n%s", view)
}
}
+54 -10
View File
@@ -1669,17 +1669,38 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// when the loop is already running or there is nothing to animate.
return m, m.ensureSpinnerTick()
case permissionRequestMsg:
// The agent goroutine that raised this request is BLOCKED waiting on the
// decision callback, so every branch below must resolve it exactly once —
// or store it in pendingPermission, which resolves it on the user's reply.
// Dropping a request without resolving parks the run forever (the reported
// "stuck for 33 minutes" deadlock): the agent waits on a decision channel
// nothing will ever signal, with no visible prompt and no network activity.
if msg.runID != m.activeRunID {
// A superseded/stale run: unblock its parked goroutine now rather than
// relying on that run's context cancel to fire first.
if msg.decide != nil {
msg.decide(agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run superseded"})
}
return m, nil
}
if msg.request.Action != agent.PermissionActionPrompt {
// Not a user-facing prompt (e.g. a sandbox-allowed command that still
// blocked because it requested additional permissions). The UI has
// nothing to ask, so resolve immediately and FAIL CLOSED — never
// silently grant access that was never surfaced to the user. (The agent
// now marks such elevation requests as prompts, so in practice this is a
// defensive backstop; matches the ACP handler's fail-closed contract.)
if msg.decide != nil {
msg.decide(autoResolvedPermissionDecision(msg.request.Action))
}
return m, nil
}
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
promptRow.runID = msg.runID
m.transcript = appendTranscriptRow(m.transcript, promptRow)
if msg.request.Action == agent.PermissionActionPrompt {
m.pendingPermission = &pendingPermissionPrompt{
request: msg.request,
decide: msg.decide,
}
m.pendingPermission = &pendingPermissionPrompt{
request: msg.request,
decide: msg.decide,
}
return m, nil
case askUserRequestMsg:
@@ -2097,12 +2118,13 @@ func (m model) View() tea.View {
var content string
if m.setup.visible {
content = m.setupView(chatWidth(m.width))
} else if m.helpOverlay {
content = m.renderKeybindingHelpOverlay(chatWidth(m.width), m.height)
} else if m.transcriptDetailed {
content = m.detailedTranscriptView()
} else {
} else if m.helpOverlay || !m.transcriptDetailed {
// When helpOverlay is active the help panel is composited into the normal
// transcript view as a true overlay (scrim + vertical centering), matching
// how the suggestion picker / provider wizard / pickers are drawn.
content = m.transcriptView()
} else {
content = m.detailedTranscriptView()
}
view := tea.NewView(content)
@@ -2173,6 +2195,11 @@ func (m model) transcriptView() string {
return body + footer
}
helpOverlayContent := ""
if m.helpOverlay {
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
}
suggestionOverlay := m.suggestionOverlay(width)
providerOverlay := m.providerWizardOverlay(width)
mcpAddOverlay := m.mcpAddWizardOverlay(width)
@@ -2180,6 +2207,8 @@ func (m model) transcriptView() string {
pickerOverlay := m.pickerOverlay(width)
viewportOverlay := ""
switch {
case helpOverlayContent != "":
viewportOverlay = helpOverlayContent
case providerOverlay != "":
viewportOverlay = providerOverlay
case mcpAddOverlay != "":
@@ -4525,6 +4554,21 @@ func (m model) sendPermissionRequest(runID int, request agent.PermissionRequest,
m.runtimeMessageSink(permissionRequestMsg{runID: runID, request: request, decide: decide})
}
// autoResolvedPermissionDecision resolves a permission request the TUI cannot
// turn into a user prompt (Action != prompt). The agent is blocked awaiting a
// decision, so one must ALWAYS be produced. Only an explicit Cancel is honored
// as such; every other non-prompt action — including allow — is DENIED, so the
// UI never silently grants access it did not surface for approval.
func autoResolvedPermissionDecision(action agent.PermissionAction) agent.PermissionDecision {
if action == agent.PermissionActionCancel {
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run cancelled"}
}
return agent.PermissionDecision{
Action: agent.PermissionDecisionDeny,
Reason: "permission request could not be surfaced for approval",
}
}
func (m model) sendAskUserRequest(runID int, request agent.AskUserRequest, answer func([]string)) {
if m.runtimeMessageSink == nil {
return
+57
View File
@@ -0,0 +1,57 @@
package tui
import "testing"
// The model picker must title each row with the model NAME, not the models.dev
// marketing description. Regression: Ollama Cloud models resolved full-sentence
// descriptions from the remote catalog, so rows showed sentences and several
// models sharing one description looked like exact duplicates (issue: /model
// list rendered descriptions as titles).
func TestModelPickerDisplayNamePrefersModelName(t *testing.T) {
sentence := "DeepSeek chat model for instruction following, coding, and analysis"
cases := []struct {
id string
description string
want string
}{
{"deepseek-v3.2", sentence, "Deepseek V3.2"},
{"deepseek-v3.2-thinking", sentence, "Deepseek V3.2 Thinking"},
{"glm-5.2", "GLM flagship model", "GLM 5.2"},
{"qwen3-coder:480b", "Qwen coding agent model for repository tasks", "Qwen3 Coder 480b"},
{"anthropic/claude-sonnet-4.6", "some blurb", "Claude Sonnet 4.6"},
}
for _, tc := range cases {
if got := modelPickerDisplayName(tc.id, tc.description); got != tc.want {
t.Errorf("modelPickerDisplayName(%q, %q) = %q, want %q", tc.id, tc.description, got, tc.want)
}
}
}
// Two distinct model ids that share the same catalog description must render as
// distinct rows (the "duplicate-looking rows" symptom), never as the shared
// sentence.
func TestModelPickerDisplayNameDistinctForSharedDescription(t *testing.T) {
shared := "Mistral coding agent model for repository tasks and software engineering"
a := modelPickerDisplayName("codestral-2", shared)
b := modelPickerDisplayName("devstral-2", shared)
if a == b {
t.Fatalf("shared description collapsed rows to the same title: %q", a)
}
if a == shared || b == shared {
t.Fatalf("row title used the description sentence: a=%q b=%q", a, b)
}
}
// With no id to name the row, a non-generic description is still an acceptable
// fallback; a generic placeholder (or nothing) falls back to "model".
func TestModelPickerDisplayNameFallsBackToDescriptionOnlyWithoutID(t *testing.T) {
if got := modelPickerDisplayName("", "Friendly Name"); got != "Friendly Name" {
t.Fatalf("empty id should fall back to the description, got %q", got)
}
if got := modelPickerDisplayName("", "catalog default"); got != "model" {
t.Fatalf("generic description with no id should yield %q, got %q", "model", got)
}
if got := modelPickerDisplayName("", ""); got != "model" {
t.Fatalf("empty id and description should yield %q, got %q", "model", got)
}
}
+109
View File
@@ -0,0 +1,109 @@
package tui
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
func permissionModel(t *testing.T) model {
t.Helper()
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
m.width = 96
return m
}
// The deadlock: a permission request whose Action is NOT prompt (e.g. a
// sandbox-allowed command that still blocked to request additional permissions)
// must be resolved immediately — the agent goroutine is parked awaiting a
// decision. Before the fix the handler dropped it, and the run hung forever
// (the reported "stuck for 33 minutes"). Fail closed: a non-prompt allow denies.
func TestPermissionRequestNonPromptResolvesFailClosed(t *testing.T) {
m := permissionModel(t)
var got *agent.PermissionDecision
decide := func(d agent.PermissionDecision) { got = &d }
updated, _ := m.Update(permissionRequestMsg{
runID: 7,
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionAllow},
decide: decide,
})
next := updated.(model)
if got == nil {
t.Fatal("DEADLOCK: decide was never called for a non-prompt request — the agent goroutine would park forever")
}
if got.Action != agent.PermissionDecisionDeny {
t.Fatalf("decision = %q, want deny (fail closed — never silently grant an unsurfaced permission)", got.Action)
}
if next.pendingPermission != nil {
t.Fatal("a non-prompt request must not open a pending prompt")
}
}
// A superseded/stale run's request (runID mismatch) must also be resolved
// (cancelled) so its parked goroutine unblocks instead of depending on
// context-cancel ordering.
func TestPermissionRequestStaleRunResolvesCancel(t *testing.T) {
m := permissionModel(t) // activeRunID = 7
var got *agent.PermissionDecision
decide := func(d agent.PermissionDecision) { got = &d }
m.Update(permissionRequestMsg{
runID: 3, // != activeRunID
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt},
decide: decide,
})
if got == nil {
t.Fatal("stale-run request was dropped without resolving — its agent goroutine would park")
}
if got.Action != agent.PermissionDecisionCancel {
t.Fatalf("decision = %q, want cancel for a superseded run", got.Action)
}
}
// An explicit-cancel action is honored as a cancel (not denied).
func TestPermissionRequestCancelActionResolvesCancel(t *testing.T) {
m := permissionModel(t)
var got *agent.PermissionDecision
decide := func(d agent.PermissionDecision) { got = &d }
m.Update(permissionRequestMsg{
runID: 7,
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionCancel},
decide: decide,
})
if got == nil || got.Action != agent.PermissionDecisionCancel {
t.Fatalf("cancel action must resolve as cancel, got %#v", got)
}
}
// A genuine prompt request stores the pending prompt and does NOT resolve yet —
// the decision comes from the user's reply. (Regression guard: the fix must not
// accidentally auto-resolve real prompts.)
func TestPermissionRequestPromptDefersToUser(t *testing.T) {
m := permissionModel(t)
resolved := false
decide := func(agent.PermissionDecision) { resolved = true }
updated, _ := m.Update(permissionRequestMsg{
runID: 7,
request: agent.PermissionRequest{
ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt,
Reason: "runs a shell command", AvailableDecisions: []agent.PermissionDecisionAction{agent.PermissionDecisionAllow},
},
decide: decide,
})
next := updated.(model)
if resolved {
t.Fatal("a real prompt must not be auto-resolved; it awaits the user")
}
if next.pendingPermission == nil {
t.Fatal("a prompt request must open a pending permission prompt")
}
}
+9 -3
View File
@@ -452,11 +452,17 @@ func registryModelPickerMeta(entry modelregistry.ModelEntry) string {
}
func modelPickerDisplayName(id string, description string) string {
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
return description
}
// The row title is the model NAME, not its marketing blurb. Catalog/discovery
// descriptions (from models.dev) are full sentences, and several models share
// the same one — using them as titles showed sentence-long rows that looked like
// exact duplicates and hid the actual id (which only survived in the meta line).
// Prefer the (prettified) id; fall back to a non-generic description only when
// there is no id to name the row.
id = strings.TrimSpace(id)
if id == "" {
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
return description
}
return "model"
}
name := id
+13 -5
View File
@@ -44,11 +44,19 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
// keep/replace/remove before re-entering credentials.
if m.providerWizard.step == providerWizardStepProvider && !m.providerWizard.oauthMode {
if name, ok := m.wizardProviderStoredKey(m.providerWizard.currentProvider()); ok {
m.providerWizard.manageProviderName = name
m.providerWizard.manageKeyCursor = 0
m.providerWizard.err = ""
m.providerWizard.step = providerWizardStepManageKey
return m, nil
// Generic/custom providers (custom-openai-compatible etc.) all share
// the same CatalogID — matching on CatalogID would block creating a
// second instance. Skip ManageKey and fall through to the shared
// advance() path below; the user can overwrite by re-entering the
// same name or create a new one with a different name.
if !providerWizardNeedsEndpoint(m.providerWizard.currentProvider()) {
m.providerWizard.manageProviderName = name
m.providerWizard.manageKeyCursor = 0
m.providerWizard.err = ""
m.providerWizard.step = providerWizardStepManageKey
return m, nil
}
m.providerWizard.manageProviderName = ""
}
}
previous := m.providerWizard.step
+65
View File
@@ -1167,6 +1167,71 @@ func TestProviderWizardManageKeyReplaceAndKeep(t *testing.T) {
}
}
func TestAdvanceProviderWizardCustomSkipsManageKey(t *testing.T) {
tests := []struct {
name string
catalogID string
transport providercatalog.Transport
}{
{
name: "custom-openai-compatible",
catalogID: "custom-openai-compatible",
transport: providercatalog.TransportOpenAICompatible,
},
{
name: "custom-anthropic-compatible",
catalogID: "custom-anthropic-compatible",
transport: providercatalog.TransportAnthropicCompatible,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := model{savedProviders: []config.ProviderProfile{
{Name: "my-gateway", CatalogID: tt.catalogID, APIKeyStored: true},
}}
m.providerWizard = &providerWizardState{
step: providerWizardStepProvider,
providers: []providercatalog.Descriptor{
{ID: tt.catalogID, Name: tt.name, Transport: tt.transport},
},
selectedProvider: 0,
}
next, _ := m.advanceProviderWizard()
if next.providerWizard == nil {
t.Fatal("wizard should not be nil after advancing from provider step")
}
if next.providerWizard.step == providerWizardStepManageKey {
t.Fatal("custom provider should skip ManageKey and route to endpoint, got ManageKey")
}
if next.providerWizard.step != providerWizardStepEndpoint {
t.Fatalf("custom provider should route to endpoint step, got step: %v", next.providerWizard.step)
}
})
}
}
func TestAdvanceProviderWizardNamedShowsManageKey(t *testing.T) {
m := model{savedProviders: []config.ProviderProfile{
{Name: "openai", CatalogID: "openai", APIKeyStored: true},
}}
m.providerWizard = &providerWizardState{
step: providerWizardStepProvider,
providers: []providercatalog.Descriptor{
{ID: "openai", Name: "OpenAI", Transport: providercatalog.TransportOpenAI},
},
selectedProvider: 0,
}
next, _ := m.advanceProviderWizard()
if next.providerWizard == nil {
t.Fatal("wizard should not be nil after advancing from provider step")
}
if next.providerWizard.step != providerWizardStepManageKey {
t.Fatalf("named provider should route to ManageKey step, got step: %v", next.providerWizard.step)
}
}
func readProviderWizardConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
+1 -1
View File
@@ -70,7 +70,7 @@ func (m model) sidebarAvailable() bool {
// list) take over the chat column and render at full width; suppress the
// second column while any is active so their geometry and mouse hit-testing
// stay full-width as before.
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
if m.setup.visible || m.helpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return false
}
-10
View File
@@ -21,12 +21,6 @@ type CollectedStream struct {
// thinking blocks) that must be replayed on the next turn. Empty for providers
// or runs without extended thinking.
ReasoningBlocks []ReasoningBlock
// ReasoningEmitted reports that the stream carried ANY reasoning signal
// (live reasoning deltas or preserved blocks). It distinguishes "the model
// thought but produced no answer" (a behavioral empty turn) from "the
// provider returned a contentless stream" (a transport/backend fault worth
// retrying) — the two need different handling in the agent loop.
ReasoningEmitted bool
}
// Truncated reports whether the response ended for a non-normal reason (the
@@ -126,7 +120,6 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
// accumulate them regardless of type so they survive for replay.
if len(event.ReasoningBlocks) > 0 {
collected.ReasoningBlocks = append(collected.ReasoningBlocks, event.ReasoningBlocks...)
collected.ReasoningEmitted = true
}
switch event.Type {
@@ -139,9 +132,6 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
options.OnText(event.Content)
}
case StreamEventReasoning:
if event.Content != "" {
collected.ReasoningEmitted = true
}
if options.OnReasoning != nil {
options.OnReasoning(event.Content)
}
+2 -1
View File
@@ -24,7 +24,8 @@
"os": [
"linux",
"darwin",
"win32"
"win32",
"android"
],
"cpu": [
"x64",
+2 -1
View File
@@ -14,7 +14,7 @@
// Env overrides (testing / mirrors / locked-down installs):
// ZERO_SKIP_DOWNLOAD=1 skip entirely, exit 0 (wrapper will guide if run)
// ZERO_INSTALL_DRY_RUN=1 print the resolved plan as JSON, no network, exit 0
// ZERO_INSTALL_PLATFORM=… override process.platform (linux|darwin|win32)
// ZERO_INSTALL_PLATFORM=… override process.platform (linux|darwin|win32|android)
// ZERO_INSTALL_ARCH=… override process.arch (x64|arm64)
// ZERO_REPO=owner/name override the GitHub repo (default Gitlawb/zero)
// ZERO_GITHUB_BASE_URL=… override the download host (default https://github.com)
@@ -65,6 +65,7 @@ function warnSkip(message) {
function resolvePlatform(p) {
switch (p) {
case 'linux':
case 'android':
return 'linux';
case 'darwin':
return 'macos';