mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5746424272 |
No files matched your search
@@ -8,8 +8,6 @@
|
||||
<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,
|
||||
@@ -144,20 +142,6 @@ the key in the wizard:
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
```
|
||||
|
||||
To configure AI/ML API directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup aimlapi --set-active
|
||||
```
|
||||
|
||||
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
@@ -1,297 +0,0 @@
|
||||
<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) 发布。
|
||||
@@ -17,9 +17,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
defaultModel string
|
||||
}{
|
||||
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
||||
{id: "aimlapi", name: "AI/ML API", transport: "openai-compatible", defaultModel: "openai/gpt-5-chat"},
|
||||
{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"},
|
||||
}
|
||||
@@ -48,36 +46,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogAIMLAPIRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "aimlapi")
|
||||
|
||||
for _, want := range []string{
|
||||
"requiresAuth=true",
|
||||
"authEnvVars=AIMLAPI_API_KEY",
|
||||
"setup: zero providers setup aimlapi --set-active",
|
||||
} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("expected AIMLAPI catalog block to contain %q, got:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -1059,130 +1059,6 @@ 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",
|
||||
|
||||
@@ -34,8 +34,6 @@ const (
|
||||
|
||||
var ErrUnknownProvider = errors.New("unknown provider")
|
||||
|
||||
const AIMLAPIID = "aimlapi"
|
||||
|
||||
type Descriptor struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -101,7 +99,6 @@ var descriptors = []Descriptor{
|
||||
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
|
||||
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
|
||||
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
|
||||
openAICompat(AIMLAPIID, "AI/ML API", "https://api.aimlapi.com/v1", "openai/gpt-5-chat", []string{"AIMLAPI_API_KEY"}, "aiml api", "ai/ml api"),
|
||||
// Hugging Face Inference Providers — OpenAI-compatible router at
|
||||
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
|
||||
// requires a one-time app registration at huggingface.co/settings/applications/new
|
||||
@@ -126,10 +123,8 @@ 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),
|
||||
@@ -138,8 +133,7 @@ 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://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("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
||||
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"),
|
||||
|
||||
@@ -16,7 +16,6 @@ var expectedCatalogIDs = []string{
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"openrouter",
|
||||
"aimlapi",
|
||||
"huggingface",
|
||||
"chatgpt",
|
||||
"groq",
|
||||
@@ -24,10 +23,8 @@ var expectedCatalogIDs = []string{
|
||||
"together",
|
||||
"dashscope",
|
||||
"moonshot",
|
||||
"longcat",
|
||||
"nvidia-nim",
|
||||
"minimax",
|
||||
"minimaxi-cn",
|
||||
"mistral",
|
||||
"github",
|
||||
"bedrock",
|
||||
@@ -37,7 +34,6 @@ var expectedCatalogIDs = []string{
|
||||
"xiaomi-mimo",
|
||||
"bankr",
|
||||
"zai",
|
||||
"zai-cn",
|
||||
"kilocode",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
@@ -103,50 +99,6 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIDescriptor(t *testing.T) {
|
||||
descriptor, err := Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatalf("Require(aimlapi) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "AI/ML API" {
|
||||
t.Fatalf("Name = %q, want AI/ML API", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.aimlapi.com/v1" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want AI/ML API endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "openai/gpt-5-chat" {
|
||||
t.Fatalf("DefaultModel = %q, want openai/gpt-5-chat", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"AIMLAPI_API_KEY"}) {
|
||||
t.Fatalf("AuthEnvVars = %#v, want AIMLAPI_API_KEY", descriptor.AuthEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
@@ -314,8 +266,8 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportGoogle: {"google"},
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "aimlapi", "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"},
|
||||
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"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -21,22 +21,6 @@ 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"},
|
||||
@@ -104,8 +88,10 @@ 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": minimaxCuratedModels,
|
||||
"minimaxi-cn": minimaxCuratedModels,
|
||||
"minimax": {
|
||||
{ID: "MiniMax-M3", Description: "catalog default"},
|
||||
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
|
||||
},
|
||||
"mistral": {
|
||||
{ID: "mistral-large-latest", Description: "catalog default"},
|
||||
{ID: "codestral-latest", Description: "coding model"},
|
||||
@@ -137,8 +123,12 @@ var curatedModels = map[string][]Model{
|
||||
"bankr": {
|
||||
{ID: "bankr-large", Description: "catalog default"},
|
||||
},
|
||||
"zai": zaiCuratedModels,
|
||||
"zai-cn": zaiCuratedModels,
|
||||
"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"},
|
||||
},
|
||||
// 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,16 +37,6 @@ 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 {
|
||||
@@ -98,66 +88,3 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,10 +128,6 @@ 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,8 +118,6 @@ 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)
|
||||
|
||||
@@ -195,7 +195,7 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
|
||||
AuthHeader: profile.AuthHeader,
|
||||
AuthScheme: profile.AuthScheme,
|
||||
AuthHeaderValue: profile.AuthHeaderValue,
|
||||
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
|
||||
CustomHeaders: profile.CustomHeaders,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,44 +52,6 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAIMLAPIModelsSendsAuthAndCustomHeadersWithoutAttribution(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for header, want := range map[string]string{
|
||||
"Authorization": "Bearer test-key",
|
||||
"X-Trace": "test",
|
||||
} {
|
||||
if got := r.Header.Get(header); got != want {
|
||||
t.Errorf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
// No first-party referral/attribution headers are injected for catalog
|
||||
// presets; aimlapi rides through CopyHeaders like every other provider.
|
||||
for _, header := range []string{
|
||||
"X-AIMLAPI-Partner-ID",
|
||||
"X-AIMLAPI-Integration-Repo",
|
||||
"X-AIMLAPI-Integration-Version",
|
||||
} {
|
||||
if got := r.Header.Get(header); got != "" {
|
||||
t.Errorf("%s = %q, want no attribution header", header, got)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"openai/gpt-5-chat"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := Discover(context.Background(), config.ProviderProfile{
|
||||
CatalogID: "aimlapi",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "test-key",
|
||||
CustomHeaders: map[string]string{"X-Trace": "test"},
|
||||
}, Options{HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("Discover returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverOpenAICompatibleModelsHonorsAuthHeaderValue(t *testing.T) {
|
||||
// A profile can authenticate via a raw auth-header value instead of APIKey;
|
||||
// discovery must send it rather than probe unauthenticated.
|
||||
|
||||
@@ -61,7 +61,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
AuthHeader: profile.AuthHeader,
|
||||
AuthScheme: profile.AuthScheme,
|
||||
AuthHeaderValue: profile.AuthHeaderValue,
|
||||
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
|
||||
CustomHeaders: profile.CustomHeaders,
|
||||
OAuthResolver: options.OAuthResolver,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
|
||||
@@ -95,55 +95,6 @@ func TestNewThreadsCustomProviderHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAIMLAPIProviderSendsEndpointAndAuthWithoutAttribution(t *testing.T) {
|
||||
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "aimlapi",
|
||||
CatalogID: "aimlapi",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "aimlapi-test-key",
|
||||
Model: "openai/gpt-5-chat",
|
||||
CustomHeaders: map[string]string{"X-Trace": "test"},
|
||||
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
for range stream {
|
||||
}
|
||||
|
||||
if transport.request == nil {
|
||||
t.Fatal("HTTP client was not used")
|
||||
}
|
||||
if got := transport.request.URL.String(); got != "https://api.aimlapi.com/v1/chat/completions" {
|
||||
t.Fatalf("request URL = %q, want AI/ML API endpoint", got)
|
||||
}
|
||||
for header, want := range map[string]string{
|
||||
"Authorization": "Bearer aimlapi-test-key",
|
||||
"X-Trace": "test",
|
||||
} {
|
||||
if got := transport.request.Header.Get(header); got != want {
|
||||
t.Fatalf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
// No first-party referral/attribution headers are injected for catalog
|
||||
// presets; aimlapi rides through CopyHeaders like every other provider.
|
||||
for _, header := range []string{
|
||||
"X-AIMLAPI-Partner-ID",
|
||||
"X-AIMLAPI-Integration-Repo",
|
||||
"X-AIMLAPI-Integration-Version",
|
||||
} {
|
||||
if got := transport.request.Header.Get(header); got != "" {
|
||||
t.Fatalf("%s = %q, want no attribution header", header, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSupportsOpenAIProviderKind(t *testing.T) {
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "openai",
|
||||
|
||||
@@ -19,13 +19,6 @@ 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
|
||||
@@ -181,28 +174,19 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
|
||||
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
|
||||
}
|
||||
// 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.
|
||||
// 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.
|
||||
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 := ""
|
||||
@@ -212,11 +196,7 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
downgradeReason = windowsSetupDowngradeReason
|
||||
}
|
||||
}
|
||||
if enforcementLevel == EnforcementUnelevated {
|
||||
downgradeReason = windowsUnelevatedDowngradeReason
|
||||
}
|
||||
wrapped := backend.CommandWrapping && backend.Available &&
|
||||
(enforcementLevel == EnforcementNative || enforcementLevel == EnforcementUnelevated)
|
||||
wrapped := backend.CommandWrapping && backend.Available && enforcementLevel == EnforcementNative
|
||||
markers := backend.SandboxEnvMarkers(policy)
|
||||
if !wrapped && backend.Name != BackendWSL {
|
||||
markers = nil
|
||||
|
||||
@@ -132,64 +132,6 @@ 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 != "" {
|
||||
|
||||
@@ -143,16 +143,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
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"
|
||||
EnforcementNative EnforcementLevel = "native"
|
||||
EnforcementDegraded EnforcementLevel = "degraded"
|
||||
EnforcementDisabled EnforcementLevel = "disabled"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
|
||||
@@ -8,21 +8,14 @@ import (
|
||||
)
|
||||
|
||||
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
|
||||
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:
|
||||
if config.SandboxLevel != WindowsSandboxLevelRestrictedToken {
|
||||
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
|
||||
@@ -64,32 +57,3 @@ 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)
|
||||
}
|
||||
@@ -2,16 +2,14 @@ package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
restore := windowsSandboxInitialized
|
||||
t.Cleanup(func() { windowsSandboxInitialized = restore })
|
||||
|
||||
@@ -26,70 +24,13 @@ func TestWindowsFallsBackUnelevatedWhenSandboxNotInitialized(t *testing.T) {
|
||||
ValidateExecution: true,
|
||||
}
|
||||
|
||||
// Marker missing + auto -> unelevated tier: still wrapped, with a reason
|
||||
// documenting the missing network enforcement, no error.
|
||||
// Marker missing + auto -> degrade (unwrapped, with a reason), no error.
|
||||
windowsSandboxInitialized = func() bool { return false }
|
||||
auto := base
|
||||
auto.Preference = SandboxPreferenceAuto
|
||||
req, err := mgr.BuildExecutionRequest(auto)
|
||||
if err != nil {
|
||||
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)
|
||||
t.Fatalf("auto must degrade, not error: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementDegraded {
|
||||
t.Fatalf("enforcement = %v, want degraded", req.EnforcementLevel)
|
||||
@@ -100,4 +41,21 @@ func TestWindowsNetworkOnlyProfileStillDegradesWhenNotInitialized(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)
|
||||
}
|
||||
}
|
||||
@@ -95,14 +95,8 @@ type WindowsSandboxLevel string
|
||||
|
||||
const (
|
||||
WindowsSandboxLevelRestrictedToken WindowsSandboxLevel = "restricted-token"
|
||||
// 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"
|
||||
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
|
||||
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
|
||||
)
|
||||
|
||||
type WindowsSandboxCommandArgsOptions struct {
|
||||
@@ -277,20 +271,13 @@ 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: level,
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -394,7 +381,7 @@ func nextWindowsSandboxFlagValue(args []string, index int) (string, int, error)
|
||||
|
||||
func validWindowsSandboxLevel(level WindowsSandboxLevel) bool {
|
||||
switch level {
|
||||
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelUnelevated, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
|
||||
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -124,15 +124,27 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
|
||||
return " " + keyCell + " " + desc
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// renderKeybindingHelpOverlay renders the framed, centered `?` help overlay for
|
||||
// the given terminal dimensions.
|
||||
func (m model) renderKeybindingHelpOverlay(width int, height int) string {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
return centerRenderedBlock(block, width)
|
||||
}
|
||||
|
||||
// keybindingHelpOverlay returns the `?` shortcut overlay as a centered block to
|
||||
// COMPOSITE over the transcript — the same viewport-overlay path the model
|
||||
// picker and suggestions use — or "" when it is not open. Rendering it this way
|
||||
// (rather than replacing the whole View) keeps the chat visible behind it, so
|
||||
// `?` behaves like every other popup instead of blanking the screen (#419).
|
||||
func (m model) keybindingHelpOverlay(width int) string {
|
||||
if !m.helpOverlay {
|
||||
return ""
|
||||
}
|
||||
return m.renderKeybindingHelpOverlay(width, m.height)
|
||||
}
|
||||
|
||||
// keybindingHelpOverlayWidth picks the overlay width: wide enough that the
|
||||
// descriptions don't truncate next to the key column, capped, and never wider
|
||||
// than the terminal.
|
||||
|
||||
@@ -109,9 +109,9 @@ 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".
|
||||
// composer. So with the overlay open, the 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
|
||||
@@ -126,7 +126,8 @@ func TestHelpOverlayCompositesOverChatNotReplacingIt(t *testing.T) {
|
||||
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.
|
||||
// box) must survive behind the overlay. The full-screen replace showed none
|
||||
// of it.
|
||||
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)
|
||||
|
||||
+12
-14
@@ -1068,7 +1068,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m = m.disarmCancelConfirmation()
|
||||
}
|
||||
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
|
||||
// other key is swallowed so nothing types into the hidden composer.
|
||||
// other key is swallowed so nothing types into the composer behind it.
|
||||
if m.helpOverlay {
|
||||
if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') {
|
||||
m.helpOverlay = false
|
||||
@@ -2118,13 +2118,10 @@ func (m model) View() tea.View {
|
||||
var content string
|
||||
if m.setup.visible {
|
||||
content = m.setupView(chatWidth(m.width))
|
||||
} 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 {
|
||||
} else if m.transcriptDetailed {
|
||||
content = m.detailedTranscriptView()
|
||||
} else {
|
||||
content = m.transcriptView()
|
||||
}
|
||||
|
||||
view := tea.NewView(content)
|
||||
@@ -2195,11 +2192,7 @@ func (m model) transcriptView() string {
|
||||
return body + footer
|
||||
}
|
||||
|
||||
helpOverlayContent := ""
|
||||
if m.helpOverlay {
|
||||
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
|
||||
}
|
||||
|
||||
helpOverlay := m.keybindingHelpOverlay(width)
|
||||
suggestionOverlay := m.suggestionOverlay(width)
|
||||
providerOverlay := m.providerWizardOverlay(width)
|
||||
mcpAddOverlay := m.mcpAddWizardOverlay(width)
|
||||
@@ -2207,8 +2200,8 @@ func (m model) transcriptView() string {
|
||||
pickerOverlay := m.pickerOverlay(width)
|
||||
viewportOverlay := ""
|
||||
switch {
|
||||
case helpOverlayContent != "":
|
||||
viewportOverlay = helpOverlayContent
|
||||
case helpOverlay != "":
|
||||
viewportOverlay = helpOverlay
|
||||
case providerOverlay != "":
|
||||
viewportOverlay = providerOverlay
|
||||
case mcpAddOverlay != "":
|
||||
@@ -2270,6 +2263,11 @@ func (m model) twoColumnTranscriptView() string {
|
||||
if m.transcriptEmpty() && !m.pending {
|
||||
overlayForViewport = ""
|
||||
}
|
||||
// The `?` help overlay composites over the chat like the pickers — set it
|
||||
// after the empty-transcript clear so a user-toggled overlay always shows.
|
||||
if helpOverlay := m.keybindingHelpOverlay(width); helpOverlay != "" {
|
||||
overlayForViewport = helpOverlay
|
||||
}
|
||||
|
||||
header := m.pinnedTitleBar(width)
|
||||
chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport))
|
||||
|
||||
@@ -44,19 +44,11 @@ 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 {
|
||||
// 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 = ""
|
||||
m.providerWizard.manageProviderName = name
|
||||
m.providerWizard.manageKeyCursor = 0
|
||||
m.providerWizard.err = ""
|
||||
m.providerWizard.step = providerWizardStepManageKey
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
previous := m.providerWizard.step
|
||||
|
||||
@@ -172,13 +172,17 @@ func TestProviderWizardAdvancesProviderAPIKeyAndModelSteps(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = openProviderWizardForTest(t, m)
|
||||
|
||||
m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "anthropic")
|
||||
next := m
|
||||
// Index 0 is the recommended OpenGateway entry, so two downs land on anthropic
|
||||
// (opengateway → openai → anthropic).
|
||||
updated, _ := m.Update(testKey(tea.KeyDown))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(testKey(tea.KeyDown))
|
||||
next = updated.(model)
|
||||
if got := next.providerWizard.currentProvider().ID; got != "anthropic" {
|
||||
t.Fatalf("after down, selected provider = %q, want anthropic", got)
|
||||
}
|
||||
|
||||
updated, _ := next.Update(testKey(tea.KeyEnter))
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
if next.providerWizard.step != providerWizardStepCredential {
|
||||
t.Fatalf("wizard step = %v, want credential", next.providerWizard.step)
|
||||
@@ -309,12 +313,14 @@ func TestProviderWizardRightAllowsExistingCredentialEnv(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = openProviderWizardForTest(t, m)
|
||||
|
||||
m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "openai")
|
||||
next := m
|
||||
// Move off the recommended OpenGateway entry (index 0) onto openai so the
|
||||
// OPENAI_API_KEY env credential is the one in play.
|
||||
updated, _ := m.Update(testKey(tea.KeyDown))
|
||||
next := updated.(model)
|
||||
if got := next.providerWizard.currentProvider().ID; got != "openai" {
|
||||
t.Fatalf("after down, selected provider = %q, want openai", got)
|
||||
}
|
||||
updated, _ := next.Update(testKey(tea.KeyEnter))
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
if next.providerWizard.step != providerWizardStepCredential {
|
||||
t.Fatalf("enter from provider step = %v, want credential", next.providerWizard.step)
|
||||
@@ -1161,71 +1167,6 @@ 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()
|
||||
|
||||
|
||||
@@ -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.helpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user