Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 2799cd0280 fix(tui): keep the profile name on /model switch so the stored key resolves
normalizeProfileForProvider rewrote profile.Name to the catalog provider.ID
whenever normalizeIdentity was true — which includes any first-party provider
whose saved baseURL matches the catalog default. The credential store is keyed
by profile.Name, and this rewrite runs before profileWithCredential, so the
stored-key lookup missed and the rebuilt provider went out with no key: a 401 on
every /model switch for a profile named differently from its catalog id (e.g.
"my-openai").

Only canonicalize an empty or generic placeholder name now; a real user-provided
name is preserved. CatalogID canonicalization is unchanged (it is not the store
key). Adds regression tests covering a custom-named first-party profile surviving
normalization and placeholder names still canonicalizing.

Fixes #440
2026-07-03 15:34:44 +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
29 changed files with 1306 additions and 209 deletions

No files matched your search

+9
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,
@@ -142,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) 发布。
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"runtime"
"strconv"
"strings"
@@ -66,7 +65,7 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
report := doctor.Run(doctor.Options{
Now: deps.now,
Runtime: runtime.Version(),
Runtime: "go",
UserConfig: userConfig,
ProjectConfig: projectConfig,
Provider: provider,
@@ -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",
+6 -47
View File
@@ -57,11 +57,6 @@ type Options struct {
// LSP-server checks. Nil means exec.LookPath; tests inject a stub so the
// checks are deterministic regardless of the host's installed tooling.
LookupExecutable func(string) (string, error)
// Executable resolves the running Zero binary's path (nil => os.Executable)
// and StatExecutable stats it (nil => os.Stat). Injected so the native-binary
// runtime check is testable without depending on a real on-disk layout.
Executable func() (string, error)
StatExecutable func(string) (os.FileInfo, error)
}
func Run(options Options) Report {
@@ -70,7 +65,7 @@ func Run(options Options) Report {
now = time.Now
}
checks := []Check{
runtimeCheck(options),
runtimeCheck(options.Runtime),
configFilesCheck(options.UserConfig, options.ProjectConfig),
configValidationCheck(options.UserConfig, options.ProjectConfig),
}
@@ -119,48 +114,12 @@ func Format(report Report) string {
return strings.Join(lines, "\n")
}
// runtimeCheck verifies the running Zero native binary is actually present and
// runnable, and reports its resolved path — instead of the former hardcoded
// always-pass, which claimed "Go runtime is available" while checking nothing
// (#405). doctor executes INSIDE the native binary, so this only fails if
// os.Executable can't resolve or the running file has vanished; the missing-
// binary-at-install case is caught earlier by the npm wrapper, which refuses to
// launch without the platform binary. Surfacing the binary PATH is the useful
// part: it lets a user see which binary answered (e.g. an npm-installed one vs a
// stale/shadowing copy on PATH) rather than a meaningless green.
func runtimeCheck(options Options) Check {
runtimeVersion := strings.TrimSpace(options.Runtime)
if runtimeVersion == "" {
runtimeVersion = "go"
func runtimeCheck(runtime string) Check {
runtime = strings.TrimSpace(runtime)
if runtime == "" {
runtime = "go"
}
details := map[string]any{"runtime": runtimeVersion}
executable := options.Executable
if executable == nil {
executable = os.Executable
}
path, err := executable()
if err != nil {
return check("runtime.go", "Zero native binary", StatusFail,
"Could not resolve the running Zero binary: "+err.Error(), details)
}
details["binaryPath"] = path
stat := options.StatExecutable
if stat == nil {
stat = os.Stat
}
info, err := stat(path)
if err != nil {
return check("runtime.go", "Zero native binary", StatusFail,
fmt.Sprintf("The running Zero binary is not accessible at %s: %s. Reinstall the zero package, or build from source: https://github.com/Gitlawb/zero", path, err.Error()), details)
}
if info.IsDir() {
return check("runtime.go", "Zero native binary", StatusFail,
fmt.Sprintf("The resolved Zero binary path %s is a directory, not an executable.", path), details)
}
return check("runtime.go", "Zero native binary", StatusPass,
fmt.Sprintf("Zero native binary is present and runnable at %s (%s).", path, runtimeVersion), details)
return check("runtime.go", "Go runtime", StatusPass, fmt.Sprintf("Zero Go runtime is available (%s).", runtime), map[string]any{"runtime": runtime})
}
func configFilesCheck(userPath string, projectPath string) Check {
-88
View File
@@ -1,88 +0,0 @@
package doctor
import (
"errors"
"io/fs"
"os"
"strings"
"testing"
"time"
)
// fakeFileInfo is a minimal os.FileInfo for the runtime-check stat injection.
type fakeFileInfo struct{ dir bool }
func (f fakeFileInfo) Name() string { return "zero" }
func (f fakeFileInfo) Size() int64 { return 1 }
func (f fakeFileInfo) Mode() fs.FileMode { return 0o755 }
func (f fakeFileInfo) ModTime() time.Time { return time.Unix(0, 0) }
func (f fakeFileInfo) IsDir() bool { return f.dir }
func (f fakeFileInfo) Sys() any { return nil }
func runtimeCheckFor(t *testing.T, exe func() (string, error), stat func(string) (os.FileInfo, error)) Check {
t.Helper()
return runtimeCheck(Options{Runtime: "go1.26", Executable: exe, StatExecutable: stat})
}
// A present, runnable binary passes AND reports its resolved path — the check is
// no longer a hardcoded green; it names which binary answered.
func TestRuntimeCheckPassesAndReportsBinaryPath(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero", nil },
func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
if c.Status != StatusPass {
t.Fatalf("status = %s, want pass", c.Status)
}
if c.Details["binaryPath"] != "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero" {
t.Fatalf("binaryPath = %v, want the resolved path in details", c.Details["binaryPath"])
}
if !strings.Contains(c.Message, "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero") {
t.Fatalf("message should surface the binary path, got %q", c.Message)
}
}
// The vanished-binary case FAILS loudly instead of the old unconditional pass —
// this is the #405 regression: a broken runtime no longer gets a green check.
func TestRuntimeCheckFailsWhenBinaryMissing(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/path/to/zero", nil },
func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
if c.Status != StatusFail {
t.Fatalf("#405: a missing/inaccessible binary must FAIL, got %s (%q)", c.Status, c.Message)
}
if !strings.Contains(c.Message, "/path/to/zero") {
t.Fatalf("failure message should name the path, got %q", c.Message)
}
}
// os.Executable resolution failure fails loudly too.
func TestRuntimeCheckFailsWhenExecutableUnresolvable(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "", errors.New("no executable path") },
func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
if c.Status != StatusFail {
t.Fatalf("unresolvable executable must FAIL, got %s", c.Status)
}
}
// A path that resolves to a directory is not a runnable binary → FAIL.
func TestRuntimeCheckFailsWhenPathIsDirectory(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/some/dir", nil },
func(string) (os.FileInfo, error) { return fakeFileInfo{dir: true}, nil })
if c.Status != StatusFail {
t.Fatalf("a directory path must FAIL, got %s", c.Status)
}
}
// Default injection (nil Executable/StatExecutable) resolves the real running
// test binary — proving the production default path works and passes.
func TestRuntimeCheckDefaultsToRealBinary(t *testing.T) {
c := runtimeCheck(Options{})
if c.Status != StatusPass {
t.Fatalf("default runtime check should pass for the running test binary, got %s (%q)", c.Status, c.Message)
}
if strings.TrimSpace(c.Details["binaryPath"].(string)) == "" {
t.Fatal("default runtime check should report the running binary path")
}
}
+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)
+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)
}
}
+13 -5
View File
@@ -2118,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)
@@ -2194,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)
@@ -2201,6 +2207,8 @@ func (m model) transcriptView() string {
pickerOverlay := m.pickerOverlay(width)
viewportOverlay := ""
switch {
case helpOverlayContent != "":
viewportOverlay = helpOverlayContent
case providerOverlay != "":
viewportOverlay = providerOverlay
case mcpAddOverlay != "":
+9 -1
View File
@@ -619,7 +619,15 @@ func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor)
strings.TrimSpace(profile.Name) == "" ||
strings.TrimSpace(profile.CatalogID) == ""
if normalizeIdentity {
profile.Name = provider.ID
// Only canonicalize an empty or generic placeholder name — never clobber a
// real user-provided profile name. The credential store is keyed by
// profile.Name (SecureProviderProfile stores under Name), so rewriting it
// here made the later profileWithCredential lookup miss the saved key and
// rebuild a keyless provider — a 401 on every /model switch for any profile
// named differently from its catalog id (e.g. "my-openai").
if strings.TrimSpace(profile.Name) == "" || genericProviderCatalogID(profile.Name) {
profile.Name = provider.ID
}
profile.CatalogID = provider.ID
}
if strings.TrimSpace(profile.BaseURL) == "" {
+46
View File
@@ -976,3 +976,49 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
t.Fatal("switching to ollama should warm both the generic and ollama-specific discovery commands")
}
}
func TestNormalizeProfileForProviderPreservesCustomName(t *testing.T) {
openai, ok := providercatalog.Get("openai")
if !ok {
t.Fatal("openai descriptor missing from catalog")
}
// A first-party provider set up with a custom profile name and the baseURL at
// the catalog default. normalizeIdentity is true here (baseURL matches), but
// the real name must survive: it is the credential-store key, so clobbering it
// dropped the saved key and 401'd every /model switch (issue #440).
m := model{providerProfile: config.ProviderProfile{
Name: "my-openai",
CatalogID: "openai",
BaseURL: openai.DefaultBaseURL,
APIKeyStored: true,
}}
got := m.normalizeProfileForProvider(openai)
if got.Name != "my-openai" {
t.Fatalf("Name = %q, want it preserved as %q (credential store is keyed by Name)", got.Name, "my-openai")
}
if got.CatalogID != "openai" {
t.Fatalf("CatalogID = %q, want it canonicalized to %q", got.CatalogID, "openai")
}
}
func TestNormalizeProfileForProviderCanonicalizesPlaceholderName(t *testing.T) {
openai, ok := providercatalog.Get("openai")
if !ok {
t.Fatal("openai descriptor missing from catalog")
}
// Empty and generic placeholder names still get canonicalized to the catalog
// id — those carry no meaningful credential-store key to preserve.
for _, name := range []string{"", "custom-openai-compatible"} {
m := model{providerProfile: config.ProviderProfile{
Name: name,
BaseURL: openai.DefaultBaseURL,
CatalogID: name,
}}
got := m.normalizeProfileForProvider(openai)
if got.Name != "openai" {
t.Fatalf("Name = %q for placeholder %q, want canonicalized to %q", got.Name, name, "openai")
}
}
}
+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
}