mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cc6730452 | ||
|
|
efd483f978 | ||
|
|
8b10c43cc6 | ||
|
|
4cd6466f43 | ||
|
|
0d1df5c0b4 | ||
|
|
82e6fafab4 | ||
|
|
22455afd6b | ||
|
|
8ba6ed42b5 | ||
|
|
9a1a1ee240 | ||
|
|
565e571509 | ||
|
|
30dd7c3112 | ||
|
|
e7d88b4b51 | ||
|
|
177442cfe4 | ||
|
|
190241bd59 | ||
|
|
ba6c026469 | ||
|
|
daf65e0af9 | ||
|
|
f401c66e81 | ||
|
|
949ee43f71 | ||
|
|
2fcea71778 | ||
|
|
5f36349c18 | ||
|
|
e4f760ee8b | ||
|
|
068c5ad3ad | ||
|
|
407a92739f | ||
|
|
d05e9148a7 | ||
|
|
1d8638466c | ||
|
|
305a62c954 | ||
|
|
9134148f4d | ||
|
|
f653dcac36 | ||
|
|
071ea3a495 | ||
|
|
b0abde7d06 | ||
|
|
5658a36627 | ||
|
|
c60ad8729f | ||
|
|
0562e3bef7 | ||
|
|
9bd93c62f8 | ||
|
|
bdb1b0ecd1 | ||
|
|
4266baf222 | ||
|
|
8d0c5feccb | ||
|
|
b63fe696fd | ||
|
|
3fbbd28e4c | ||
|
|
b9ddd6f421 | ||
|
|
aaad4d2712 | ||
|
|
b4275e3504 | ||
|
|
5b2b4dea1a |
No files matched your search
@@ -4,12 +4,14 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
jobs:
|
||||
@@ -29,7 +31,8 @@ jobs:
|
||||
import sys, yaml
|
||||
|
||||
with open("action.yml", "r", encoding="utf-8") as handle:
|
||||
doc = yaml.safe_load(handle)
|
||||
action_raw = handle.read()
|
||||
doc = yaml.safe_load(action_raw)
|
||||
|
||||
assert isinstance(doc, dict), "action.yml must be a mapping"
|
||||
assert doc.get("name"), "action must declare a name"
|
||||
@@ -65,6 +68,19 @@ jobs:
|
||||
assert "--skip-permissions-unsafe" not in serialized, \
|
||||
"action must never pass --skip-permissions-unsafe"
|
||||
|
||||
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
|
||||
assert run_zero is not None, "action.yml must contain a Run ZERO step"
|
||||
run_zero_script = run_zero.get("run") or ""
|
||||
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
|
||||
"Run ZERO must export the provider key for the current step"
|
||||
assert "later steps inherit" not in action_raw.lower(), \
|
||||
"provider keys must not be documented as inherited by later steps"
|
||||
for line in run_zero_script.splitlines():
|
||||
if "GITHUB_ENV" in line and (
|
||||
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
|
||||
):
|
||||
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
|
||||
|
||||
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
|
||||
PY
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
|
||||
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||
# lint` before opening a PR" — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
|
||||
|
||||
@@ -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,
|
||||
@@ -57,20 +59,24 @@ Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
Install with Bun, then fetch the binary in one of two ways:
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# Option A: run the installer manually
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# Option B: allow the postinstall to run on install
|
||||
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
|
||||
bun add @gitlawb/zero
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
|
||||
global install path, or prefer the install scripts below.
|
||||
Alternatives: allow the postinstall up front by adding
|
||||
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
|
||||
before `bun add`, or run the installer manually
|
||||
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
|
||||
that do not have `bun pm trust`.
|
||||
|
||||
### Install scripts
|
||||
|
||||
@@ -142,6 +148,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
@@ -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`。
|
||||
|
||||
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
|
||||
|
||||
```bash
|
||||
# 项目安装
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# 全局安装
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
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) 发布。
|
||||
+4
-5
@@ -27,14 +27,14 @@ inputs:
|
||||
api-key:
|
||||
description: >-
|
||||
The provider API key. Pass it from a repository or organization secret;
|
||||
it is exported into the step environment and never written to the log.
|
||||
it is exported only for the ZERO step and never written to the log.
|
||||
required: false
|
||||
default: ""
|
||||
api-key-env:
|
||||
description: >-
|
||||
Name of the environment variable the chosen provider reads its key from
|
||||
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
|
||||
api-key, that variable is exported for the run. Leave the action
|
||||
api-key, that variable is exported only for the ZERO step. Leave the action
|
||||
provider-agnostic by passing the env name your provider expects rather
|
||||
than hardcoding one here.
|
||||
required: false
|
||||
@@ -192,10 +192,9 @@ runs:
|
||||
echo "::error::api-key-env must be a valid environment variable name." >&2
|
||||
exit 2
|
||||
fi
|
||||
# Export in-process so the zero invocation in this same run block sees
|
||||
# it, and append to GITHUB_ENV so any later steps inherit it too.
|
||||
# Export in-process for this Run ZERO step so zero can read it; do
|
||||
# not persist provider credentials to later job steps.
|
||||
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
|
||||
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
|
||||
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
|
||||
|
||||
+5
-3
@@ -66,10 +66,12 @@ if (!existsSync(nativePath)) {
|
||||
'\n' +
|
||||
(ranByBun
|
||||
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. To let it run on future installs, add zero to your\n' +
|
||||
'project\'s trustedDependencies:\n' +
|
||||
'by default. Trust the package to run the blocked postinstall:\n' +
|
||||
' bun pm trust @gitlawb/zero (project install)\n' +
|
||||
' bun pm -g trust @gitlawb/zero (global install)\n' +
|
||||
'On Bun versions without `bun pm trust`, add\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'then reinstall.\n' +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
|
||||
@@ -41,8 +41,9 @@ jobs:
|
||||
> provider-neutral: `api-key-env` is the environment variable name the chosen
|
||||
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
|
||||
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
|
||||
> that variable for the run and never prints it. If your repository already
|
||||
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
|
||||
> that variable only for the ZERO step and never prints it. If your repository
|
||||
> already commits a `.zero/config.json` with an active provider, you can omit
|
||||
> `provider`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -51,8 +52,8 @@ jobs:
|
||||
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
|
||||
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
|
||||
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
|
||||
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
|
||||
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
|
||||
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
|
||||
@@ -162,9 +163,10 @@ jobs:
|
||||
## Security notes
|
||||
|
||||
- **Secrets are passed as Action secrets and never logged.** The action exports
|
||||
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
|
||||
environment only. They are never echoed, and ZERO redacts secret-shaped
|
||||
strings from its own output.
|
||||
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
|
||||
is passed only to the Slack post step. They are never echoed or persisted to
|
||||
later workflow steps, and ZERO redacts secret-shaped strings from its own
|
||||
output.
|
||||
- **The sandbox is always active.** This action never passes
|
||||
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
|
||||
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
|
||||
|
||||
+19
-6
@@ -31,15 +31,24 @@ dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
To install with Bun, either run the installer manually after installing:
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
Or allow the postinstall to run by adding the package to your project's
|
||||
`trustedDependencies` before installing:
|
||||
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
|
||||
you want to inspect before trusting.
|
||||
|
||||
Alternatively, allow the postinstall to run at install time by adding the
|
||||
package to your project's `trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -51,8 +60,12 @@ Or allow the postinstall to run by adding the package to your project's
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
|
||||
against the global install path, or use the install scripts below.
|
||||
On Bun versions that do not have `bun pm trust`, run the installer manually
|
||||
after installing:
|
||||
|
||||
```bash
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||
// omits (or malforms) additional_permissions can never succeed: the same
|
||||
// validation runs again at grant time regardless of the user's decision. It
|
||||
// must be rejected as a plain tool error BEFORE any permission prompt, not
|
||||
// surfaced as a confusing "approved but denied" prompt outcome.
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The mismatched-flag case (additional_permissions present without the
|
||||
// sandbox_permissions flag) must be rejected the same way.
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection message must show a concrete, correctly-shaped example: a
|
||||
// model that gets this wrong once (attaching sandbox_permissions out of habit
|
||||
// from an earlier call, without a valid payload) needs enough in the error to
|
||||
// self-correct on retry instead of repeating the identical mistake.
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed additional_permissions payload must still reach the normal
|
||||
// permission prompt: this fix only rejects malformed payloads.
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
|
||||
`"additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionDeny}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if promptCalls != 1 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,17 @@ import (
|
||||
// the agent loop owns the provider wiring.
|
||||
|
||||
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
||||
// when the caller does not specify a preserve count.
|
||||
const defaultCompactionPreserveLast = 8
|
||||
// when the caller does not specify a preserve count. Kept in sync with the replay
|
||||
// reconstruction default in internal/sessions/replay.go.
|
||||
const defaultCompactionPreserveLast = 6
|
||||
|
||||
// compactionTriggerRatio is the fraction of the context window at which
|
||||
// proactive compaction kicks in (top of each turn).
|
||||
const compactionTriggerRatio = 0.8
|
||||
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
|
||||
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
|
||||
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
|
||||
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
|
||||
// summarization, raise it back toward 0.8.
|
||||
const compactionTriggerRatio = 0.7
|
||||
|
||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||
// transcript (and so tests can assert on it).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
)
|
||||
|
||||
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
|
||||
// slow or wedged language server can never hang a tool call; on timeout the
|
||||
// edit simply reports without a diagnostics block.
|
||||
const fileDiagnosticsTimeout = 10 * time.Second
|
||||
|
||||
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
|
||||
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
|
||||
// checks it against the file's language server, and formats error-severity
|
||||
// diagnostics for the model. Warnings and hints are excluded — nagging about
|
||||
// style on every edit is noise, while a type error the edit just introduced is
|
||||
// exactly what the model should see before its next step. Returns nil when
|
||||
// manager is nil, disabling inline diagnostics entirely.
|
||||
func NewFileDiagnostics(manager *lsp.Manager) func(context.Context, string) string {
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, absPath string) string {
|
||||
text, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
|
||||
defer cancel()
|
||||
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
|
||||
if len(errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
return lsp.FormatDiagnostics(absPath, errors)
|
||||
}
|
||||
}
|
||||
+134
-61
@@ -475,11 +475,35 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// between tool_results breaks strict provider replay) — same after-batch
|
||||
// rationale as turnRequestedModel above.
|
||||
var changedFilesThisBatch []string
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
precomputedStart, precomputedEnd := 0, 0
|
||||
for index, call := range collected.ToolCalls {
|
||||
// When this call starts a consecutive run of >= 2 auto-allowed read-only
|
||||
// calls, execute the whole run concurrently now (see parallel_tools.go).
|
||||
// The scan happens lazily at the run's first index — never ahead of a
|
||||
// pending mutating call — so read-after-write ordering is preserved.
|
||||
if index >= precomputedEnd {
|
||||
runEnd := index
|
||||
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
|
||||
runEnd++
|
||||
}
|
||||
if runEnd-index >= 2 {
|
||||
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
|
||||
precomputedStart, precomputedEnd = index, runEnd
|
||||
}
|
||||
}
|
||||
if options.OnToolCall != nil {
|
||||
options.OnToolCall(call)
|
||||
}
|
||||
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
|
||||
var toolResult ToolResult
|
||||
var abortErr error
|
||||
if index >= precomputedStart && index < precomputedEnd {
|
||||
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
|
||||
} else {
|
||||
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
|
||||
}
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -816,6 +840,25 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}
|
||||
}
|
||||
|
||||
// A shell command that sets sandbox_permissions: with_additional_permissions
|
||||
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
|
||||
// is the single source of truth for that shape, and buildPermissionEvent (below)
|
||||
// calls it again to render the prompt's scope text. Check it here, before any
|
||||
// permission prompt is shown: a malformed payload can never be satisfied no
|
||||
// matter what the user decides, so surfacing it as a plain tool error (which the
|
||||
// model can see and retry) is both clearer and avoids presenting a normal-looking
|
||||
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
|
||||
if toolFound && isShellCommandTool(call.Name) {
|
||||
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ask_user is intercepted in the loop (like permissions) so the question can
|
||||
// be routed to an interactive front-end instead of blocking inside the tool.
|
||||
// When no front-end is wired up it degrades to the tool's own graceful Run().
|
||||
@@ -1032,6 +1075,8 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||
// a file that changed on disk outside Zero since it was last read.
|
||||
FileTracker: options.FileTracker,
|
||||
// Inline post-edit diagnostics for mutating tools (nil = disabled).
|
||||
Diagnostics: options.FileDiagnostics,
|
||||
// Forward the run's operator tool filters so a filter-aware tool
|
||||
// (tool_search) never discloses or loads an operator-hidden deferred tool.
|
||||
EnabledTools: options.EnabledTools,
|
||||
@@ -2322,7 +2367,12 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
}
|
||||
raw, ok := args["additional_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
|
||||
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
|
||||
"omit sandbox_permissions entirely and retry",
|
||||
tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -2337,7 +2387,9 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
return sandbox.RequestPermissionProfile{}, true, err
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"additional_permissions must include at least one of network or file_system, for example " +
|
||||
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
@@ -2470,76 +2522,97 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
|
||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
|
||||
// deferral there is no mid-session loading, so this is byte-stable across turns
|
||||
// and byte-identical to the pre-deferral output.
|
||||
if !active {
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
for _, tool := range visible {
|
||||
if tool.Name() == tools.ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, runtimeToolDefinition(tool))
|
||||
}
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
})
|
||||
return definitions, ""
|
||||
}
|
||||
|
||||
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
|
||||
// deferred tool loads mid-session. The tools block is part of the provider's
|
||||
// cached prefix; if a load reorders it, the cache invalidates from that point
|
||||
// through the system prompt and messages — on EVERY load. Previously the whole
|
||||
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
|
||||
// middle and shifted every later definition. Instead we build three append-only
|
||||
// regions:
|
||||
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
|
||||
// 2. tool_search — always present, right after the stable block;
|
||||
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||||
// transition grows the tail instead of inserting into the eager block.
|
||||
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||||
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||||
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||||
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||||
var hiddenTools []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
deferred := tools.IsDeferred(tool)
|
||||
|
||||
if !active {
|
||||
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue // added explicitly between the eager block and the loaded tail
|
||||
}
|
||||
if tools.IsDeferred(tool) {
|
||||
if loaded[name] {
|
||||
loadedTail = append(loadedTail, runtimeToolDefinition(tool))
|
||||
} else {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Active path.
|
||||
if deferred && !loaded[name] {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
eager = append(eager, runtimeToolDefinition(tool))
|
||||
}
|
||||
|
||||
// On the ACTIVE path tool_search is guaranteed runnable (active implies
|
||||
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
|
||||
// EnabledTools allowlist omits it (the operator allowlisted the deferred
|
||||
// tools, not the loader). Expose its full definition whenever it is not
|
||||
// already in the exposed set. This never runs on the inactive path, so the
|
||||
// byte-identical below-threshold output is preserved.
|
||||
discovery := ""
|
||||
if active && len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
if active && !exposedNames[tools.ToolSearchToolName] {
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
} else if active && discovery != "" {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == tools.ToolSearchToolName {
|
||||
definitions[i].Description = discovery
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
sort.Slice(eager, func(left int, right int) bool {
|
||||
return eager[left].Name < eager[right].Name
|
||||
})
|
||||
sort.Slice(loadedTail, func(left int, right int) bool {
|
||||
return loadedTail[left].Name < loadedTail[right].Name
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
|
||||
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||||
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||||
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||||
// eager block and carries the discovery list for still-hidden tools.
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
|
||||
definitions = append(definitions, eager...)
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
definitions = append(definitions, loadedTail...)
|
||||
|
||||
return definitions, discovery
|
||||
}
|
||||
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// Parallel read-ahead for tool batches. When a turn requests several
|
||||
// independent lookups (read_file + grep + glob is the common shape), executing
|
||||
// them one after another serializes pure I/O waits. A consecutive run of
|
||||
// auto-allowed read-only calls is executed concurrently instead; results are
|
||||
// then consumed in the original call order, so guard counters, message
|
||||
// ordering, abort semantics, and the surface's call/result event pairing are
|
||||
// byte-identical to sequential execution. Runs never span a mutating call: a
|
||||
// read that follows a write must observe the write, so eligibility is decided
|
||||
// per consecutive run, not per batch.
|
||||
|
||||
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
|
||||
const maxParallelReadTools = 8
|
||||
|
||||
// precomputedToolResult is one parallel read-ahead execution, keyed back to
|
||||
// its batch index by the caller.
|
||||
type precomputedToolResult struct {
|
||||
result ToolResult
|
||||
abortErr error
|
||||
}
|
||||
|
||||
// parallelSafeToolCall reports whether call may run concurrently with its
|
||||
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
|
||||
// auto-allowed for these args, so no interactive prompt or workspace mutation
|
||||
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
|
||||
// and tool_search (mutates the deferred-tool set) stay sequential.
|
||||
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
|
||||
switch call.Name {
|
||||
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
|
||||
return false
|
||||
}
|
||||
tool, found := registry.Get(call.Name)
|
||||
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
|
||||
return false
|
||||
}
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return effectivePermission(tool, args) == tools.PermissionAllow
|
||||
}
|
||||
|
||||
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
|
||||
// maxParallelReadTools) and returns results indexed relative to start. Any
|
||||
// permission prompt that still occurs (a sandbox preflight can demand one even
|
||||
// for an auto-allowed read) is serialized so an interactive front-end never
|
||||
// sees two prompts at once.
|
||||
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
|
||||
batchOptions := options
|
||||
if options.OnPermissionRequest != nil {
|
||||
var promptMutex sync.Mutex
|
||||
inner := options.OnPermissionRequest
|
||||
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptMutex.Lock()
|
||||
defer promptMutex.Unlock()
|
||||
return inner(ctx, request)
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]precomputedToolResult, end-start)
|
||||
semaphore := make(chan struct{}, maxParallelReadTools)
|
||||
var waitGroup sync.WaitGroup
|
||||
for index := start; index < end; index++ {
|
||||
waitGroup.Add(1)
|
||||
go func(index int) {
|
||||
defer waitGroup.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
|
||||
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
|
||||
}(index)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// probeTool records execution overlap and ordering so tests can assert what
|
||||
// actually ran concurrently.
|
||||
type probeTool struct {
|
||||
name string
|
||||
sideEffect tools.SideEffect
|
||||
delay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
active int
|
||||
maxActive int
|
||||
log []string
|
||||
}
|
||||
|
||||
func (tool *probeTool) Name() string { return tool.name }
|
||||
func (tool *probeTool) Description() string { return "test probe tool" }
|
||||
func (tool *probeTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *probeTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
id, _ := args["id"].(string)
|
||||
tool.mu.Lock()
|
||||
tool.active++
|
||||
if tool.active > tool.maxActive {
|
||||
tool.maxActive = tool.active
|
||||
}
|
||||
tool.log = append(tool.log, "start:"+id)
|
||||
tool.mu.Unlock()
|
||||
time.Sleep(tool.delay)
|
||||
tool.mu.Lock()
|
||||
tool.active--
|
||||
tool.log = append(tool.log, "end:"+id)
|
||||
tool.mu.Unlock()
|
||||
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
|
||||
}
|
||||
|
||||
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSafeToolCall(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
|
||||
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
|
||||
|
||||
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
|
||||
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
|
||||
t.Fatal("auto-allowed read tool must be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
|
||||
t.Fatal("mutating tool must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
|
||||
t.Fatal("unknown tool must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
|
||||
t.Fatal("undecodable arguments must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
|
||||
t.Fatal("loop-intercepted tools must stay sequential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
|
||||
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(probe)
|
||||
|
||||
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
|
||||
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
turnOne,
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
|
||||
var results []ToolResult
|
||||
_, err := Run(context.Background(), "probe", provider, Options{
|
||||
Registry: registry,
|
||||
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probe.maxActive < 2 {
|
||||
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
|
||||
}
|
||||
// Results must still be recorded in original call order.
|
||||
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
|
||||
t.Fatalf("tool results out of order: %#v", results)
|
||||
}
|
||||
messages := provider.requests[1].Messages
|
||||
var toolOrder []string
|
||||
for _, message := range messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolOrder = append(toolOrder, message.ToolCallID)
|
||||
}
|
||||
}
|
||||
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
|
||||
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
|
||||
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond}
|
||||
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite}
|
||||
// Shared log across both probes so relative ordering is observable.
|
||||
write.mu = sync.Mutex{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(read)
|
||||
registry.Register(write)
|
||||
|
||||
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
|
||||
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
turnOne,
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The write must start only after r1+r2 finished, and r3/r4 must start only
|
||||
// after the write finished: batches never cross a mutating call.
|
||||
index := func(log []string, entry string) int {
|
||||
for i, e := range log {
|
||||
if e == entry {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
readLog := func() []string { read.mu.Lock(); defer read.mu.Unlock(); return append([]string(nil), read.log...) }()
|
||||
writeLog := func() []string { write.mu.Lock(); defer write.mu.Unlock(); return append([]string(nil), write.log...) }()
|
||||
if index(writeLog, "start:w") == -1 {
|
||||
t.Fatalf("write probe never ran: %v", writeLog)
|
||||
}
|
||||
for _, entry := range []string{"end:r1", "end:r2"} {
|
||||
if index(readLog, entry) == -1 {
|
||||
t.Fatalf("first read batch incomplete: %v", readLog)
|
||||
}
|
||||
}
|
||||
// r3/r4 must appear strictly after r1/r2 in the read log (the write barrier
|
||||
// between the two batches forces full separation).
|
||||
firstBatchMaxEnd := max(index(readLog, "end:r1"), index(readLog, "end:r2"))
|
||||
secondBatchMinStart := min(index(readLog, "start:r3"), index(readLog, "start:r4"))
|
||||
if secondBatchMinStart < firstBatchMaxEnd {
|
||||
t.Fatalf("second read batch started before first batch ended: %v", readLog)
|
||||
}
|
||||
if read.maxActive < 2 {
|
||||
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// Cache-stability property: loading a deferred tool must APPEND it after the
|
||||
// always-eager tools, not sort it into the middle of them — otherwise the tool
|
||||
// definitions (the provider's cacheable prefix) shift on every load. The old
|
||||
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
|
||||
|
||||
pos := func(name string) int {
|
||||
for i := range exposed {
|
||||
if exposed[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
|
||||
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
|
||||
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||
}
|
||||
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||
if !(readPos < alphaPos && searchPos < alphaPos) {
|
||||
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||
}
|
||||
// beta was never loaded — it stays hidden.
|
||||
if pos("mcp__srv__beta") != -1 {
|
||||
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||
// system prompt and the eager tool schemas that ride on EVERY request. They are
|
||||
// set ~10% above the current measured cost, using ApproxTextTokens (the same
|
||||
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
|
||||
// that pushes either past its ceiling must be justified (and the ceiling raised
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
// Minimal render: a model is set (so the session block renders) but no Cwd, so
|
||||
// the workspace map, project guidelines, and repo map are excluded — this is the
|
||||
// fixed base every session pays regardless of the workspace it runs in.
|
||||
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
|
||||
got := ApproxTextTokens(prompt)
|
||||
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
|
||||
if got > maxBaseSystemPromptTokens {
|
||||
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
}
|
||||
// The first skill is always listed regardless of budget.
|
||||
if !strings.Contains(got, "- skill-0:") {
|
||||
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
}
|
||||
got := skillsContext(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a conventional-commit message."},
|
||||
{Name: " ", Description: "nameless, should be skipped"},
|
||||
{Name: "reviewer"},
|
||||
}})
|
||||
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
|
||||
t.Fatalf("missing available_skills block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
|
||||
t.Fatalf("missing commit-writer line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- reviewer\n") {
|
||||
t.Fatalf("reviewer (no description) line missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
|
||||
with := buildSystemPrompt(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a commit message."},
|
||||
}})
|
||||
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
|
||||
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
|
||||
}
|
||||
// Default (no skills) must reproduce the prior prompt: no skills block.
|
||||
without := buildSystemPrompt(Options{})
|
||||
if strings.Contains(without, "<available_skills>") {
|
||||
t.Fatalf("available_skills block must not appear without skills")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -86,6 +87,9 @@ func buildSystemPrompt(options Options) string {
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
@@ -164,6 +168,64 @@ func specialistDelegationContext(options Options) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// skillsContext lists the reusable skills the model can pull in on demand via the
|
||||
// skill tool, so it invokes the right one on the first try instead of guessing a
|
||||
// name, failing, and reading the error. It mirrors specialistDelegationContext:
|
||||
// names + one-line descriptions only (never skill bodies), and it renders nothing
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
line := "- " + name
|
||||
if desc := strings.TrimSpace(info.Description); desc != "" {
|
||||
line += ": " + truncateForSkillLine(desc)
|
||||
}
|
||||
line += "\n"
|
||||
// Always list at least one skill; past the budget, summarize the remainder
|
||||
// as a count instead of silently dropping it.
|
||||
if listed > 0 && spent+len(line) > skillsContextListBudget {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
spent += len(line)
|
||||
listed++
|
||||
}
|
||||
if omitted > 0 {
|
||||
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
|
||||
}
|
||||
b.WriteString("</available_skills>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
|
||||
}
|
||||
|
||||
func sessionRuntimeContext(options Options) string {
|
||||
provider := strings.TrimSpace(options.ProviderName)
|
||||
model := strings.TrimSpace(options.Model)
|
||||
@@ -310,9 +372,9 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
||||
|
||||
// findProjectContextFile returns the first matching project guideline file
|
||||
// in dir. Lookup is case-insensitive on the basename and on the actual
|
||||
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
|
||||
// still resolves to its true filename on case-sensitive filesystems — the
|
||||
// returned path is what should appear in the project guidelines label.
|
||||
// filename returned, so a git-tracked file like AGENTS.md still resolves to
|
||||
// its true filename on case-sensitive filesystems — the returned path is what
|
||||
// should appear in the project guidelines label.
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
|
||||
@@ -46,14 +46,10 @@ work.
|
||||
formatting-only edits unless the user asked for them.
|
||||
4. **Verify.** Verify after edits; see the testing gate below. This is
|
||||
mandatory.
|
||||
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
|
||||
summary — never a single throwaway line. Use short headings and bullets: a
|
||||
one-line "what I built/changed"; **Where it lives** — the key files and how they
|
||||
fit together; **How each requirement is met** — walk through each requirement
|
||||
from the request and name the specifics that satisfy it; what validation ran and
|
||||
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
|
||||
the depth to the work — a big build earns several sections, a trivial fix earns
|
||||
a line or two.
|
||||
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
|
||||
line; a substantial change earns a few short bullets covering what changed, where
|
||||
it lives, and what you did and did not verify. Lead with the outcome, and never
|
||||
pad — do not restate the task or narrate steps the user already watched you take.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -64,7 +60,9 @@ work.
|
||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||
shell or script invocation.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import "strings"
|
||||
//
|
||||
// Different model families respond to different framing: the GPT family benefits
|
||||
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance; the
|
||||
// Claude family is already well-aligned with the core prompt and only needs a
|
||||
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
|
||||
// block appended after the core prompt. Classification is by model id (the agent
|
||||
// only has the model string — no registry dependency).
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance. The
|
||||
// Claude family is already aligned with the core prompt and needs no addendum —
|
||||
// comment discipline is universal in the core prompt now, so every family gets it.
|
||||
// modelPromptAddendum returns the family-specific block appended after the core
|
||||
// prompt, or "" when the family is unknown or needs no specialization.
|
||||
// Classification is by model id (the agent only has the model string — no registry
|
||||
// dependency).
|
||||
|
||||
const (
|
||||
familyOpenAI = "openai"
|
||||
@@ -45,9 +47,10 @@ func modelPromptAddendum(model string) string {
|
||||
return openAIPromptAddendum
|
||||
case familyGemini:
|
||||
return geminiPromptAddendum
|
||||
case familyAnthropic:
|
||||
return anthropicPromptAddendum
|
||||
default:
|
||||
// Anthropic (aligned with the core prompt) and unknown families get no
|
||||
// family-specific block; comment discipline now lives in the core prompt
|
||||
// for every model.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
</model_guidance>`
|
||||
|
||||
const anthropicPromptAddendum = `<model_guidance>
|
||||
- Do not add code comments unless the user asks or the surrounding code is
|
||||
comment-dense; match the file's existing comment density.
|
||||
</model_guidance>`
|
||||
@@ -29,9 +29,11 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
if !strings.Contains(claude, anthropicPromptAddendum) {
|
||||
t.Fatalf("expected the Anthropic addendum in the claude prompt")
|
||||
if strings.Contains(claude, "<model_guidance>") {
|
||||
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
|
||||
}
|
||||
if strings.Contains(claude, openAIPromptAddendum) {
|
||||
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
||||
|
||||
@@ -23,7 +23,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"how each requirement is met",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -128,7 +129,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
||||
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
||||
|
||||
@@ -174,6 +174,16 @@ type SpecialistInfo struct {
|
||||
WhenToUse string
|
||||
}
|
||||
|
||||
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
|
||||
// frontmatter description) surfaced to the system prompt so the model can invoke
|
||||
// the right skill with the skill tool on the first try instead of guessing a name
|
||||
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
|
||||
// package needs no dependency on internal/skills.
|
||||
type SkillInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
@@ -189,6 +199,12 @@ type Options struct {
|
||||
// populated only where the Task tool is actually registered, so an empty slice
|
||||
// (the default) reproduces the previous prompt byte-for-byte.
|
||||
Specialists []SpecialistInfo
|
||||
// Skills lists the reusable skills installed for this run (the default skills
|
||||
// dir merged with any plugin skill roots). When non-empty the system prompt
|
||||
// gains an <available_skills> block naming them so the model loads the right one
|
||||
// via the skill tool on the first try. Empty (the default) reproduces the
|
||||
// previous prompt byte-for-byte.
|
||||
Skills []SkillInfo
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
@@ -269,6 +285,12 @@ type Options struct {
|
||||
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
// FileDiagnostics, when set, is passed to mutating tools so edit_file /
|
||||
// write_file append error-severity language diagnostics for the file they
|
||||
// just wrote directly to their tool output — the model sees an error it
|
||||
// introduced in the same turn instead of a later verification pass. Build
|
||||
// one with NewFileDiagnostics. nil disables inline diagnostics.
|
||||
FileDiagnostics func(ctx context.Context, absPath string) string
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
// any assistant turn that produces text but no tool call is accepted as the
|
||||
|
||||
+37
-4
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
@@ -80,6 +81,7 @@ type appDeps struct {
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -180,6 +182,7 @@ func defaultAppDeps() appDeps {
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -227,6 +230,12 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
|
||||
deps = fillAppDeps(deps)
|
||||
|
||||
// CLI runs opt into the models.dev overlay (cached live context limits and
|
||||
// pricing on top of the curated catalog). Explicitly enabled here — and only
|
||||
// here — so library consumers and hermetic tests are never perturbed by a
|
||||
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
|
||||
modelregistry.EnableModelsDevOverlay()
|
||||
|
||||
addDirs, args, err := splitLeadingAddDirFlags(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
@@ -383,6 +392,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runSandbox(args[1:], stdout, stderr, deps)
|
||||
case "update":
|
||||
return runUpdate(args[1:], stdout, stderr, deps)
|
||||
case "upgrade":
|
||||
return runUpgrade(args[1:], stdout, stderr, deps)
|
||||
case "worktrees", "worktree":
|
||||
return runWorktrees(args[1:], stdout, stderr, deps)
|
||||
case "verify":
|
||||
@@ -515,6 +526,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.checkUpdate == nil {
|
||||
deps.checkUpdate = defaults.checkUpdate
|
||||
}
|
||||
if deps.applyUpdate == nil {
|
||||
deps.applyUpdate = defaults.applyUpdate
|
||||
}
|
||||
if deps.now == nil {
|
||||
deps.now = defaults.now
|
||||
}
|
||||
@@ -537,6 +551,11 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
|
||||
}
|
||||
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
||||
@@ -557,8 +576,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
|
||||
// or "providers ARE configured, just none marked active" (e.g. config.json's
|
||||
// activeProvider is blank/stale). In the second case resolved.Providers still
|
||||
// carries the already-normalized list — prefer falling back to one of those
|
||||
// over wiping everything and forcing the user to re-enter credentials they
|
||||
// already saved.
|
||||
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
} else {
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
}
|
||||
}
|
||||
userConfigPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
@@ -740,9 +770,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
Setup: tui.SetupOptions{
|
||||
Visible: setupVisible,
|
||||
Required: needsSetup,
|
||||
@@ -1158,7 +1190,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
|
||||
}
|
||||
|
||||
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
||||
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
||||
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
|
||||
// provider like Ollama/LM Studio that needs no API key.
|
||||
func baseURLIsLoopback(baseURL string) bool {
|
||||
u := strings.TrimSpace(baseURL)
|
||||
if u == "" {
|
||||
@@ -1173,7 +1206,7 @@ func baseURLIsLoopback(baseURL string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -205,6 +205,67 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
var providerProfile config.ProviderProfile
|
||||
fake := &cliFakeProvider{}
|
||||
|
||||
usable := config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
// config.json has providers configured but none marked active (e.g. a
|
||||
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||
// normalized providers list alongside ErrNoActiveProvider.
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
return fake, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched")
|
||||
}
|
||||
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
|
||||
}
|
||||
if providerProfile.Name != "work" {
|
||||
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -1267,6 +1328,153 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
|
||||
applyResult := update.ApplyResult{
|
||||
Result: update.Result{
|
||||
CurrentVersion: "dev",
|
||||
LatestVersion: "0.2.0",
|
||||
UpdateAvailable: true,
|
||||
},
|
||||
Applied: true,
|
||||
InstallMethod: update.InstallMethodStandalone,
|
||||
BinaryPath: "/usr/local/bin/zero",
|
||||
Message: "updated to 0.2.0",
|
||||
}
|
||||
var gotOptions update.Options
|
||||
deps := appDeps{
|
||||
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
|
||||
gotOptions = options
|
||||
return applyResult, nil
|
||||
},
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run for --apply")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
|
||||
t.Fatalf("unexpected apply text: %q", stdout.String())
|
||||
}
|
||||
if gotOptions.CurrentVersion != "dev" {
|
||||
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Applied bool `json:"applied"`
|
||||
InstallMethod string `json:"installMethod"`
|
||||
BinaryPath string `json:"binaryPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
|
||||
t.Fatalf("unexpected apply JSON: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpgradeDefaultsToApply(t *testing.T) {
|
||||
var applyCalled bool
|
||||
deps := appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
applyCalled = true
|
||||
return update.ApplyResult{Message: "already up to date"}, nil
|
||||
},
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run for `zero upgrade`")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !applyCalled {
|
||||
t.Fatal("expected `zero upgrade` to call applyUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
t.Fatal("applyUpdate should not run")
|
||||
return update.ApplyResult{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
|
||||
t.Fatalf("expected --check/--apply usage error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"update", "--apply", "--target", "linux-arm64"},
|
||||
{"upgrade", "--target", "linux-arm64"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
t.Fatal("applyUpdate should not run when --target is combined with --apply")
|
||||
return update.ApplyResult{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
|
||||
t.Fatalf("expected --target/--apply usage error, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateReportsApplyError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
return update.ApplyResult{}, errors.New("download failed")
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "download failed") {
|
||||
t.Fatalf("expected apply error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOutput(t *testing.T, args []string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+19
-3
@@ -301,12 +301,27 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
|
||||
// auth/callback) and mandatory authorize params (id_token_add_organizations,
|
||||
// codex_cli_simplified_flow, originator) that the generic loopback flow
|
||||
// cannot supply. Route it to the dedicated ChatGPT login so
|
||||
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
|
||||
if strings.EqualFold(provider, "chatgpt") {
|
||||
if parsed.device {
|
||||
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
|
||||
}
|
||||
if len(parsed.scopes) > 0 {
|
||||
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
|
||||
}
|
||||
return runAuthChatGPT(nil, stdout, stderr, deps)
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
||||
Provider: parsed.positional[0],
|
||||
Provider: provider,
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
@@ -489,8 +504,9 @@ Commands:
|
||||
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
||||
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
||||
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
||||
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
|
||||
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
|
||||
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
|
||||
@@ -156,3 +156,44 @@ func TestRunAuthHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
||||
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
||||
// authorize params), not the generic manager path. The generic login accepts
|
||||
// --device, so a ChatGPT-specific rejection proves the routing took effect.
|
||||
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
|
||||
// without the required extra params, so OpenAI's authorize endpoint rejected it.
|
||||
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
|
||||
}
|
||||
// Case-insensitive provider name should still route.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login ChatGPT --device should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
|
||||
// must not be silently dropped on the ChatGPT path. The Codex client
|
||||
// registration pins a fixed scope set (incl. api.connectors.*), so custom
|
||||
// scopes are rejected up front rather than plumbed through.
|
||||
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --scope should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
||||
}
|
||||
}
|
||||
+26
-13
@@ -135,6 +135,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
|
||||
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
||||
// front — before tool-filter validation and the --list-tools branch — so a
|
||||
// mode-injected tool filter is validated and reflected in --list-tools, and a
|
||||
@@ -499,15 +504,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// by default the corrector is nil, leaving the agent loop byte-identical. When
|
||||
// on we verify with both the workspace test plan and LSP diagnostics over the
|
||||
// changed files; the autonomy gate inside the corrector still decides whether
|
||||
// failures auto-fix or just report. lspShutdown tears down any language-server
|
||||
// sessions the LSP half spawned (no-op when self-correct is off).
|
||||
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
||||
// failures auto-fix or just report. fileDiagnostics (always on, lazy) gives
|
||||
// edit_file/write_file inline error diagnostics for the file they just wrote.
|
||||
// lspShutdown tears down any language-server sessions either half spawned.
|
||||
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
||||
defer lspShutdown()
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
@@ -524,6 +531,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
@@ -699,23 +707,28 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// unless a changed file's language actually has one installed on PATH. The
|
||||
// returned cleanup shuts that manager down (terminating any spawned server
|
||||
// sessions); it is a no-op when self-correct is off.
|
||||
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
|
||||
if !enabled {
|
||||
return nil, func() {}
|
||||
}
|
||||
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
|
||||
// The manager is created regardless of --self-correct: it also backs the
|
||||
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), and it
|
||||
// stays lazy — no language server is spawned unless an edited file's
|
||||
// language actually has one installed on PATH.
|
||||
manager := lsp.NewManager(workspaceRoot)
|
||||
cleanup := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = manager.Shutdown(ctx)
|
||||
}
|
||||
fileDiagnostics := agent.NewFileDiagnostics(manager)
|
||||
if !enabled {
|
||||
return nil, fileDiagnostics, cleanup
|
||||
}
|
||||
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
|
||||
Enabled: true,
|
||||
IncludeTests: true,
|
||||
IncludeLSP: true,
|
||||
Autonomy: autonomy,
|
||||
})
|
||||
cleanup := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = manager.Shutdown(ctx)
|
||||
}
|
||||
return corrector, cleanup
|
||||
return corrector, fileDiagnostics, cleanup
|
||||
}
|
||||
|
||||
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
||||
|
||||
@@ -3,7 +3,9 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -57,6 +59,27 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
||||
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
|
||||
}
|
||||
|
||||
// skillInfos resolves the reusable skills the model can load via the skill tool —
|
||||
// the default skills dir merged with any plugin skill roots, the same set the
|
||||
// plugin-aware skill tool resolves against — as plain data for the agent's system
|
||||
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
|
||||
// the prompt byte-identical.
|
||||
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
|
||||
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
|
||||
if len(merged) == 0 {
|
||||
return nil
|
||||
}
|
||||
infos := make([]agent.SkillInfo, 0, len(merged))
|
||||
for _, skill := range merged {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
|
||||
// line, prefixing the diagnostic kind and appending the most specific locator
|
||||
// available (manifest path, then plugin path, then plugin ID) so an operator can
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -453,6 +454,11 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
|
||||
case "localhost", "127.0.0.1", "::1":
|
||||
return true
|
||||
}
|
||||
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
|
||||
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
|
||||
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+51
-8
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type updateOptions struct {
|
||||
check bool
|
||||
apply bool
|
||||
json bool
|
||||
repository string
|
||||
endpoint string
|
||||
@@ -20,6 +21,18 @@ type updateOptions struct {
|
||||
}
|
||||
|
||||
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
return runUpdateCommand(args, stdout, stderr, deps, false)
|
||||
}
|
||||
|
||||
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
return runUpdateCommand(args, stdout, stderr, deps, true)
|
||||
}
|
||||
|
||||
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
|
||||
// makes `--apply` the implicit behavior for `zero upgrade` when neither
|
||||
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
|
||||
// one of the two so existing scripts around `zero update --check` don't change.
|
||||
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
|
||||
options, help, err := parseUpdateArgs(args)
|
||||
if err != nil {
|
||||
return writeUsageError(stderr, err.Error())
|
||||
@@ -30,10 +43,19 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if !options.check {
|
||||
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
|
||||
if options.check && options.apply {
|
||||
return writeUsageError(stderr, "Pass only one of --check or --apply.")
|
||||
}
|
||||
checkOptions := update.Options{
|
||||
if !options.check && !options.apply {
|
||||
if !defaultApply {
|
||||
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
|
||||
}
|
||||
options.apply = true
|
||||
}
|
||||
if options.apply && options.target != "" {
|
||||
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
|
||||
}
|
||||
updateOptions := update.Options{
|
||||
CurrentVersion: version,
|
||||
Repository: options.repository,
|
||||
Endpoint: options.endpoint,
|
||||
@@ -44,10 +66,26 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
if err != nil {
|
||||
return writeUsageError(stderr, err.Error())
|
||||
}
|
||||
checkOptions.GOOS = target.GOOS
|
||||
checkOptions.GOARCH = target.GOARCH
|
||||
updateOptions.GOOS = target.GOOS
|
||||
updateOptions.GOARCH = target.GOARCH
|
||||
}
|
||||
result, err := deps.checkUpdate(context.Background(), checkOptions)
|
||||
if options.apply {
|
||||
result, err := deps.applyUpdate(context.Background(), updateOptions)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
result, err := deps.checkUpdate(context.Background(), updateOptions)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
|
||||
}
|
||||
@@ -72,6 +110,8 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
|
||||
return options, true, nil
|
||||
case arg == "--check":
|
||||
options.check = true
|
||||
case arg == "--apply":
|
||||
options.apply = true
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--repo":
|
||||
@@ -155,14 +195,17 @@ func parseUpdateTarget(value string) (string, error) {
|
||||
func writeUpdateHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero update --check [flags]
|
||||
zero update --apply [flags]
|
||||
zero upgrade [flags]
|
||||
|
||||
Flags:
|
||||
--check Check the latest GitHub release without installing
|
||||
--json Print the update check result as JSON
|
||||
--apply Download, verify, and install the latest release
|
||||
--json Print the update result as JSON
|
||||
--repo <owner/repo> Repository to check when no endpoint is provided
|
||||
--endpoint <url|repo> Release API URL or owner/repo slug to check
|
||||
--timeout <duration> Release check timeout (default 5s)
|
||||
--target <platform> Release target to verify (for example windows-x64)
|
||||
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
|
||||
@@ -662,3 +662,144 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangesArgsAuto(t *testing.T) {
|
||||
// 1. Correct parsing of auto
|
||||
for _, args := range [][]string{
|
||||
{"--auto"},
|
||||
{"-a"},
|
||||
} {
|
||||
options, help, err := parseChangesArgs(args, "commit")
|
||||
if err != nil {
|
||||
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
|
||||
}
|
||||
if help {
|
||||
t.Fatalf("parseChangesArgs(%v) returned help", args)
|
||||
}
|
||||
if !options.auto {
|
||||
t.Fatalf("auto = false, want true (args %v)", args)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Reject --auto on inspect
|
||||
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
|
||||
if err == nil || !strings.Contains(err.Error(), "--auto") {
|
||||
t.Fatalf("expected --auto rejection on inspect, got %v", err)
|
||||
}
|
||||
|
||||
// 3. Reject both --message and --auto on commit
|
||||
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
||||
t.Fatalf("expected --message and --auto conflict error, got %v", err)
|
||||
}
|
||||
|
||||
// 4. Reject both empty message and --auto on commit
|
||||
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
||||
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type mockCommitMsgProvider struct {
|
||||
response string
|
||||
req zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
p.req = request
|
||||
events := make(chan zeroruntime.StreamEvent, 2)
|
||||
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
|
||||
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(events)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func TestRunChangesCommitAuto(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
summary := zerogit.ChangeSummary{
|
||||
Root: cwd,
|
||||
Branch: "main",
|
||||
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
|
||||
Diff: "some diff content with ghp_SECRETKEYHERE",
|
||||
}
|
||||
|
||||
mockProv := &mockCommitMsgProvider{
|
||||
response: "```\nfeat: auto commit message\n```",
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
commitCalled := false
|
||||
|
||||
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
||||
return summary, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.Name = "openai"
|
||||
cfg.Provider.Model = "gpt-4o"
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return mockProv, nil
|
||||
},
|
||||
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
|
||||
commitCalled = true
|
||||
if options.Message != "feat: auto commit message" {
|
||||
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
|
||||
}
|
||||
return zerogit.CommitResult{
|
||||
Root: cwd,
|
||||
Message: options.Message,
|
||||
Committed: true,
|
||||
CommitHash: "abc1234",
|
||||
Before: summary,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !commitCalled {
|
||||
t.Fatal("expected commitChanges to be called")
|
||||
}
|
||||
// Verify that secret in the diff was redacted
|
||||
promptContent := mockProv.req.Messages[0].Content
|
||||
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
|
||||
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
|
||||
}
|
||||
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
|
||||
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
|
||||
t.Fatalf("expected status message in stdout, got %q", stdout.String())
|
||||
}
|
||||
|
||||
t.Run("EmptyLLMResponse", func(t *testing.T) {
|
||||
mockProvEmpty := &mockCommitMsgProvider{
|
||||
response: " \n\n ",
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
||||
return summary, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return mockProvEmpty, nil
|
||||
},
|
||||
})
|
||||
if code == exitSuccess {
|
||||
t.Fatal("expected command to fail when LLM returns empty response, got success")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "empty commit message") {
|
||||
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/selfverify"
|
||||
"github.com/Gitlawb/zero/internal/testrunner"
|
||||
"github.com/Gitlawb/zero/internal/verify"
|
||||
"github.com/Gitlawb/zero/internal/worktrees"
|
||||
"github.com/Gitlawb/zero/internal/zerogit"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type worktreeCommandOptions struct {
|
||||
@@ -35,8 +38,10 @@ type changesCommandOptions struct {
|
||||
cwd string
|
||||
baseRef string
|
||||
message string
|
||||
hasMessage bool
|
||||
dryRun bool
|
||||
maxDiffBytes int
|
||||
auto bool
|
||||
}
|
||||
|
||||
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
@@ -198,9 +203,52 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
return exitSuccess
|
||||
case "commit":
|
||||
var message string
|
||||
if options.auto {
|
||||
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
|
||||
Cwd: workspaceRoot,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
|
||||
}
|
||||
if summary.Clean {
|
||||
return writeExecUsageError(stderr, "no changes to commit")
|
||||
}
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
|
||||
}
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
|
||||
}
|
||||
provider, err := deps.newProvider(resolved.Provider)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
|
||||
}
|
||||
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
safeSummary := redactChangeSummary(summary)
|
||||
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
|
||||
}
|
||||
message = msg
|
||||
} else {
|
||||
message = options.message
|
||||
}
|
||||
|
||||
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Message: options.message,
|
||||
Message: message,
|
||||
DryRun: options.dryRun,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
})
|
||||
@@ -395,9 +443,11 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.message = value
|
||||
options.hasMessage = true
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--message="):
|
||||
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
|
||||
options.hasMessage = true
|
||||
case arg == "--diff-bytes":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -415,14 +465,19 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.maxDiffBytes = maxDiffBytes
|
||||
case arg == "-a" || arg == "--auto":
|
||||
options.auto = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && (options.message != "" || options.dryRun) {
|
||||
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
|
||||
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||
}
|
||||
if command == "commit" && options.hasMessage && options.auto {
|
||||
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||
}
|
||||
if command == "commit" && options.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
@@ -712,9 +767,47 @@ Flags:
|
||||
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
||||
--diff-bytes <n> Maximum diff bytes to include
|
||||
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
||||
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||
var promptBuilder strings.Builder
|
||||
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
|
||||
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
|
||||
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
|
||||
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
|
||||
promptBuilder.WriteString("Git Diff:\n")
|
||||
promptBuilder.WriteString(summary.Diff)
|
||||
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
|
||||
},
|
||||
}
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
collected := zeroruntime.CollectStream(ctx, stream)
|
||||
if collected.Error != "" {
|
||||
return "", fmt.Errorf("%s", collected.Error)
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(collected.Text)
|
||||
if strings.HasPrefix(msg, "```") {
|
||||
if idx := strings.Index(msg, "\n"); idx != -1 {
|
||||
msg = msg[idx+1:]
|
||||
}
|
||||
}
|
||||
msg = strings.TrimSuffix(msg, "```")
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return "", fmt.Errorf("provider returned empty commit message")
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
@@ -130,7 +130,11 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
|
||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
// On ErrNoActiveProvider, providers may still hold the successfully
|
||||
// normalized (but active-less) profile list — keep it so a caller can fall
|
||||
// back to an already-configured usable provider instead of treating this
|
||||
// like a config with nothing set up at all.
|
||||
return ResolvedConfig{Providers: providers}, err
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
@@ -144,6 +148,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
}, nil
|
||||
}
|
||||
@@ -225,6 +230,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -277,6 +283,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
// Local control is intentionally user-config/override only. A cloned project
|
||||
// must not be able to make browser, desktop, or terminal automation tools
|
||||
// appear in the model's tool surface.
|
||||
@@ -583,9 +590,17 @@ func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile
|
||||
if profile.Name == "" {
|
||||
profile.Name = string(providerKind)
|
||||
}
|
||||
// A non-official baseURL from the environment signals a proxy/gateway, so promote
|
||||
// the first-party kind to its -compatible transport (which accepts a custom URL).
|
||||
// Env-only by design: a custom baseURL in a project config.json is left as-is and
|
||||
// rejected by Resolve, so an untrusted project can't redirect a first-party key to
|
||||
// another host.
|
||||
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
|
||||
profile.ProviderKind = ProviderKindOpenAICompatible
|
||||
}
|
||||
if providerKind == ProviderKindAnthropic && baseURL != "" && !isOfficialAnthropicBaseURL(baseURL) {
|
||||
profile.ProviderKind = ProviderKindAnthropicCompat
|
||||
}
|
||||
// When the env supplies only credentials (no baseURL) for a provider name that
|
||||
// already exists, don't force the standard transport kind — a same-named
|
||||
// openai-compatible/anthropic-compatible proxy or gateway must keep its kind.
|
||||
@@ -665,6 +680,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
cfg.Tools.deferThresholdSet = true
|
||||
}
|
||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
@@ -700,6 +716,24 @@ func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalContr
|
||||
}
|
||||
}
|
||||
|
||||
func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
|
||||
if src.ToggleDetailed != "" {
|
||||
dst.ToggleDetailed = src.ToggleDetailed
|
||||
}
|
||||
if src.ToggleMouse != "" {
|
||||
dst.ToggleMouse = src.ToggleMouse
|
||||
}
|
||||
if src.CycleReasoning != "" {
|
||||
dst.CycleReasoning = src.CycleReasoning
|
||||
}
|
||||
if src.TogglePlan != "" {
|
||||
dst.TogglePlan = src.TogglePlan
|
||||
}
|
||||
if src.ToggleSidebar != "" {
|
||||
dst.ToggleSidebar = src.ToggleSidebar
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
@@ -834,7 +868,11 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
|
||||
if !activeFound {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
// Return the successfully normalized list alongside the error (rather than
|
||||
// nil) so a caller like the interactive TUI can still fall back to an
|
||||
// already-configured, usable provider instead of forcing a full
|
||||
// re-onboarding wizard just because none was marked active.
|
||||
return normalized, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
}
|
||||
if active.Model == "" {
|
||||
return nil, ProviderProfile{}, &setupFixableError{
|
||||
|
||||
@@ -617,6 +617,35 @@ func TestResolveUsesOpenAIEnvFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesAnthropicEnvBaseURLAsCompatible(t *testing.T) {
|
||||
// ANTHROPIC_BASE_URL pointing at a proxy/gateway must resolve to an
|
||||
// anthropic-compatible provider (mirroring OPENAI_BASE_URL), not the "requires
|
||||
// official baseURL" rejection — issue #479. The env is user-controlled, so a
|
||||
// custom URL there is a deliberate gateway choice, unlike a project config.json.
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
Env: map[string]string{
|
||||
"ANTHROPIC_API_KEY": "sk-ant-env",
|
||||
"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic",
|
||||
"ANTHROPIC_MODEL": "claude-custom",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.ActiveProvider != "anthropic" {
|
||||
t.Fatalf("ActiveProvider = %q, want anthropic", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
|
||||
t.Fatalf("ProviderKind = %q, want anthropic-compatible", resolved.Provider.ProviderKind)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://gateway.example/anthropic" {
|
||||
t.Fatalf("BaseURL = %q, want gateway URL", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-custom" {
|
||||
t.Fatalf("Provider = %#v, want env credentials/model", resolved.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesOpenAIAPIKeyOnlyWithDefaultModel(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
Env: map[string]string{
|
||||
@@ -916,6 +945,25 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
// Multiple providers configured (e.g. via `zero provider add`) but
|
||||
// activeProvider is blank/stale — a caller like the interactive TUI still
|
||||
// needs the normalized list to fall back to an already-usable provider
|
||||
// instead of forcing a full re-onboarding wizard.
|
||||
path := writeConfig(t, `{"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
|
||||
}
|
||||
if len(resolved.Providers) != 2 {
|
||||
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
@@ -1059,6 +1107,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",
|
||||
|
||||
@@ -112,6 +112,25 @@ func (p PreferencesConfig) RecapsEnabled() bool {
|
||||
return p.Recaps == nil || *p.Recaps
|
||||
}
|
||||
|
||||
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
|
||||
// can remap. An empty string means "use the built-in default" for that action.
|
||||
type KeyBindingDef string
|
||||
|
||||
// KeyBindingsConfig holds the subset of TUI keybindings that users may remap
|
||||
// via config.json. Each field defaults to a sensible built-in when empty.
|
||||
type KeyBindingsConfig struct {
|
||||
// ToggleDetailed toggles the detailed transcript view (default: ctrl+o).
|
||||
ToggleDetailed KeyBindingDef `json:"toggleDetailed,omitempty"`
|
||||
// ToggleMouse toggles mouse capture release (default: ctrl+e).
|
||||
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
|
||||
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
|
||||
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
|
||||
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
|
||||
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
|
||||
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
}
|
||||
|
||||
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
||||
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
||||
// background probe is required during startup.
|
||||
@@ -206,6 +225,7 @@ type FileConfig struct {
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
|
||||
@@ -220,6 +240,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
@@ -232,6 +253,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
}
|
||||
if !cfg.LocalControl.Empty() {
|
||||
raw.LocalControl = &cfg.LocalControl
|
||||
@@ -256,6 +278,7 @@ type Overrides struct {
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
@@ -270,6 +293,7 @@ type ResolvedConfig struct {
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
@@ -316,6 +340,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
@@ -342,6 +367,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Tools = raw.Tools
|
||||
cfg.Swarm = raw.Swarm
|
||||
cfg.Preferences = raw.Preferences
|
||||
cfg.KeyBindings = raw.KeyBindings
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
|
||||
@@ -131,29 +131,45 @@ func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
|
||||
// lspServersCheck reports which language servers ZERO would use are present on
|
||||
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
|
||||
// those languages — so the worst status is WARN, and each missing server gets an
|
||||
// actionable install command keyed by its binary name.
|
||||
// actionable install command keyed by its binary name. Only the tier-1 servers
|
||||
// (lsp.CoreServerBinaries) drive the status: the long-tail servers configured
|
||||
// for breadth are listed informationally under missingOptional, because warning
|
||||
// about a missing zls on a machine with no Zig code would be permanent noise.
|
||||
func lspServersCheck(lookup func(string) (string, error)) Check {
|
||||
if lookup == nil {
|
||||
lookup = exec.LookPath
|
||||
}
|
||||
core := map[string]bool{}
|
||||
for _, binary := range lsp.CoreServerBinaries() {
|
||||
core[binary] = true
|
||||
}
|
||||
present := map[string]any{}
|
||||
missing := map[string]any{}
|
||||
missingOptional := map[string]any{}
|
||||
for _, binary := range lsp.ServerBinaries() {
|
||||
if _, err := lookup(binary); err == nil {
|
||||
present[binary] = "on PATH"
|
||||
continue
|
||||
}
|
||||
missing[binary] = lspRemedy(binary)
|
||||
if core[binary] {
|
||||
missing[binary] = lspRemedy(binary)
|
||||
} else {
|
||||
missingOptional[binary] = lspRemedy(binary)
|
||||
}
|
||||
}
|
||||
details := map[string]any{"present": present}
|
||||
if len(missingOptional) > 0 {
|
||||
details["missingOptional"] = missingOptional
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return check("lsp.servers", "LSP servers", StatusPass, "All known language servers are available on PATH.", map[string]any{
|
||||
"present": present,
|
||||
})
|
||||
message := "All core language servers are available on PATH."
|
||||
if len(missingOptional) > 0 {
|
||||
message = fmt.Sprintf("All core language servers are available on PATH (%d optional server(s) not installed).", len(missingOptional))
|
||||
}
|
||||
return check("lsp.servers", "LSP servers", StatusPass, message, details)
|
||||
}
|
||||
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), map[string]any{
|
||||
"present": present,
|
||||
"missing": missing,
|
||||
})
|
||||
details["missing"] = missing
|
||||
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), details)
|
||||
}
|
||||
|
||||
// lspRemedy returns an actionable install command for a missing language-server
|
||||
|
||||
+123
-15
@@ -9,26 +9,134 @@ import (
|
||||
|
||||
// serverCommands maps a file extension to the language-server command (argv) ZERO
|
||||
// will spawn. The first element is the binary looked up on PATH; missing binaries
|
||||
// are not an error, the agent just degrades to text-only for that file.
|
||||
// are not an error, the agent just degrades to text-only for that file. Every
|
||||
// command is the language's community-standard server invoked in stdio mode, so
|
||||
// a wrong guess can never spawn something surprising — only fail the PATH check.
|
||||
var serverCommands = map[string][]string{
|
||||
".go": {"gopls", "serve"},
|
||||
".ts": {"typescript-language-server", "--stdio"},
|
||||
".tsx": {"typescript-language-server", "--stdio"},
|
||||
".js": {"typescript-language-server", "--stdio"},
|
||||
".jsx": {"typescript-language-server", "--stdio"},
|
||||
".py": {"pyright-langserver", "--stdio"},
|
||||
".rs": {"rust-analyzer"},
|
||||
".go": {"gopls", "serve"},
|
||||
".ts": {"typescript-language-server", "--stdio"},
|
||||
".tsx": {"typescript-language-server", "--stdio"},
|
||||
".mts": {"typescript-language-server", "--stdio"},
|
||||
".cts": {"typescript-language-server", "--stdio"},
|
||||
".js": {"typescript-language-server", "--stdio"},
|
||||
".jsx": {"typescript-language-server", "--stdio"},
|
||||
".mjs": {"typescript-language-server", "--stdio"},
|
||||
".cjs": {"typescript-language-server", "--stdio"},
|
||||
".py": {"pyright-langserver", "--stdio"},
|
||||
".pyi": {"pyright-langserver", "--stdio"},
|
||||
".rs": {"rust-analyzer"},
|
||||
".c": {"clangd"},
|
||||
".h": {"clangd"},
|
||||
".cpp": {"clangd"},
|
||||
".cc": {"clangd"},
|
||||
".cxx": {"clangd"},
|
||||
".hpp": {"clangd"},
|
||||
".java": {"jdtls"},
|
||||
".kt": {"kotlin-language-server"},
|
||||
".kts": {"kotlin-language-server"},
|
||||
".rb": {"ruby-lsp"},
|
||||
".php": {"intelephense", "--stdio"},
|
||||
".zig": {"zls"},
|
||||
".lua": {"lua-language-server"},
|
||||
".ex": {"elixir-ls"},
|
||||
".exs": {"elixir-ls"},
|
||||
".hs": {"haskell-language-server-wrapper", "--lsp"},
|
||||
".swift": {"sourcekit-lsp"},
|
||||
".ml": {"ocamllsp"},
|
||||
".mli": {"ocamllsp"},
|
||||
".scala": {"metals"},
|
||||
".clj": {"clojure-lsp"},
|
||||
".cljs": {"clojure-lsp"},
|
||||
".cljc": {"clojure-lsp"},
|
||||
".dart": {"dart", "language-server"},
|
||||
".gleam": {"gleam", "lsp"},
|
||||
".nix": {"nixd"},
|
||||
".tf": {"terraform-ls", "serve"},
|
||||
".sh": {"bash-language-server", "start"},
|
||||
".bash": {"bash-language-server", "start"},
|
||||
".yaml": {"yaml-language-server", "--stdio"},
|
||||
".yml": {"yaml-language-server", "--stdio"},
|
||||
".json": {"vscode-json-language-server", "--stdio"},
|
||||
".css": {"vscode-css-language-server", "--stdio"},
|
||||
".scss": {"vscode-css-language-server", "--stdio"},
|
||||
".less": {"vscode-css-language-server", "--stdio"},
|
||||
".html": {"vscode-html-language-server", "--stdio"},
|
||||
".svelte": {"svelteserver", "--stdio"},
|
||||
".vue": {"vue-language-server", "--stdio"},
|
||||
".astro": {"astro-ls", "--stdio"},
|
||||
}
|
||||
|
||||
// languageIDs maps a file extension to the LSP languageId used in didOpen.
|
||||
var languageIDs = map[string]string{
|
||||
".go": "go",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".py": "python",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".rs": "rust",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".cc": "cpp",
|
||||
".cxx": "cpp",
|
||||
".hpp": "cpp",
|
||||
".java": "java",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".zig": "zig",
|
||||
".lua": "lua",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".hs": "haskell",
|
||||
".swift": "swift",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".scala": "scala",
|
||||
".clj": "clojure",
|
||||
".cljs": "clojure",
|
||||
".cljc": "clojure",
|
||||
".dart": "dart",
|
||||
".gleam": "gleam",
|
||||
".nix": "nix",
|
||||
".tf": "terraform",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".json": "json",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".less": "less",
|
||||
".html": "html",
|
||||
".svelte": "svelte",
|
||||
".vue": "vue",
|
||||
".astro": "astro",
|
||||
}
|
||||
|
||||
// coreServerBinaries are the tier-1 servers for the languages agents hit most;
|
||||
// `zero doctor` treats their absence as warn-worthy. The long tail of servers
|
||||
// configured above for breadth is reported informationally only — warning about
|
||||
// a missing zls on a machine with no Zig code would be permanent noise.
|
||||
var coreServerBinaries = []string{
|
||||
"gopls",
|
||||
"typescript-language-server",
|
||||
"pyright-langserver",
|
||||
"rust-analyzer",
|
||||
}
|
||||
|
||||
// CoreServerBinaries returns the tier-1 server binaries (sorted copy).
|
||||
func CoreServerBinaries() []string {
|
||||
binaries := append([]string(nil), coreServerBinaries...)
|
||||
sort.Strings(binaries)
|
||||
return binaries
|
||||
}
|
||||
|
||||
// ServerBinaries returns the unique set of language-server binaries ZERO may
|
||||
|
||||
@@ -63,6 +63,10 @@ func DefaultModelEntries() []ModelEntry {
|
||||
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
|
||||
}
|
||||
decorateModelDepth(entries)
|
||||
// Overlay volatile facts (context limits, base pricing) from a cached
|
||||
// models.dev snapshot when one is present and fresh — see modelsdev.go.
|
||||
// Identity fields (ids, aliases, patterns, deprecations) stay curated.
|
||||
entries = applyModelsDevOverrides(entries, cachedModelsDevProviders())
|
||||
return cloneModelEntries(entries)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Live models.dev overlay for the curated catalog. The hand-maintained
|
||||
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
|
||||
// match patterns, deprecations, escalation targets — but its VOLATILE facts
|
||||
// (context window, max output tokens, per-million pricing) go stale between
|
||||
// releases. When a cached snapshot of https://models.dev/api.json is present,
|
||||
// those fields are refreshed from it at registry construction; everything else
|
||||
// stays curated. The overlay never adds models (an auto-added entry would lack
|
||||
// aliases, match patterns, and provider wiring) and never touches the network
|
||||
// on the registry hot path — fetching happens only in the explicit background
|
||||
// refresh, cached to disk with a TTL.
|
||||
|
||||
const (
|
||||
modelsDevDefaultURL = "https://models.dev/api.json"
|
||||
// modelsDevRefreshAfter is how old the cache may get before a background
|
||||
// refresh re-fetches it.
|
||||
modelsDevRefreshAfter = 24 * time.Hour
|
||||
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
|
||||
// this the curated catalog (updated with the binary) is likely fresher than
|
||||
// the snapshot, so a stale file is ignored rather than trusted.
|
||||
modelsDevMaxAge = 7 * 24 * time.Hour
|
||||
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
|
||||
modelsDevFetchWindow = 15 * time.Second
|
||||
)
|
||||
|
||||
// modelsDevModel is the subset of a models.dev model record the overlay uses.
|
||||
type modelsDevModel struct {
|
||||
Limit struct {
|
||||
Context int `json:"context"`
|
||||
Output int `json:"output"`
|
||||
} `json:"limit"`
|
||||
Cost struct {
|
||||
Input float64 `json:"input"`
|
||||
Output float64 `json:"output"`
|
||||
CacheRead float64 `json:"cache_read"`
|
||||
CacheWrite float64 `json:"cache_write"`
|
||||
} `json:"cost"`
|
||||
}
|
||||
|
||||
// modelsDevProvider matches one provider object in api.json.
|
||||
type modelsDevProvider struct {
|
||||
Models map[string]modelsDevModel `json:"models"`
|
||||
}
|
||||
|
||||
// parseModelsDev decodes an api.json document into provider-slug -> api-model
|
||||
// -> record. api.json is a top-level object keyed by provider id.
|
||||
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
|
||||
var doc map[string]modelsDevProvider
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
|
||||
}
|
||||
providers := make(map[string]map[string]modelsDevModel, len(doc))
|
||||
for slug, provider := range doc {
|
||||
if len(provider.Models) == 0 {
|
||||
continue
|
||||
}
|
||||
providers[slug] = provider.Models
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
|
||||
// worth checking. Only first-party slugs are used: router entries on models.dev
|
||||
// carry generic or stale numbers.
|
||||
func modelsDevSlugs(kind ProviderKind) []string {
|
||||
switch kind {
|
||||
case ProviderAnthropic:
|
||||
return []string{"anthropic"}
|
||||
case ProviderOpenAI:
|
||||
return []string{"openai"}
|
||||
case ProviderGoogle:
|
||||
return []string{"google", "google-vertex"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// applyModelsDevOverrides refreshes each curated entry's context limits and
|
||||
// base pricing from the snapshot, when the snapshot knows the model. Tiered
|
||||
// pricing is never touched: models.dev has no tier data, and mixing a live
|
||||
// base rate with curated tiers would misprice the tier boundaries.
|
||||
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
|
||||
if len(providers) == 0 {
|
||||
return entries
|
||||
}
|
||||
for i := range entries {
|
||||
entry := &entries[i]
|
||||
var record modelsDevModel
|
||||
found := false
|
||||
for _, slug := range modelsDevSlugs(entry.Provider) {
|
||||
if models, ok := providers[slug]; ok {
|
||||
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
|
||||
record = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if record.Limit.Context > 0 {
|
||||
entry.ContextLimits.ContextWindow = record.Limit.Context
|
||||
}
|
||||
if record.Limit.Output > 0 {
|
||||
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
|
||||
}
|
||||
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
|
||||
entry.Cost.InputPerMillion = record.Cost.Input
|
||||
entry.Cost.OutputPerMillion = record.Cost.Output
|
||||
if record.Cost.CacheRead > 0 {
|
||||
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
|
||||
}
|
||||
if record.Cost.CacheWrite > 0 {
|
||||
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
|
||||
}
|
||||
entry.Cost.Source = "models.dev/api.json (cached)"
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
|
||||
// overrides it (used by tests and unusual setups).
|
||||
func modelsDevCachePath() (string, error) {
|
||||
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
|
||||
return override, nil
|
||||
}
|
||||
base, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(base, "zero", "modelsdev.json"), nil
|
||||
}
|
||||
|
||||
var (
|
||||
modelsDevOnce sync.Once
|
||||
modelsDevCached map[string]map[string]modelsDevModel
|
||||
modelsDevEnabled atomic.Bool
|
||||
)
|
||||
|
||||
// EnableModelsDevOverlay opts the process into applying the cached models.dev
|
||||
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
|
||||
// consumers and tests that never do get the curated catalog byte-identical to
|
||||
// before, so hermetic tests can't be perturbed by a cache file on the machine.
|
||||
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
|
||||
func EnableModelsDevOverlay() {
|
||||
modelsDevEnabled.Store(true)
|
||||
}
|
||||
|
||||
// cachedModelsDevProviders loads the cached snapshot once per process. Not
|
||||
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
|
||||
// the curated catalog is used untouched. Read once deliberately:
|
||||
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
|
||||
// re-stat the file every time; a background refresh benefits the NEXT process.
|
||||
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
|
||||
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||
return nil
|
||||
}
|
||||
modelsDevOnce.Do(func() {
|
||||
path, err := modelsDevCachePath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
providers, err := parseModelsDev(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
modelsDevCached = providers
|
||||
})
|
||||
return modelsDevCached
|
||||
}
|
||||
|
||||
// resetModelsDevCacheForTest clears the process-level cache memoization and
|
||||
// disables the overlay.
|
||||
func resetModelsDevCacheForTest() {
|
||||
modelsDevOnce = sync.Once{}
|
||||
modelsDevCached = nil
|
||||
modelsDevEnabled.Store(false)
|
||||
}
|
||||
|
||||
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
|
||||
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
|
||||
// call fire-and-forget from startup (use a goroutine); it never affects the
|
||||
// current process's registry (see cachedModelsDevProviders). Disabled entirely
|
||||
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
|
||||
func RefreshModelsDevCache(ctx context.Context) error {
|
||||
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||
return nil
|
||||
}
|
||||
path, err := modelsDevCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
|
||||
return nil
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
|
||||
if url == "" {
|
||||
url = modelsDevDefaultURL
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate before persisting: a bad body must never clobber a good cache.
|
||||
if _, err := parseModelsDev(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
_ = os.Remove(temp.Name())
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
_ = os.Remove(temp.Name())
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sampleModelsDev = `{
|
||||
"anthropic": {
|
||||
"id": "anthropic",
|
||||
"models": {
|
||||
"claude-sonnet-4-5-20250929": {
|
||||
"limit": {"context": 1000000, "output": 64000},
|
||||
"cost": {"input": 3.5, "output": 17.5, "cache_read": 0.35, "cache_write": 4.4}
|
||||
}
|
||||
}
|
||||
},
|
||||
"google": {
|
||||
"id": "google",
|
||||
"models": {
|
||||
"gemini-2.5-pro": {
|
||||
"limit": {"context": 2097152, "output": 65536},
|
||||
"cost": {"input": 9.99, "output": 9.99}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
func TestParseModelsDev(t *testing.T) {
|
||||
providers, err := parseModelsDev([]byte(sampleModelsDev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, ok := providers["anthropic"]["claude-sonnet-4-5-20250929"]
|
||||
if !ok {
|
||||
t.Fatal("expected anthropic sonnet record")
|
||||
}
|
||||
if record.Limit.Context != 1_000_000 || record.Cost.Input != 3.5 {
|
||||
t.Fatalf("unexpected record: %+v", record)
|
||||
}
|
||||
if _, err := parseModelsDev([]byte(`{}`)); err == nil {
|
||||
t.Fatal("empty document must be rejected")
|
||||
}
|
||||
if _, err := parseModelsDev([]byte(`not json`)); err == nil {
|
||||
t.Fatal("malformed document must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyModelsDevOverrides(t *testing.T) {
|
||||
// Point the cache at a non-existent file so DefaultModelEntries returns the
|
||||
// pure curated catalog, then apply the sample snapshot explicitly.
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "absent.json"))
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
|
||||
providers, err := parseModelsDev([]byte(sampleModelsDev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := applyModelsDevOverrides(DefaultModelEntries(), providers)
|
||||
|
||||
var sonnet, geminiPro, opus ModelEntry
|
||||
for _, entry := range entries {
|
||||
switch entry.ID {
|
||||
case "claude-sonnet-4.5":
|
||||
sonnet = entry
|
||||
case "gemini-2.5-pro":
|
||||
geminiPro = entry
|
||||
case "claude-opus-4.1":
|
||||
opus = entry
|
||||
}
|
||||
}
|
||||
|
||||
// Known model: limits and base pricing refreshed from the snapshot.
|
||||
if sonnet.ContextLimits.ContextWindow != 1_000_000 || sonnet.ContextLimits.MaxOutputTokens != 64_000 {
|
||||
t.Fatalf("sonnet limits not overridden: %+v", sonnet.ContextLimits)
|
||||
}
|
||||
if sonnet.Cost.InputPerMillion != 3.5 || sonnet.Cost.OutputPerMillion != 17.5 || sonnet.Cost.CacheWritePerMillion != 4.4 {
|
||||
t.Fatalf("sonnet cost not overridden: %+v", sonnet.Cost)
|
||||
}
|
||||
if sonnet.Cost.Source != "models.dev/api.json (cached)" {
|
||||
t.Fatalf("sonnet cost source not marked: %q", sonnet.Cost.Source)
|
||||
}
|
||||
|
||||
// Tiered pricing is curated: limits refresh, cost must NOT (gemini-2.5-pro
|
||||
// has curated tiers and the snapshot's flat 9.99 would misprice them).
|
||||
if geminiPro.ContextLimits.ContextWindow != 2_097_152 {
|
||||
t.Fatalf("gemini limits not overridden: %+v", geminiPro.ContextLimits)
|
||||
}
|
||||
if geminiPro.Cost.InputPerMillion == 9.99 || len(geminiPro.Cost.Tiers) == 0 {
|
||||
t.Fatalf("tiered cost must stay curated: %+v", geminiPro.Cost)
|
||||
}
|
||||
|
||||
// Model absent from the snapshot: untouched.
|
||||
if opus.ContextLimits.ContextWindow != 200_000 || opus.Cost.InputPerMillion != 15 {
|
||||
t.Fatalf("opus must be untouched: %+v %+v", opus.ContextLimits, opus.Cost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheFetchesAndCaches(t *testing.T) {
|
||||
hits := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_, _ = w.Write([]byte(sampleModelsDev))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("expected 1 fetch, got %d", hits)
|
||||
}
|
||||
if _, err := os.Stat(cachePath); err != nil {
|
||||
t.Fatalf("cache file missing: %v", err)
|
||||
}
|
||||
// Fresh cache: second call must not re-fetch.
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("fresh cache must skip the fetch, got %d hits", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("not json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := time.Now().Add(-48 * time.Hour)
|
||||
if err := os.Chtimes(cachePath, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
|
||||
if err := RefreshModelsDevCache(t.Context()); err == nil {
|
||||
t.Fatal("bad body must return an error")
|
||||
}
|
||||
content, err := os.ReadFile(cachePath)
|
||||
if err != nil || string(content) != sampleModelsDev {
|
||||
t.Fatal("bad fetch must not clobber the existing cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheDisabledByEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("fetch must not happen when disabled")
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "modelsdev.json"))
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := time.Now().Add(-modelsDevMaxAge - time.Hour)
|
||||
if err := os.Chtimes(cachePath, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
|
||||
if providers := cachedModelsDevProviders(); providers != nil {
|
||||
t.Fatal("stale cache must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
|
||||
// Without EnableModelsDevOverlay a fresh, valid cache must still be ignored:
|
||||
// library consumers and hermetic tests get the pure curated catalog.
|
||||
if providers := cachedModelsDevProviders(); providers != nil {
|
||||
t.Fatal("overlay must be opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
|
||||
for _, entry := range DefaultModelEntries() {
|
||||
if entry.ID == "claude-sonnet-4.5" {
|
||||
if entry.ContextLimits.ContextWindow != 1_000_000 {
|
||||
t.Fatalf("fresh cache must overlay the registry: %+v", entry.ContextLimits)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("claude-sonnet-4.5 not found")
|
||||
}
|
||||
@@ -114,7 +114,12 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry too — mirroring acquireFileLock in lock.go —
|
||||
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -169,8 +169,8 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
||||
if err := validateEndpoint(ctx, ep, r, true); err != nil {
|
||||
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
|
||||
}
|
||||
if !endpointIsLoopback(ep) {
|
||||
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
|
||||
if !endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,14 +179,27 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
||||
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
|
||||
}
|
||||
|
||||
// allowLoopback relaxes loopback ONLY — other special ranges (cloud metadata,
|
||||
// link-local, private) stay blocked even for a local provider config.
|
||||
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.5:8080/v1"} {
|
||||
// allowLoopbackOrPrivate relaxes loopback AND private-network ranges — but
|
||||
// link-local and other special ranges (cloud metadata, documentation) stay
|
||||
// blocked even for a local provider config.
|
||||
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://192.0.2.1:8080/v1"} {
|
||||
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
|
||||
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
|
||||
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
|
||||
}
|
||||
if endpointIsLoopback(ep) {
|
||||
t.Errorf("endpointIsLoopback(%q) = true, want false", ep)
|
||||
if endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = true, want false", ep)
|
||||
}
|
||||
}
|
||||
|
||||
// Private-network addresses (192.168.x.y / 10.x.y.z / 172.16.x.y) are
|
||||
// now allowed when the flag is true — the user is pointing at their own
|
||||
// LAN box (e.g. llama.cpp on another local machine).
|
||||
for _, ep := range []string{"http://192.168.1.100:8080/v1", "http://10.0.0.5:8080/v1", "http://172.16.0.10:8080/v1"} {
|
||||
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("192.168.1.100")}, true); err != nil {
|
||||
t.Errorf("private-network address %q should be allowed with flag=true, got %v", ep, err)
|
||||
}
|
||||
if !endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
|
||||
request, allowLoopbackOrPrivate, err := healthRequest(requestCtx, profile, kind, options)
|
||||
if err != nil {
|
||||
category := CategoryConfig
|
||||
var safety endpointSafetyError
|
||||
@@ -280,7 +280,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
}
|
||||
client := options.HTTPClient
|
||||
if client == nil {
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopback)
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
@@ -332,8 +332,8 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
||||
// Loopback is permitted only when the user's OWN configured base_url is loopback
|
||||
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
|
||||
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
|
||||
allowLoopback := endpointIsLoopback(endpoint)
|
||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
|
||||
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
|
||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
@@ -344,7 +344,7 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
||||
request.Header.Set("User-Agent", options.UserAgent)
|
||||
}
|
||||
applyAuth(request, profile, kind)
|
||||
return request, allowLoopback, nil
|
||||
return request, allowLoopbackOrPrivate, nil
|
||||
}
|
||||
|
||||
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
|
||||
@@ -367,11 +367,11 @@ func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (
|
||||
}
|
||||
|
||||
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
|
||||
// allowLoopback permits loopback hosts (localhost / 127.0.0.0/8 / ::1) ONLY — it is
|
||||
// set true exclusively for the user's own explicitly-configured local provider
|
||||
// base_url (e.g. Ollama/LM Studio), never for redirect targets, and it relaxes
|
||||
// loopback alone (other private/special ranges, incl. cloud metadata, stay blocked).
|
||||
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopback bool) error {
|
||||
// allowLoopbackOrPrivate permits loopback hosts (localhost / 127.0.0.0/8 / ::1)
|
||||
// and private-network hosts (192.168.0.0/16 / 10.0.0.0/8 / 172.16.0.0/12) — it
|
||||
// is set true exclusively for the user's own explicitly-configured base_url
|
||||
// (e.g. Ollama/LM Studio on the LAN), never for redirect targets.
|
||||
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopbackOrPrivate bool) error {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -385,13 +385,13 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
}
|
||||
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
|
||||
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
|
||||
if allowLoopback {
|
||||
if allowLoopbackOrPrivate {
|
||||
return nil // user-configured local provider; loopback is intentional
|
||||
}
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
|
||||
}
|
||||
if addr, err := netip.ParseAddr(normalized); err == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return nil
|
||||
@@ -407,17 +407,19 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// endpointIsLoopback reports whether the configured endpoint's host is a loopback
|
||||
// host the user typed themselves (localhost or a 127.0.0.0/8 / ::1 literal). Used to
|
||||
// decide whether the connectivity probe may reach the user's own local provider.
|
||||
func endpointIsLoopback(endpoint string) bool {
|
||||
// endpointIsLoopbackOrPrivate reports whether the configured endpoint's host is a loopback
|
||||
// or private-network host the user typed themselves — a local provider on the LAN
|
||||
// (e.g. 192.168.x.y / 10.x.y.z) or localhost. Used to decide whether the
|
||||
// connectivity probe may relax address-blocking and reach the user's own
|
||||
// explicitly-configured provider.
|
||||
func endpointIsLoopbackOrPrivate(endpoint string) bool {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -427,7 +429,7 @@ func endpointIsLoopback(endpoint string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -446,7 +448,7 @@ const maxConnectivityRedirects = 5
|
||||
// time and then dials the validated IP literal, closing the TOCTOU window
|
||||
// between the pre-flight validateEndpoint check and the actual connection
|
||||
// (DNS rebinding).
|
||||
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopback bool) *http.Client {
|
||||
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
|
||||
if resolver == nil {
|
||||
resolver = defaultResolver{}
|
||||
}
|
||||
@@ -456,7 +458,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
} else {
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopback)
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
@@ -476,8 +478,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
}
|
||||
}
|
||||
// Redirects are NEVER allowed to reach loopback, even for a local provider:
|
||||
// allowLoopback covers only the user's own configured base_url, not a 3xx
|
||||
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
||||
// allowLoopbackOrPrivate covers only the user's own configured base_url,
|
||||
// not a 3xx target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
||||
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
|
||||
},
|
||||
}
|
||||
@@ -519,7 +521,7 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
|
||||
// safeDialContext returns a dial function that resolves the target host, refuses
|
||||
// the connection if any resolved address is blocked, then dials the validated IP
|
||||
// literal so the kernel cannot re-resolve to a different address after the check.
|
||||
func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
|
||||
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
@@ -527,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
||||
return nil, err
|
||||
}
|
||||
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
@@ -540,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
||||
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// The final two messages must carry a cache_control breakpoint on their last
|
||||
// cacheable block so the conversation transcript — not just system prompt and
|
||||
// tools — is a prompt-cache hit on the next turn. Earlier messages must stay
|
||||
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
|
||||
// these two).
|
||||
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{BaseURL: server.URL + "/", Model: "claude-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "first"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "calling a tool",
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "grep", Arguments: `{}`}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "result", ToolCallID: "toolu_1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
messages := gotBody["messages"].([]any)
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 wire messages, got %d: %#v", len(messages), messages)
|
||||
}
|
||||
lastBlockHasCache := func(message any) bool {
|
||||
blocks, ok := message.(map[string]any)["content"].([]any)
|
||||
if !ok || len(blocks) == 0 {
|
||||
return false
|
||||
}
|
||||
last := blocks[len(blocks)-1].(map[string]any)
|
||||
control, ok := last["cache_control"].(map[string]any)
|
||||
return ok && control["type"] == "ephemeral"
|
||||
}
|
||||
if lastBlockHasCache(messages[0]) {
|
||||
t.Fatalf("first message must not carry a breakpoint: %#v", messages[0])
|
||||
}
|
||||
if !lastBlockHasCache(messages[1]) {
|
||||
t.Fatalf("second-to-last message must carry a breakpoint: %#v", messages[1])
|
||||
}
|
||||
if !lastBlockHasCache(messages[2]) {
|
||||
t.Fatalf("last message must carry a breakpoint: %#v", messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
// A string-content message must be converted to a block array so it can carry
|
||||
// the breakpoint, and thinking blocks must never carry cache_control (the API
|
||||
// rejects them) — the marker goes on the last cacheable block instead.
|
||||
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
|
||||
messages := []anthropicMessage{
|
||||
{Role: "user", Content: "plain string"},
|
||||
{Role: "assistant", Content: []map[string]any{
|
||||
{"type": "text", "text": "answer"},
|
||||
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
|
||||
}},
|
||||
}
|
||||
applyMessageCacheBreakpoints(messages)
|
||||
|
||||
userBlocks := contentBlocks(messages[0].Content)
|
||||
if len(userBlocks) != 1 || userBlocks[0]["cache_control"] == nil {
|
||||
t.Fatalf("string content must become a marked block: %#v", messages[0].Content)
|
||||
}
|
||||
assistantBlocks := contentBlocks(messages[1].Content)
|
||||
if assistantBlocks[1]["cache_control"] != nil {
|
||||
t.Fatalf("thinking block must not be marked: %#v", assistantBlocks[1])
|
||||
}
|
||||
if assistantBlocks[0]["cache_control"] == nil {
|
||||
t.Fatalf("text block must carry the breakpoint instead: %#v", assistantBlocks[0])
|
||||
}
|
||||
}
|
||||
@@ -405,9 +405,35 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
|
||||
}
|
||||
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
|
||||
}
|
||||
applyMessageCacheBreakpoints(mapped.Messages)
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
// applyMessageCacheBreakpoints marks the last content block of the final two
|
||||
// messages with cache_control so the conversation transcript is cached
|
||||
// turn-over-turn, not just the system prompt and tool definitions. Two message
|
||||
// breakpoints (not one) keep the previous turn's prefix a cache hit while the
|
||||
// newest suffix is being written. Anthropic allows at most 4 breakpoints per
|
||||
// request: system + tools use two above, these use the remaining two. Thinking
|
||||
// blocks cannot carry cache_control, so the marker goes on the last block that
|
||||
// can.
|
||||
func applyMessageCacheBreakpoints(messages []anthropicMessage) {
|
||||
marked := 0
|
||||
for i := len(messages) - 1; i >= 0 && marked < 2; i-- {
|
||||
blocks := contentBlocks(messages[i].Content)
|
||||
for j := len(blocks) - 1; j >= 0; j-- {
|
||||
blockType, _ := blocks[j]["type"].(string)
|
||||
if blockType == "thinking" || blockType == "redacted_thinking" {
|
||||
continue
|
||||
}
|
||||
blocks[j]["cache_control"] = map[string]any{"type": cacheEphemeral}
|
||||
messages[i].Content = blocks
|
||||
marked++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
|
||||
systemParts := []string{}
|
||||
mapped := []anthropicMessage{}
|
||||
|
||||
@@ -23,12 +23,18 @@ import (
|
||||
// (billable) work. Only the INITIAL request is ever in scope; once the response
|
||||
// body starts streaming it is never re-issued.
|
||||
|
||||
const defaultMaxRetryAttempts = 3
|
||||
const defaultMaxRetryAttempts = 6
|
||||
|
||||
// maxBackoff caps a single backoff wait so a hostile or buggy Retry-After can't
|
||||
// stall the agent for minutes.
|
||||
const maxBackoff = 30 * time.Second
|
||||
|
||||
// retryBackoffBase is the first wait when the server supplied no Retry-After.
|
||||
// Rate-limit windows are measured in seconds, not milliseconds: retrying a 429
|
||||
// after 400ms almost always burns the attempt while still limited, so the
|
||||
// schedule is 2s, 4s, 8s, 16s, then maxBackoff. A var so tests can shrink it.
|
||||
var retryBackoffBase = 2 * time.Second
|
||||
|
||||
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
|
||||
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
|
||||
// between tries and honoring a server Retry-After header and context
|
||||
@@ -110,17 +116,11 @@ func ShouldRetryStatus(code int) bool {
|
||||
}
|
||||
|
||||
// Backoff waits before retry attempt N (1-based), returning false if the context
|
||||
// is cancelled during the wait. The wait is attempt*400ms unless the server
|
||||
// supplied a (positive) Retry-After, and is capped at maxBackoff.
|
||||
// is cancelled during the wait. A server-supplied (positive) Retry-After wins;
|
||||
// otherwise the wait doubles from retryBackoffBase per attempt. Either way the
|
||||
// wait is capped at maxBackoff.
|
||||
func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
||||
wait := time.Duration(attempt) * 400 * time.Millisecond
|
||||
if retryAfter > 0 {
|
||||
wait = retryAfter
|
||||
}
|
||||
if wait > maxBackoff {
|
||||
wait = maxBackoff
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
timer := time.NewTimer(backoffWait(attempt, retryAfter))
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -130,6 +130,27 @@ func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// backoffWait computes the wait before retry attempt N (1-based): Retry-After
|
||||
// when supplied, else exponential from retryBackoffBase, both capped at
|
||||
// maxBackoff. The exponent is clamped so a large attempt count cannot overflow.
|
||||
func backoffWait(attempt int, retryAfter time.Duration) time.Duration {
|
||||
wait := retryAfter
|
||||
if wait <= 0 {
|
||||
exponent := attempt - 1
|
||||
if exponent > 5 {
|
||||
exponent = 5
|
||||
}
|
||||
if exponent < 0 {
|
||||
exponent = 0
|
||||
}
|
||||
wait = retryBackoffBase * time.Duration(1<<exponent)
|
||||
}
|
||||
if wait > maxBackoff {
|
||||
wait = maxBackoff
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// RetryAfter parses a response's Retry-After header (delay-seconds or an HTTP
|
||||
// date) into a positive duration, or 0 when absent/unparseable. The result is
|
||||
// capped at maxBackoff by Backoff.
|
||||
|
||||
@@ -94,7 +94,40 @@ func TestBackoffWaitsThenReturnsTrue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// shrinkBackoff makes retry waits negligible for the duration of a test.
|
||||
func shrinkBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
saved := retryBackoffBase
|
||||
retryBackoffBase = time.Millisecond
|
||||
t.Cleanup(func() { retryBackoffBase = saved })
|
||||
}
|
||||
|
||||
func TestBackoffWaitSchedule(t *testing.T) {
|
||||
// Without Retry-After the wait doubles per attempt from 2s and caps at 30s;
|
||||
// a supplied Retry-After wins but is capped too.
|
||||
cases := []struct {
|
||||
attempt int
|
||||
retryAfter time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{1, 0, 2 * time.Second},
|
||||
{2, 0, 4 * time.Second},
|
||||
{3, 0, 8 * time.Second},
|
||||
{4, 0, 16 * time.Second},
|
||||
{5, 0, 30 * time.Second}, // 32s capped
|
||||
{50, 0, 30 * time.Second}, // clamped exponent, no overflow
|
||||
{1, 7 * time.Second, 7 * time.Second},
|
||||
{1, 5 * time.Minute, 30 * time.Second}, // hostile Retry-After capped
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := backoffWait(c.attempt, c.retryAfter); got != c.want {
|
||||
t.Errorf("backoffWait(%d, %v) = %v, want %v", c.attempt, c.retryAfter, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithRetryRetriesThenSucceeds(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&hits, 1) == 1 {
|
||||
@@ -140,6 +173,7 @@ func TestSendWithRetryReturnsNonRetryableImmediately(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendWithRetryReturnsLastResponseAfterMaxAttempts(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,6 +72,9 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
|
||||
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
|
||||
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
|
||||
}
|
||||
// /tmp is a default temp write root on POSIX only (see
|
||||
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
|
||||
// against the current drive, so a stray C:\tmp must not turn this on.
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -129,6 +129,9 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
|
||||
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
|
||||
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
|
||||
}
|
||||
// /tmp is a default temp write root on POSIX only (see
|
||||
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
|
||||
// against the current drive, so a stray C:\tmp must not turn this on.
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
t.Fatalf("Roots()=%v want default /tmp write root", roots)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -87,11 +87,20 @@ func windowsCommandLine(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", errorsNewWindowsProcess("missing command")
|
||||
}
|
||||
out := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
if strings.ContainsRune(arg, 0) {
|
||||
return "", errorsNewWindowsProcess("command argument contains NUL")
|
||||
}
|
||||
}
|
||||
// Zero's cmd.exe shell invocation needs its final element (the shell
|
||||
// command text) reaching cmd.exe raw, not escaped like a normal argv
|
||||
// element: see WindowsShellCommandLine for why. Every other command this
|
||||
// runner might launch is escaped normally below.
|
||||
if commandLine, ok := windowsShellCommandLineFromArgs(args); ok {
|
||||
return commandLine, nil
|
||||
}
|
||||
out := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
out = append(out, syscall.EscapeArg(arg))
|
||||
}
|
||||
return strings.Join(out, " "), nil
|
||||
|
||||
@@ -95,10 +95,66 @@ 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"
|
||||
)
|
||||
|
||||
// WindowsShellArgs returns cmd.exe's argv (after the executable name) for
|
||||
// running commandText as a shell command on Windows: no wrapping quotes and
|
||||
// no /S, so commandText reaches cmd.exe's own /C remainder parsing exactly as
|
||||
// written. See WindowsShellCommandLine for why that matters. Used both as
|
||||
// CommandSpec.Args for the plain (unwrapped) path and, via
|
||||
// windowsShellCommandLineFromArgs, to recognize this same shape once it has
|
||||
// round-tripped through the sandboxed runner's CLI argv.
|
||||
func WindowsShellArgs(commandText string) []string {
|
||||
return []string{"/d", "/c", commandText}
|
||||
}
|
||||
|
||||
// WindowsShellCommandLine builds the raw command line a Windows CreateProcess
|
||||
// call should receive to run commandText as a shell command: "cmd.exe /d /c "
|
||||
// followed by commandText completely unescaped.
|
||||
//
|
||||
// cmd.exe's own /C remainder parsing is not CommandLineToArgvW-compatible: it
|
||||
// applies its own quote-stripping heuristic (documented in `cmd /?`) whenever
|
||||
// the remainder starts with a literal quote, and that heuristic does not
|
||||
// understand backslash-escaped inner quotes the way a normal argv consumer
|
||||
// would. A command like `python -c "print(15 / 3)"` passed through the
|
||||
// standard exec.Cmd Args mechanism gets wrapped in an outer pair of quotes
|
||||
// (because it contains spaces) with its own quotes escaped as \" — exactly
|
||||
// the shape that heuristic corrupts, stripping the outer quotes but leaving
|
||||
// the literal backslashes behind. Passing commandText through raw, exactly as
|
||||
// the model wrote it, means cmd.exe parses it the same way it would if typed
|
||||
// directly at an interactive prompt: a leading quoted executable path like
|
||||
// `"C:\Program Files\foo.exe" arg` is preserved correctly by cmd.exe's own
|
||||
// quote-preserving special case, and a command with no leading quote at all,
|
||||
// like the python example above, is never touched by the stripping heuristic
|
||||
// in the first place.
|
||||
func WindowsShellCommandLine(commandText string) string {
|
||||
return "cmd.exe /d /c " + commandText
|
||||
}
|
||||
|
||||
// windowsShellCommandLineFromArgs reports whether args is exactly
|
||||
// ["cmd.exe"]+WindowsShellArgs(text) for some text and, if so, returns
|
||||
// WindowsShellCommandLine's result for that text. The sandboxed runner path
|
||||
// only has the deserialized argv (WindowsSandboxCommandConfig.Command) to
|
||||
// work from, not the original commandText, so it recovers the raw command
|
||||
// line by recognizing the shape instead.
|
||||
func windowsShellCommandLineFromArgs(args []string) (string, bool) {
|
||||
if len(args) != 4 {
|
||||
return "", false
|
||||
}
|
||||
if !strings.EqualFold(args[0], "cmd.exe") || args[1] != "/d" || args[2] != "/c" {
|
||||
return "", false
|
||||
}
|
||||
return WindowsShellCommandLine(args[3]), true
|
||||
}
|
||||
|
||||
type WindowsSandboxCommandArgsOptions struct {
|
||||
SandboxHome string
|
||||
CommandCWD string
|
||||
@@ -271,13 +327,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 +444,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
|
||||
|
||||
@@ -147,3 +147,56 @@ func TestRunWindowsSandboxCommandRunnerRejectsInvalidArgs(t *testing.T) {
|
||||
t.Fatalf("stderr = %q, want runner name", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsShellArgsHasNoSAndKeepsCommandTextWhole(t *testing.T) {
|
||||
args := WindowsShellArgs(`python -c "print(15 / 3)"`)
|
||||
want := []string{"/d", "/c", `python -c "print(15 / 3)"`}
|
||||
if len(args) != len(want) {
|
||||
t.Fatalf("args = %#v, want %#v", args, want)
|
||||
}
|
||||
for index := range want {
|
||||
if args[index] != want[index] {
|
||||
t.Fatalf("args = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsShellCommandLineLeavesCommandTextUnescaped(t *testing.T) {
|
||||
// The whole point of this function: commandText's own quotes must reach
|
||||
// cmd.exe exactly as written, not re-quoted or backslash-escaped the way
|
||||
// a normal argv-escaping function (like syscall.EscapeArg) would treat an
|
||||
// argument containing spaces and embedded quotes.
|
||||
commandText := `python -c "print(15 / 3)"`
|
||||
got := WindowsShellCommandLine(commandText)
|
||||
want := `cmd.exe /d /c python -c "print(15 / 3)"`
|
||||
if got != want {
|
||||
t.Fatalf("WindowsShellCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsShellCommandLineFromArgsRecognizesTheShellShape(t *testing.T) {
|
||||
commandLine, ok := windowsShellCommandLineFromArgs([]string{"cmd.exe", "/d", "/c", `echo "hi"`})
|
||||
if !ok {
|
||||
t.Fatal("windowsShellCommandLineFromArgs did not recognize the shell shape")
|
||||
}
|
||||
if want := `cmd.exe /d /c echo "hi"`; commandLine != want {
|
||||
t.Fatalf("commandLine = %q, want %q", commandLine, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsShellCommandLineFromArgsRejectsOtherShapes(t *testing.T) {
|
||||
cases := [][]string{
|
||||
nil,
|
||||
{"cmd.exe"},
|
||||
{"cmd.exe", "/d", "/c"},
|
||||
{"cmd.exe", "/d", "/c", "echo hi", "extra"},
|
||||
{"git.exe", "/d", "/c", "echo hi"},
|
||||
{"cmd.exe", "/s", "/c", "echo hi"},
|
||||
{"cmd.exe", "/d", "/k", "echo hi"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
if _, ok := windowsShellCommandLineFromArgs(args); ok {
|
||||
t.Fatalf("windowsShellCommandLineFromArgs(%#v) matched, want no match", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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 or a corrupt/truncated body is likewise
|
||||
// treated as empty so the runner re-applies (and rewrites) rather than failing:
|
||||
// the marker only memoizes idempotent ACL applies, so resetting it can only
|
||||
// cost redundant work, never skip enforcement.
|
||||
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{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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 TestLoadWindowsUnelevatedSetupMarkerSelfHealsCorruptFile(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)
|
||||
// A truncated body must reset like an unknown schema, not brick every
|
||||
// unelevated command until the file is hand-deleted.
|
||||
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"appliedPlans":[{"aclPlanHash":"h`), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt marker: %v", err)
|
||||
}
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("corrupt marker must self-heal, got error: %v", err)
|
||||
}
|
||||
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
|
||||
t.Fatalf("corrupt marker must reset to empty current-schema marker, got %+v", marker)
|
||||
}
|
||||
// The healed marker must accept new recordings over the corrupt file.
|
||||
applied := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-heal", ACLPlanEntries: 2}
|
||||
if err := recordWindowsUnelevatedAppliedPlan(home, applied); err != nil {
|
||||
t.Fatalf("record over corrupt marker: %v", err)
|
||||
}
|
||||
marker, err = loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("reload healed marker: %v", err)
|
||||
}
|
||||
if !marker.contains(applied) {
|
||||
t.Fatalf("healed marker = %+v, want the recorded plan", marker)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -160,10 +160,7 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
|
||||
if prepared.Mode == ModeNew || len(prepared.ContextEvents) == 0 {
|
||||
return prompt
|
||||
}
|
||||
events := prepared.ContextEvents
|
||||
if len(events) > 20 {
|
||||
events = events[len(events)-20:]
|
||||
}
|
||||
events := promptContextEvents(prepared.ContextEvents)
|
||||
|
||||
lines := []string{}
|
||||
for _, event := range events {
|
||||
@@ -187,6 +184,25 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func promptContextEvents(events []Event) []Event {
|
||||
const maxPromptContextEvents = 80
|
||||
|
||||
filtered := make([]Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case EventMessage, EventCompaction, EventSessionFork, EventSessionChild, EventSpecialistStart, EventSpecialistStop, EventError:
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
filtered = append(filtered, events...)
|
||||
}
|
||||
if len(filtered) > maxPromptContextEvents {
|
||||
filtered = filtered[len(filtered)-maxPromptContextEvents:]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func forkTitle(title string) string {
|
||||
if title == "" {
|
||||
return ""
|
||||
|
||||
@@ -71,7 +71,7 @@ type CompactionPayload struct {
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
const defaultCompactionPreserveLast = 8
|
||||
const defaultCompactionPreserveLast = 6
|
||||
const defaultCompactionMaxPromptChars = 8000
|
||||
|
||||
func (store *Store) PlanRewind(sessionID string, options RewindOptions) (RewindPlan, error) {
|
||||
|
||||
@@ -555,6 +555,91 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow(t *testing.T) {
|
||||
events := []Event{
|
||||
{Sequence: 1, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"first user request"}`)},
|
||||
{Sequence: 2, Type: EventMessage, Payload: json.RawMessage(`{"role":"assistant","content":"first assistant answer"}`)},
|
||||
}
|
||||
for sequence := 3; sequence <= 45; sequence++ {
|
||||
events = append(events, Event{Sequence: sequence, Type: EventToolResult, Payload: json.RawMessage(`{"name":"read_file","output":"noisy tool result"}`)})
|
||||
}
|
||||
events = append(events, Event{Sequence: 46, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"latest user request"}`)})
|
||||
|
||||
prompt := FormatExecPrompt("continue", PreparedExec{
|
||||
Mode: ModeResume,
|
||||
Session: Metadata{SessionID: "session-with-noise"},
|
||||
ContextEvents: events,
|
||||
})
|
||||
|
||||
for _, want := range []string{"first user request", "first assistant answer", "latest user request", "Current user request:", "continue"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prompt, "noisy tool result") {
|
||||
t.Fatalf("expected prompt to omit noisy non-conversation events, got %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *testing.T) {
|
||||
events := make([]Event, 0, 100)
|
||||
for sequence := 1; sequence <= 85; sequence++ {
|
||||
events = append(events, Event{
|
||||
Sequence: sequence,
|
||||
Type: EventMessage,
|
||||
Payload: json.RawMessage(fmt.Sprintf(`{"role":"user","content":"conversation message %03d"}`, sequence)),
|
||||
})
|
||||
if sequence%10 == 0 {
|
||||
events = append(events, Event{
|
||||
Sequence: 1000 + sequence,
|
||||
Type: EventToolResult,
|
||||
Payload: json.RawMessage(fmt.Sprintf(`{"name":"read_file","output":"noisy tool result %03d"}`, sequence)),
|
||||
})
|
||||
}
|
||||
}
|
||||
events = append(events, Event{
|
||||
Sequence: 2000,
|
||||
Type: EventToolResult,
|
||||
Payload: json.RawMessage(`{"name":"bash","output":"trailing noisy tool result"}`),
|
||||
})
|
||||
|
||||
contextEvents := promptContextEvents(events)
|
||||
if len(contextEvents) != 80 {
|
||||
t.Fatalf("expected prompt context to keep 80 events, got %d", len(contextEvents))
|
||||
}
|
||||
if contextEvents[0].Sequence != 6 || contextEvents[len(contextEvents)-1].Sequence != 85 {
|
||||
t.Fatalf("expected prompt context to keep message sequences 6-85, got first=%d last=%d", contextEvents[0].Sequence, contextEvents[len(contextEvents)-1].Sequence)
|
||||
}
|
||||
|
||||
prompt := FormatExecPrompt("continue", PreparedExec{
|
||||
Mode: ModeResume,
|
||||
Session: Metadata{SessionID: "long-session-with-noise"},
|
||||
ContextEvents: events,
|
||||
})
|
||||
|
||||
if got, want := strings.Count(prompt, "- #"), len(contextEvents); got != want {
|
||||
t.Fatalf("expected prompt to contain %d context event lines, got %d in %q", want, got, prompt)
|
||||
}
|
||||
for _, want := range []string{"conversation message 006", "conversation message 085", "Current user request:", "continue"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"conversation message 001",
|
||||
"conversation message 002",
|
||||
"conversation message 003",
|
||||
"conversation message 004",
|
||||
"conversation message 005",
|
||||
"noisy tool result",
|
||||
"trailing noisy tool result",
|
||||
} {
|
||||
if strings.Contains(prompt, unwanted) {
|
||||
t.Fatalf("expected prompt to omit %q, got %q", unwanted, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
const (
|
||||
sessionTagSpecialist = "specialist"
|
||||
promptFileThresholdBytes = 4 * 1024
|
||||
maxSpecialistDepth = 8 // hard cap to prevent infinite recursion/resource exhaustion via Task
|
||||
)
|
||||
|
||||
const SessionTagSpecialist = sessionTagSpecialist
|
||||
@@ -218,6 +219,13 @@ func (executor Executor) Run(ctx context.Context, params TaskParameters, options
|
||||
if options.CurrentDepth < 0 {
|
||||
return ExecResult{}, fmt.Errorf("current depth cannot be negative")
|
||||
}
|
||||
// >= (not >): this Run call spawns a CHILD at options.CurrentDepth+1, so a
|
||||
// parent already AT the cap must still be rejected here rather than being
|
||||
// allowed to launch one more level before the child's own next call trips
|
||||
// the guard.
|
||||
if options.CurrentDepth >= maxSpecialistDepth {
|
||||
return ExecResult{}, fmt.Errorf("spawning a specialist at depth %d would exceed maximum nesting depth %d", options.CurrentDepth+1, maxSpecialistDepth)
|
||||
}
|
||||
if strings.TrimSpace(params.Prompt) == "" {
|
||||
return ExecResult{}, fmt.Errorf("specialist prompt is required")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package specialist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -336,6 +337,39 @@ func TestBuildArgsRejectsInvalidInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsDepthExceedingMax(t *testing.T) {
|
||||
_, err := (Executor{}).Run(context.Background(), TaskParameters{
|
||||
Prompt: "hi",
|
||||
}, TaskRunOptions{CurrentDepth: maxSpecialistDepth + 1})
|
||||
if err == nil || !strings.Contains(err.Error(), "depth") {
|
||||
t.Fatalf("Run error = %v, want depth error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRejectsDepthAtMax covers the boundary: a parent already AT the cap
|
||||
// must be rejected too, since this Run call would launch a child one level
|
||||
// past it (--depth CurrentDepth+1). Only checking ">" here would let that
|
||||
// child start before the guard ever caught it. The guard sits before the
|
||||
// fresh/resume branch in Run, so both call shapes must be proven to reject
|
||||
// here rather than falling through to runFresh/runResume.
|
||||
func TestRunRejectsDepthAtMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params TaskParameters
|
||||
}{
|
||||
{name: "fresh", params: TaskParameters{Prompt: "hi"}},
|
||||
{name: "resume", params: TaskParameters{Prompt: "hi", Resume: "child_session"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := (Executor{}).Run(context.Background(), tc.params, TaskRunOptions{CurrentDepth: maxSpecialistDepth})
|
||||
if err == nil || !strings.Contains(err.Error(), "depth") {
|
||||
t.Fatalf("Run error = %v, want depth error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsRejectsInvalidSessionIDs(t *testing.T) {
|
||||
_, err := (Executor{NewSessionID: func() (string, error) { return "../escape", nil }}).BuildArgs(BuildArgsInput{
|
||||
Prompt: "hi",
|
||||
|
||||
+21
-4
@@ -120,7 +120,6 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
// Release any plan-scoped resources once the command has finished running.
|
||||
defer plan.Cleanup()
|
||||
addSandboxMeta(meta, plan)
|
||||
|
||||
@@ -221,6 +220,14 @@ func shellIssueBlockResult(issue shellIssue) Result {
|
||||
}
|
||||
}
|
||||
|
||||
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
|
||||
// commandText. On Windows, when the command is not wrapped by the sandbox
|
||||
// engine (plan.Wrapped == false), it also overrides the child's raw command
|
||||
// line so commandText reaches cmd.exe unescaped; see
|
||||
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
|
||||
// gets the same treatment inside the sandboxed runner process itself
|
||||
// (internal/sandbox/windows_process_windows.go), since that command line is
|
||||
// built there, not here.
|
||||
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
|
||||
spec := zeroSandbox.CommandSpec{
|
||||
Name: shellExecutable(),
|
||||
@@ -228,7 +235,11 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
|
||||
Dir: absoluteCwd,
|
||||
}
|
||||
if engine != nil {
|
||||
return engine.CommandContext(ctx, spec)
|
||||
command, plan, err := engine.CommandContext(ctx, spec)
|
||||
if err == nil {
|
||||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
|
||||
}
|
||||
return command, plan, err
|
||||
}
|
||||
plan := zeroSandbox.CommandPlan{
|
||||
Backend: zeroSandbox.Backend{
|
||||
@@ -242,6 +253,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
|
||||
}
|
||||
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
|
||||
command.Dir = spec.Dir
|
||||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
|
||||
return command, plan, nil
|
||||
}
|
||||
|
||||
@@ -302,7 +314,7 @@ func shellExecutable() string {
|
||||
|
||||
func shellArguments(command string) []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{"/d", "/s", "/c", command}
|
||||
return zeroSandbox.WindowsShellArgs(command)
|
||||
}
|
||||
return []string{"-c", command}
|
||||
}
|
||||
@@ -342,7 +354,12 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
|
||||
if len(parts) == 0 {
|
||||
return "Command completed with no output."
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
// Cap the context cost of a verbose command at the same default budget as
|
||||
// exec_command (head/tail window); the full output is spilled to disk and
|
||||
// referenced in the truncation notice, so nothing is lost. Previously bash
|
||||
// output was unbounded — one chatty build log could flood the context.
|
||||
output, _ := truncateExecOutputSpill(strings.Join(parts, "\n"), defaultMaxOutputTokens, "bash")
|
||||
return output
|
||||
}
|
||||
|
||||
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
|
||||
@@ -42,3 +42,7 @@ func hardenProcessLifetime(command *exec.Cmd) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
|
||||
// cmd.exe command-line-parsing quirk to work around here.
|
||||
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
|
||||
@@ -5,7 +5,10 @@ package tools
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// bashWaitDelay bounds how long Wait blocks for the I/O pipes to drain after the
|
||||
@@ -28,3 +31,17 @@ func hardenProcessLifetime(command *exec.Cmd) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// applyWindowsShellCommandLine overrides command's raw child command line so
|
||||
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
|
||||
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
|
||||
// is true: the sandbox engine then routes execution through a separate
|
||||
// zero-windows-command-runner process, which builds its own child command
|
||||
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
|
||||
// inheriting whatever this outer exec.Cmd is configured with.
|
||||
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
|
||||
if wrapped {
|
||||
return
|
||||
}
|
||||
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
|
||||
}
|
||||
@@ -17,6 +17,18 @@ import (
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if len(os.Args) >= 3 && os.Args[1] == "--zero-bash-helper" {
|
||||
if os.Args[2] == "echo-arg" {
|
||||
// Echoes back exactly the one argument it received, to verify a
|
||||
// quoted, space-and-slash-containing argument (the same shape as
|
||||
// `python -c "print(15 / 3)"`) survives the shell invocation
|
||||
// intact instead of being truncated/corrupted.
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Fprintln(os.Stderr, "echo-arg requires exactly one argument")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(os.Args[3])
|
||||
return
|
||||
}
|
||||
runBashToolHelper(os.Args[2])
|
||||
return
|
||||
}
|
||||
@@ -161,6 +173,35 @@ func TestDetectShellCommandIssueRequiresActualLSCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git log --oneline -- pinokio_compat.py | head`,
|
||||
`git blame -L 40,46 pinokio_compat.py --format=%author | grep summary`,
|
||||
`cat file.txt | wc -l`,
|
||||
`some_command | tail -n 20`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
|
||||
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git log --oneline`,
|
||||
`dir /b`,
|
||||
`findstr /r "summary" file.txt`,
|
||||
`echo header text`,
|
||||
`tail-log.sh`,
|
||||
`grep-cli --version`,
|
||||
`sed.exe --help`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
|
||||
if issue == nil {
|
||||
@@ -506,6 +547,68 @@ func TestBashToolAllowsNonInteractiveCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBashToolPreservesEmbeddedQuotesOnWindows pins a real, previously-broken
|
||||
// case: passing commandText as a normal exec.Cmd argument makes Go wrap it in
|
||||
// an outer pair of quotes (it contains spaces) with its own quotes escaped as
|
||||
// \", and cmd.exe's /C remainder parsing strips the first and last literal
|
||||
// quote character in that remainder without undoing the backslash-escaping -
|
||||
// corrupting a command whose own text contains embedded double quotes,
|
||||
// exactly the shape of `python -c "print(15 / 3)"`, `git commit -m
|
||||
// "message"`, `node -e "..."`. Before the fix, the helper below received a
|
||||
// truncated, mis-quoted argument (starting with a stray literal `"`) instead
|
||||
// of the text between the quotes.
|
||||
func TestBashToolPreservesEmbeddedQuotesOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("cmd.exe /S/C quote-remainder parsing is Windows-specific")
|
||||
}
|
||||
root := t.TempDir()
|
||||
executable := shellQuote(os.Args[0])
|
||||
// Space and a slash: the two characters whose position in the original
|
||||
// bug report's SyntaxError ("print(15, truncated right before the /")
|
||||
// showed exactly where the corruption happened.
|
||||
commandText := executable + ` --zero-bash-helper echo-arg "hello / world"`
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
"command": commandText,
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "hello / world") {
|
||||
t.Fatalf("expected the embedded-quote argument to survive intact, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBashToolRunsCommandLineForLoopSyntax pins cmd.exe command-line syntax,
|
||||
// not batch-file syntax: a `for %i in (...) do ...` loop with a single
|
||||
// percent sign is valid typed directly at a cmd.exe prompt (or via
|
||||
// `cmd /C "..."`), but requires %%i inside an actual .bat/.cmd FILE, because
|
||||
// batch files perform an extra pass of percent-substitution before FOR ever
|
||||
// runs, consuming the single percent so the loop variable never binds. An
|
||||
// earlier version of this fix ran commandText from a temporary .cmd file
|
||||
// instead of passing it straight through to cmd.exe's /C remainder, which
|
||||
// silently broke single-percent syntax like this.
|
||||
func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
|
||||
}
|
||||
root := t.TempDir()
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
"command": "for %i in (1 2 3) do echo %i",
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
for _, want := range []string{"1", "2", "3"} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected the for-loop to expand %%i to %q, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func helperCommand(name string) string {
|
||||
executable := shellQuote(os.Args[0])
|
||||
return executable + " --zero-bash-helper " + name
|
||||
|
||||
@@ -2,6 +2,7 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -44,7 +45,7 @@ func (tool editFileTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
}
|
||||
|
||||
func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
||||
func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
|
||||
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
||||
@@ -98,6 +99,32 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy fallback: when the exact string (and its CRLF translation) is absent,
|
||||
// run a cascade of tolerant matchers (trimmed lines, block anchors, collapsed
|
||||
// whitespace, indentation drift, escape normalization) to locate the span the
|
||||
// model intended. Only a span that occurs literally in the file is accepted,
|
||||
// so the replacement applied below is still exact.
|
||||
if occurrences == 0 {
|
||||
findOld, findNew := oldString, newString
|
||||
if strings.Contains(content, "\r\n") && !strings.Contains(findOld, "\r\n") {
|
||||
findOld = strings.ReplaceAll(findOld, "\n", "\r\n")
|
||||
findNew = strings.ReplaceAll(findNew, "\n", "\r\n")
|
||||
}
|
||||
search, ferr := fuzzyEditMatch(content, findOld, replaceAll)
|
||||
switch {
|
||||
case ferr == nil:
|
||||
oldString = search
|
||||
newString = findNew
|
||||
occurrences = strings.Count(content, search)
|
||||
case errors.Is(ferr, errEditFuzzyAmbiguous):
|
||||
return errorResult("Error: old_string matches multiple locations in " + relativePath + " even after fuzzy matching. Provide more surrounding context to make the match unique, or pass replace_all: true.")
|
||||
case errors.Is(ferr, errEditFuzzyNotFound):
|
||||
// Fall through to the exact-match error below.
|
||||
default:
|
||||
return errorResult("Error editing " + relativePath + ": " + ferr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if occurrences == 0 {
|
||||
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
|
||||
}
|
||||
@@ -120,6 +147,10 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker re-baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
updated = maybeFormatWrittenFile(ctx, absolutePath, updated)
|
||||
// Re-baseline to the content we just wrote so subsequent edits in this session
|
||||
// compare against the current on-disk state, not the pre-edit version.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
@@ -130,6 +161,7 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
suffix = "s"
|
||||
}
|
||||
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
// Card-only preview (Display.Preview): the model's Output stays the one-line
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
package tools
|
||||
|
||||
// Fuzzy fallback matching for edit_file. When the model's old_string fails to
|
||||
// match byte-for-byte (drifted indentation, collapsed whitespace, escaped
|
||||
// characters, a slightly-misremembered middle line), these replacers propose
|
||||
// candidate spans that plausibly correspond to what the model intended. Only a
|
||||
// candidate that occurs literally in the file is accepted, so the replacement
|
||||
// itself is always exact even when the match was tolerant.
|
||||
//
|
||||
// Strategy cascade ported from opencode's edit tool, whose replacers were in
|
||||
// turn distilled from Cline's diff-apply evals and gemini-cli's edit corrector.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// editReplacer proposes candidate spans for find inside content. Candidates
|
||||
// are re-validated by fuzzyEditMatch before use.
|
||||
type editReplacer func(content, find string) []string
|
||||
|
||||
var (
|
||||
errEditFuzzyNotFound = errors.New("no fuzzy match for old_string")
|
||||
errEditFuzzyAmbiguous = errors.New("fuzzy match for old_string is ambiguous")
|
||||
)
|
||||
|
||||
// Minimum average middle-line similarity for block-anchor matches.
|
||||
const editAnchorSimilarityThreshold = 0.65
|
||||
|
||||
// fuzzyEditMatch runs the replacer cascade and returns the exact span of
|
||||
// content to replace. When replaceAll is false the span must be unique in
|
||||
// content; an ambiguous candidate is skipped in favor of later candidates and
|
||||
// only reported if nothing unique is found. A span wildly larger than
|
||||
// old_string is refused outright rather than risking a destructive edit.
|
||||
func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
|
||||
replacers := []editReplacer{
|
||||
lineTrimmedReplacer,
|
||||
blockAnchorReplacer,
|
||||
whitespaceNormalizedReplacer,
|
||||
indentationFlexibleReplacer,
|
||||
escapeNormalizedReplacer,
|
||||
trimmedBoundaryReplacer,
|
||||
contextAwareReplacer,
|
||||
}
|
||||
found := false
|
||||
for _, replacer := range replacers {
|
||||
for _, search := range replacer(content, find) {
|
||||
if search == "" {
|
||||
continue
|
||||
}
|
||||
index := strings.Index(content, search)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if isDisproportionateEditMatch(search, find) {
|
||||
return "", errors.New("refusing replacement because the matched span is much larger than old_string; re-read the file and provide the full exact old_string for the intended replacement")
|
||||
}
|
||||
if replaceAll {
|
||||
return search, nil
|
||||
}
|
||||
if strings.LastIndex(content, search) != index {
|
||||
continue
|
||||
}
|
||||
return search, nil
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", errEditFuzzyNotFound
|
||||
}
|
||||
return "", errEditFuzzyAmbiguous
|
||||
}
|
||||
|
||||
// isDisproportionateEditMatch guards against anchor-style replacers matching a
|
||||
// span far larger than the text the model asked to replace (e.g. first/last
|
||||
// line anchors bridging hundreds of unrelated lines).
|
||||
func isDisproportionateEditMatch(search, find string) bool {
|
||||
findLines := strings.Count(find, "\n") + 1
|
||||
searchLines := strings.Count(search, "\n") + 1
|
||||
limit := findLines + 3
|
||||
if findLines*2 > limit {
|
||||
limit = findLines * 2
|
||||
}
|
||||
if searchLines >= limit {
|
||||
return true
|
||||
}
|
||||
if findLines == 1 {
|
||||
return false
|
||||
}
|
||||
searchTrimmed := len(strings.TrimSpace(search))
|
||||
findTrimmed := len(strings.TrimSpace(find))
|
||||
byteLimit := findTrimmed + 500
|
||||
if findTrimmed*4 > byteLimit {
|
||||
byteLimit = findTrimmed * 4
|
||||
}
|
||||
return searchTrimmed > byteLimit
|
||||
}
|
||||
|
||||
// splitFindLines splits find into lines, dropping the trailing empty line a
|
||||
// trailing newline produces so windows align with real content lines.
|
||||
func splitFindLines(find string) []string {
|
||||
lines := strings.Split(find, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// lineTrimmedReplacer matches when every line equals the corresponding
|
||||
// old_string line after trimming surrounding whitespace.
|
||||
func lineTrimmedReplacer(content, find string) []string {
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) == 0 {
|
||||
return nil
|
||||
}
|
||||
var candidates []string
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
matches := true
|
||||
for j := range findLines {
|
||||
if strings.TrimSpace(contentLines[i+j]) != strings.TrimSpace(findLines[j]) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
candidates = append(candidates, strings.Join(contentLines[i:i+len(findLines)], "\n"))
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// blockAnchorReplacer anchors on the first and last lines (trimmed) and
|
||||
// accepts the block when the middle lines average >= the similarity threshold
|
||||
// (Levenshtein-based), tolerating a slightly misremembered interior.
|
||||
func blockAnchorReplacer(content, find string) []string {
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) < 3 {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
firstAnchor := strings.TrimSpace(findLines[0])
|
||||
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
|
||||
searchBlockSize := len(findLines)
|
||||
maxLineDelta := searchBlockSize / 4
|
||||
if maxLineDelta < 1 {
|
||||
maxLineDelta = 1
|
||||
}
|
||||
|
||||
type span struct{ start, end int }
|
||||
var candidates []span
|
||||
for i := 0; i < len(contentLines); i++ {
|
||||
if strings.TrimSpace(contentLines[i]) != firstAnchor {
|
||||
continue
|
||||
}
|
||||
// Only the first occurrence of the last anchor after this start counts,
|
||||
// mirroring the reference implementation: a farther-away closing line is
|
||||
// assumed to close a different block.
|
||||
for j := i + 2; j < len(contentLines); j++ {
|
||||
if strings.TrimSpace(contentLines[j]) != lastAnchor {
|
||||
continue
|
||||
}
|
||||
actualBlockSize := j - i + 1
|
||||
delta := actualBlockSize - searchBlockSize
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta <= maxLineDelta {
|
||||
candidates = append(candidates, span{start: i, end: j})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
middleSimilarity := func(candidate span) float64 {
|
||||
actualBlockSize := candidate.end - candidate.start + 1
|
||||
linesToCheck := searchBlockSize - 2
|
||||
if actualBlockSize-2 < linesToCheck {
|
||||
linesToCheck = actualBlockSize - 2
|
||||
}
|
||||
if linesToCheck <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
similarity := 0.0
|
||||
for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ {
|
||||
contentLine := strings.TrimSpace(contentLines[candidate.start+j])
|
||||
findLine := strings.TrimSpace(findLines[j])
|
||||
maxLen := len(contentLine)
|
||||
if len(findLine) > maxLen {
|
||||
maxLen = len(findLine)
|
||||
}
|
||||
if maxLen == 0 {
|
||||
continue
|
||||
}
|
||||
distance := levenshtein(contentLine, findLine)
|
||||
similarity += 1 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
return similarity / float64(linesToCheck)
|
||||
}
|
||||
|
||||
best := candidates[0]
|
||||
if len(candidates) > 1 {
|
||||
bestSimilarity := -1.0
|
||||
for _, candidate := range candidates {
|
||||
if s := middleSimilarity(candidate); s > bestSimilarity {
|
||||
bestSimilarity = s
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
if bestSimilarity < editAnchorSimilarityThreshold {
|
||||
return nil
|
||||
}
|
||||
} else if middleSimilarity(best) < editAnchorSimilarityThreshold {
|
||||
return nil
|
||||
}
|
||||
return []string{strings.Join(contentLines[best.start:best.end+1], "\n")}
|
||||
}
|
||||
|
||||
var editWhitespaceRun = regexp.MustCompile(`\s+`)
|
||||
|
||||
func normalizeEditWhitespace(text string) string {
|
||||
return strings.TrimSpace(editWhitespaceRun.ReplaceAllString(text, " "))
|
||||
}
|
||||
|
||||
// whitespaceNormalizedReplacer matches after collapsing all whitespace runs to
|
||||
// a single space: full lines, sub-line spans (via a word-boundary regex), and
|
||||
// multi-line windows.
|
||||
func whitespaceNormalizedReplacer(content, find string) []string {
|
||||
normalizedFind := normalizeEditWhitespace(find)
|
||||
if normalizedFind == "" {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
var candidates []string
|
||||
var subLinePattern *regexp.Regexp
|
||||
for _, line := range contentLines {
|
||||
normalizedLine := normalizeEditWhitespace(line)
|
||||
if normalizedLine == normalizedFind {
|
||||
candidates = append(candidates, line)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(normalizedLine, normalizedFind) {
|
||||
continue
|
||||
}
|
||||
if subLinePattern == nil {
|
||||
words := strings.Fields(find)
|
||||
quoted := make([]string, len(words))
|
||||
for i, word := range words {
|
||||
quoted[i] = regexp.QuoteMeta(word)
|
||||
}
|
||||
pattern, err := regexp.Compile(strings.Join(quoted, `\s+`))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
subLinePattern = pattern
|
||||
}
|
||||
if match := subLinePattern.FindString(line); match != "" {
|
||||
candidates = append(candidates, match)
|
||||
}
|
||||
}
|
||||
|
||||
findLines := strings.Split(find, "\n")
|
||||
if len(findLines) > 1 {
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if normalizeEditWhitespace(block) == normalizedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// stripCommonIndentation removes the minimum leading-whitespace width shared
|
||||
// by all non-empty lines, so blocks match regardless of their nesting depth.
|
||||
func stripCommonIndentation(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
minIndent := -1
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
indent := len(line) - len(strings.TrimLeft(line, " \t"))
|
||||
if minIndent < 0 || indent < minIndent {
|
||||
minIndent = indent
|
||||
}
|
||||
}
|
||||
if minIndent <= 0 {
|
||||
return text
|
||||
}
|
||||
stripped := make([]string, len(lines))
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
stripped[i] = line
|
||||
continue
|
||||
}
|
||||
stripped[i] = line[minIndent:]
|
||||
}
|
||||
return strings.Join(stripped, "\n")
|
||||
}
|
||||
|
||||
func indentationFlexibleReplacer(content, find string) []string {
|
||||
normalizedFind := stripCommonIndentation(find)
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
var candidates []string
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if stripCommonIndentation(block) == normalizedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
var editEscapeSequence = regexp.MustCompile("\\\\(n|t|r|'|\"|`|\\\\|\\n|\\$)")
|
||||
|
||||
// unescapeEditString undoes one level of string escaping (\n, \t, \", \\, a
|
||||
// backslash-newline continuation, \$) — the model sometimes reproduces file
|
||||
// content as it appeared inside a quoted string literal.
|
||||
func unescapeEditString(text string) string {
|
||||
return editEscapeSequence.ReplaceAllStringFunc(text, func(match string) string {
|
||||
switch match[1:] {
|
||||
case "n":
|
||||
return "\n"
|
||||
case "t":
|
||||
return "\t"
|
||||
case "r":
|
||||
return "\r"
|
||||
case "\n":
|
||||
return "\n"
|
||||
default:
|
||||
// ', ", `, \, $ all unescape to themselves.
|
||||
return match[1:]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func escapeNormalizedReplacer(content, find string) []string {
|
||||
unescapedFind := unescapeEditString(find)
|
||||
var candidates []string
|
||||
if strings.Contains(content, unescapedFind) {
|
||||
candidates = append(candidates, unescapedFind)
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(unescapedFind, "\n")
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if unescapeEditString(block) == unescapedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// trimmedBoundaryReplacer tolerates stray leading/trailing whitespace (often
|
||||
// blank lines) around an otherwise-exact old_string.
|
||||
func trimmedBoundaryReplacer(content, find string) []string {
|
||||
trimmedFind := strings.TrimSpace(find)
|
||||
if trimmedFind == find || trimmedFind == "" {
|
||||
return nil
|
||||
}
|
||||
var candidates []string
|
||||
if strings.Contains(content, trimmedFind) {
|
||||
candidates = append(candidates, trimmedFind)
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if strings.TrimSpace(block) == trimmedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// contextAwareReplacer anchors on the first and last lines and accepts an
|
||||
// equal-length block when at least half of its non-empty middle lines match
|
||||
// after trimming — a cheaper, stricter cousin of blockAnchorReplacer.
|
||||
func contextAwareReplacer(content, find string) []string {
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) < 3 {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
firstAnchor := strings.TrimSpace(findLines[0])
|
||||
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
|
||||
var candidates []string
|
||||
for i := 0; i < len(contentLines); i++ {
|
||||
if strings.TrimSpace(contentLines[i]) != firstAnchor {
|
||||
continue
|
||||
}
|
||||
for j := i + 2; j < len(contentLines); j++ {
|
||||
if strings.TrimSpace(contentLines[j]) != lastAnchor {
|
||||
continue
|
||||
}
|
||||
if j-i+1 == len(findLines) {
|
||||
matching, nonEmpty := 0, 0
|
||||
for k := 1; k < len(findLines)-1; k++ {
|
||||
blockLine := strings.TrimSpace(contentLines[i+k])
|
||||
findLine := strings.TrimSpace(findLines[k])
|
||||
if blockLine == "" && findLine == "" {
|
||||
continue
|
||||
}
|
||||
nonEmpty++
|
||||
if blockLine == findLine {
|
||||
matching++
|
||||
}
|
||||
}
|
||||
if nonEmpty == 0 || float64(matching)/float64(nonEmpty) >= 0.5 {
|
||||
candidates = append(candidates, strings.Join(contentLines[i:j+1], "\n"))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// levenshtein computes edit distance with a two-row rolling matrix.
|
||||
func levenshtein(a, b string) int {
|
||||
if a == "" {
|
||||
return len(b)
|
||||
}
|
||||
if b == "" {
|
||||
return len(a)
|
||||
}
|
||||
previous := make([]int, len(b)+1)
|
||||
current := make([]int, len(b)+1)
|
||||
for j := range previous {
|
||||
previous[j] = j
|
||||
}
|
||||
for i := 1; i <= len(a); i++ {
|
||||
current[0] = i
|
||||
for j := 1; j <= len(b); j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
minimum := previous[j] + 1
|
||||
if current[j-1]+1 < minimum {
|
||||
minimum = current[j-1] + 1
|
||||
}
|
||||
if previous[j-1]+cost < minimum {
|
||||
minimum = previous[j-1] + cost
|
||||
}
|
||||
current[j] = minimum
|
||||
}
|
||||
previous, current = current, previous
|
||||
}
|
||||
return previous[len(b)]
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runEdit(t *testing.T, dir, initial string, args map[string]any) (Result, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "target.txt")
|
||||
if err := os.WriteFile(path, []byte(initial), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if args["path"] == nil {
|
||||
args["path"] = "target.txt"
|
||||
}
|
||||
result := NewEditFileTool(dir).Run(context.Background(), args)
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result, string(after)
|
||||
}
|
||||
|
||||
func TestEditExactMatchStillPreferred(t *testing.T) {
|
||||
// The fast path must keep byte-exact semantics: an exact match wins even
|
||||
// when fuzzy strategies would also find candidates elsewhere.
|
||||
result, after := runEdit(t, t.TempDir(), "alpha\nbeta\ngamma\n", map[string]any{
|
||||
"old_string": "beta",
|
||||
"new_string": "delta",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if after != "alpha\ndelta\ngamma\n" {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyLineTrimmed(t *testing.T) {
|
||||
// Model reproduced the lines without the file's leading indentation; the
|
||||
// line-trimmed strategy must find the real indented span and preserve the
|
||||
// file's own indentation on the replaced region boundary.
|
||||
initial := "func a() {\n\tx := 1\n\treturn x\n}\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1\nreturn x",
|
||||
"new_string": "\ty := 2\n\treturn y",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "\ty := 2\n\treturn y\n}") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
if strings.Contains(after, "x := 1") {
|
||||
t.Fatalf("old span survived: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyWhitespaceNormalizedSingleLine(t *testing.T) {
|
||||
// Whitespace runs collapsed: "x := 1" in the file, single spaces from
|
||||
// the model.
|
||||
initial := "start\nx := 1\nend\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1",
|
||||
"new_string": "x := 2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "x := 2") || strings.Contains(after, ":= 1") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyBlockAnchorToleratesMiddleDrift(t *testing.T) {
|
||||
// First and last lines anchor the block; one interior line differs slightly
|
||||
// (comment text drifted). Levenshtein similarity keeps it above 0.65.
|
||||
initial := strings.Join([]string{
|
||||
"func handler(w http.ResponseWriter, r *http.Request) {",
|
||||
"\t// write the response body to the client",
|
||||
"\tw.WriteHeader(http.StatusOK)",
|
||||
"\tfmt.Fprint(w, \"done\")",
|
||||
"}",
|
||||
"",
|
||||
}, "\n")
|
||||
find := strings.Join([]string{
|
||||
"func handler(w http.ResponseWriter, r *http.Request) {",
|
||||
"\t// write the response body to client",
|
||||
"\tw.WriteHeader(http.StatusOK)",
|
||||
"\tfmt.Fprint(w, \"done\")",
|
||||
"}",
|
||||
}, "\n")
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": find,
|
||||
"new_string": "func handler(w http.ResponseWriter, r *http.Request) {\n\tw.WriteHeader(http.StatusNoContent)\n}",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "StatusNoContent") || strings.Contains(after, "StatusOK") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyIndentationFlexible(t *testing.T) {
|
||||
// The whole block sits one nesting level deeper in the file than in the
|
||||
// model's old_string, with interior relative indentation preserved.
|
||||
initial := "if ok {\n\t\tfor i := range xs {\n\t\t\tsum += xs[i]\n\t\t}\n}\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "for i := range xs {\n\tsum += xs[i]\n}",
|
||||
"new_string": "\t\tsum += total(xs)",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "total(xs)") || strings.Contains(after, "range xs") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyEscapeNormalized(t *testing.T) {
|
||||
// Model reproduced the line as it would appear inside a quoted string
|
||||
// literal, with escaped quotes.
|
||||
initial := "console.log(\"hello world\")\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": `console.log(\"hello world\")`,
|
||||
"new_string": `console.log("goodbye")`,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, `console.log("goodbye")`) {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyTrimmedBoundary(t *testing.T) {
|
||||
// Stray whitespace around an otherwise-exact old_string; " two " is not a
|
||||
// substring of the file, so only the fuzzy cascade can resolve it.
|
||||
initial := "one\ntwo\nthree\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": " two ",
|
||||
"new_string": "2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if after != "one\n2\nthree\n" {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimmedBoundaryReplacerYieldsTrimmedSpan(t *testing.T) {
|
||||
candidates := trimmedBoundaryReplacer("one\ntwo\nthree\n", "\ntwo\n")
|
||||
if len(candidates) == 0 || candidates[0] != "two" {
|
||||
t.Fatalf("expected trimmed candidate \"two\", got %v", candidates)
|
||||
}
|
||||
if trimmedBoundaryReplacer("anything", "already-trimmed") != nil {
|
||||
t.Fatal("already-trimmed find must yield no candidates")
|
||||
}
|
||||
}
|
||||
|
||||
// Two identical blocks that only match old_string after indentation-tolerant
|
||||
// matching: exact search finds nothing, fuzzy finds both.
|
||||
const ambiguousBlocks = "\tif x {\n\t\tgo()\n\t}\nmid\n\tif x {\n\t\tgo()\n\t}\n"
|
||||
|
||||
func TestEditFuzzyAmbiguousReportsError(t *testing.T) {
|
||||
// No strategy can disambiguate identical blocks, so the tool must refuse
|
||||
// rather than guess, and the file must be untouched.
|
||||
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
|
||||
"old_string": "if x {\n\tgo()\n}",
|
||||
"new_string": "stop()",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
|
||||
t.Fatalf("expected ambiguity error, got %q", result.Output)
|
||||
}
|
||||
if after != ambiguousBlocks {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyReplaceAll(t *testing.T) {
|
||||
// replace_all applies the fuzzy-resolved span at every occurrence.
|
||||
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
|
||||
"old_string": "if x {\n\tgo()\n}",
|
||||
"new_string": "stop()",
|
||||
"replace_all": true,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if strings.Count(after, "stop()") != 2 || strings.Contains(after, "go()") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyNotFoundKeepsExactError(t *testing.T) {
|
||||
result, after := runEdit(t, t.TempDir(), "alpha\n", map[string]any{
|
||||
"old_string": "omega",
|
||||
"new_string": "x",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "Could not find the exact string") {
|
||||
t.Fatalf("expected not-found error, got %q", result.Output)
|
||||
}
|
||||
if after != "alpha\n" {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyCRLFFile(t *testing.T) {
|
||||
// CRLF file + LF old_string with indentation drift: the CRLF translation
|
||||
// feeds the cascade and the replacement preserves CRLF endings.
|
||||
initial := "func a() {\r\n\tx := 1\r\n}\r\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1",
|
||||
"new_string": "x := 2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "\tx := 2\r\n") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDisproportionateEditMatch(t *testing.T) {
|
||||
// A candidate span that dwarfs old_string must be refused: anchors bridging
|
||||
// unrelated code would otherwise delete it all.
|
||||
big := strings.Repeat("line\n", 10)
|
||||
if !isDisproportionateEditMatch(big, "a\nb\nc") {
|
||||
t.Fatal("10-line span for 3-line find must be disproportionate")
|
||||
}
|
||||
if isDisproportionateEditMatch("a\nb\nc\nd", "a\nb\nc") {
|
||||
t.Fatal("one extra line within tolerance must be allowed")
|
||||
}
|
||||
if isDisproportionateEditMatch("single line", "single") {
|
||||
t.Fatal("single-line finds are exempt from the byte-length guard")
|
||||
}
|
||||
if !isDisproportionateEditMatch("xx\n"+strings.Repeat("y", 900)+"\nzz", "xx\nab\nzz") {
|
||||
t.Fatal("byte-length blowup must be disproportionate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"abc", "", 3},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "sitting", 3},
|
||||
{"same", "same", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := levenshtein(c.a, c.b); got != c.want {
|
||||
t.Fatalf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -908,6 +908,15 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
|
||||
}
|
||||
|
||||
func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
|
||||
return truncateExecOutputSpill(output, maxOutputTokens, "exec_command")
|
||||
}
|
||||
|
||||
// truncateExecOutputSpill keeps a head/tail window of the output within the
|
||||
// token budget and, on truncation, spills the full output to disk so the model
|
||||
// can grep/read the elided middle instead of re-running the command with a
|
||||
// bigger budget. The spill is best-effort: when it fails the notice simply
|
||||
// omits the file hint.
|
||||
func truncateExecOutputSpill(output string, maxOutputTokens int, toolName string) (string, bool) {
|
||||
if maxOutputTokens <= 0 {
|
||||
maxOutputTokens = defaultMaxOutputTokens
|
||||
}
|
||||
@@ -915,9 +924,13 @@ func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
|
||||
if len(output) <= maxBytes {
|
||||
return output, false
|
||||
}
|
||||
notice := "\n[zero] output truncated\n"
|
||||
if spillPath := spillTruncatedOutput(toolName, output); spillPath != "" {
|
||||
notice = "\n[zero] output truncated — full output saved to " + spillPath + " (grep or read_file it instead of re-running)\n"
|
||||
}
|
||||
head := maxBytes / 2
|
||||
tail := maxBytes - head
|
||||
return utf8Prefix(output, head) + "\n[zero] output truncated\n" + utf8Suffix(output, tail), true
|
||||
return utf8Prefix(output, head) + notice + utf8Suffix(output, tail), true
|
||||
}
|
||||
|
||||
func utf8Prefix(value string, maxBytes int) string {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Format-on-write for the mutating file tools. When enabled, a successful
|
||||
// edit_file/write_file runs the language's standard formatter on the file it
|
||||
// just wrote, so the model's output always lands in project-canonical style
|
||||
// and never fails a CI format check it cannot see. Off by default (set
|
||||
// ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the model did not
|
||||
// write, which strict workflows may not want.
|
||||
//
|
||||
// Ordering matters: formatting runs BEFORE the FileTracker re-baseline, and
|
||||
// the caller records the POST-format content. Formatting after the baseline
|
||||
// would make the very next edit look like an external modification and trip
|
||||
// the conflict guard.
|
||||
|
||||
// formatOnWriteTimeout bounds one formatter run; a wedged formatter must never
|
||||
// hang a tool call. On timeout the unformatted write stands.
|
||||
const formatOnWriteTimeout = 10 * time.Second
|
||||
|
||||
// formatterCommands maps a file extension to the formatter argv; the file path
|
||||
// is appended as the final argument. Only in-place, config-respecting,
|
||||
// community-standard formatters — a missing binary silently skips formatting.
|
||||
var formatterCommands = map[string][]string{
|
||||
".go": {"gofmt", "-w"},
|
||||
".rs": {"rustfmt"},
|
||||
".py": {"ruff", "format", "--quiet"},
|
||||
".ts": {"prettier", "--log-level", "silent", "--write"},
|
||||
".tsx": {"prettier", "--log-level", "silent", "--write"},
|
||||
".js": {"prettier", "--log-level", "silent", "--write"},
|
||||
".jsx": {"prettier", "--log-level", "silent", "--write"},
|
||||
".json": {"prettier", "--log-level", "silent", "--write"},
|
||||
".css": {"prettier", "--log-level", "silent", "--write"},
|
||||
".scss": {"prettier", "--log-level", "silent", "--write"},
|
||||
".html": {"prettier", "--log-level", "silent", "--write"},
|
||||
".md": {"prettier", "--log-level", "silent", "--write"},
|
||||
".yaml": {"prettier", "--log-level", "silent", "--write"},
|
||||
".yml": {"prettier", "--log-level", "silent", "--write"},
|
||||
".zig": {"zig", "fmt"},
|
||||
".dart": {"dart", "format"},
|
||||
".tf": {"terraform", "fmt"},
|
||||
".gleam": {"gleam", "format"},
|
||||
".sh": {"shfmt", "-w"},
|
||||
".bash": {"shfmt", "-w"},
|
||||
".c": {"clang-format", "-i"},
|
||||
".h": {"clang-format", "-i"},
|
||||
".cpp": {"clang-format", "-i"},
|
||||
".hpp": {"clang-format", "-i"},
|
||||
".cc": {"clang-format", "-i"},
|
||||
".kt": {"ktlint", "-F"},
|
||||
".swift": {"swiftformat"},
|
||||
".lua": {"stylua"},
|
||||
}
|
||||
|
||||
// formatOnWriteEnabled reports whether the opt-in env toggle is set.
|
||||
func formatOnWriteEnabled() bool {
|
||||
value := strings.TrimSpace(os.Getenv("ZERO_FORMAT_ON_WRITE"))
|
||||
return value != "" && value != "0" && !strings.EqualFold(value, "false")
|
||||
}
|
||||
|
||||
// maybeFormatWrittenFile runs the configured formatter for absolutePath (when
|
||||
// enabled and on PATH) and returns the file's content afterwards. Best-effort
|
||||
// throughout: any failure — no formatter, formatter error, timeout, unreadable
|
||||
// result — returns writtenContent so the caller's state matches the last write
|
||||
// it performed itself.
|
||||
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) string {
|
||||
if !formatOnWriteEnabled() {
|
||||
return writtenContent
|
||||
}
|
||||
command, ok := formatterCommands[strings.ToLower(filepath.Ext(absolutePath))]
|
||||
if !ok {
|
||||
return writtenContent
|
||||
}
|
||||
if _, err := exec.LookPath(command[0]); err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
|
||||
defer cancel()
|
||||
arguments := append(append([]string(nil), command[1:]...), absolutePath)
|
||||
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
|
||||
formatter.Dir = filepath.Dir(absolutePath)
|
||||
if err := formatter.Run(); err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
formatted, err := os.ReadFile(absolutePath)
|
||||
if err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
return string(formatted)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// gofmt ships with the Go toolchain, so it is the one formatter guaranteed to
|
||||
// exist wherever these tests run.
|
||||
func requireGofmt(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("gofmt"); err != nil {
|
||||
t.Skip("gofmt not on PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteDisabledByDefault(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "")
|
||||
dir := t.TempDir()
|
||||
ugly := "package a\n\nfunc A( ) { }\n"
|
||||
|
||||
result := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"content": ugly,
|
||||
}, RunOptions{})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", result.Output)
|
||||
}
|
||||
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(onDisk) != ugly {
|
||||
t.Fatalf("formatting must be off by default, got %q", onDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
dir := t.TempDir()
|
||||
tracker := NewFileTracker()
|
||||
|
||||
write := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"content": "package a\n\nfunc A( ) { }\n",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if write.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", write.Output)
|
||||
}
|
||||
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(onDisk), "func A() {") {
|
||||
t.Fatalf("expected gofmt-formatted content, got %q", onDisk)
|
||||
}
|
||||
|
||||
// The tracker must have been re-baselined to the POST-format content: a
|
||||
// follow-up edit must not trip the external-modification conflict guard.
|
||||
edit := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"old_string": "func A() {",
|
||||
"new_string": "func B() {",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if edit.Status != StatusOK {
|
||||
t.Fatalf("follow-up edit must not conflict after formatting: %q", edit.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
content := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text")
|
||||
if content != "raw text" {
|
||||
t.Fatalf("unknown extension must pass through: %q", content)
|
||||
}
|
||||
}
|
||||
+16
-7
@@ -45,18 +45,18 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
|
||||
}
|
||||
}
|
||||
|
||||
func (tool globTool) Run(_ context.Context, args map[string]any) Result {
|
||||
return tool.runWith(args, readExcluder{})
|
||||
func (tool globTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.runWith(ctx, args, readExcluder{})
|
||||
}
|
||||
|
||||
// RunWithSandbox runs glob while skipping subtrees the sandbox policy denies
|
||||
// reads to (DenyRead). With no DenyRead configured the excluder is a no-op and
|
||||
// behavior is unchanged.
|
||||
func (tool globTool) RunWithSandbox(_ context.Context, args map[string]any, engine *sandbox.Engine) Result {
|
||||
return tool.runWith(args, sandboxReadExcluder(engine))
|
||||
func (tool globTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
|
||||
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
|
||||
}
|
||||
|
||||
func (tool globTool) runWith(args map[string]any, exclude readExcluder) Result {
|
||||
func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
|
||||
pattern, err := aliasedStringArg(args, []string{"pattern", "glob", "match", "query", "expression"}, "", true, false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for glob: " + err.Error())
|
||||
@@ -88,8 +88,11 @@ func (tool globTool) runWith(args map[string]any, exclude readExcluder) Result {
|
||||
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
|
||||
}
|
||||
|
||||
matches, err := scanGlob(root, displayRoot, matcher, includeDirs, exclude)
|
||||
matches, err := scanGlob(ctx, root, displayRoot, matcher, includeDirs, exclude)
|
||||
if err != nil {
|
||||
if res, ok := searchCancelledResult("glob", err); ok {
|
||||
return res
|
||||
}
|
||||
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
@@ -126,9 +129,15 @@ func (tool globTool) runWith(args map[string]any, exclude readExcluder) Result {
|
||||
}
|
||||
}
|
||||
|
||||
func scanGlob(root string, displayRoot string, matcher *regexp.Regexp, includeDirs bool, exclude readExcluder) ([]string, error) {
|
||||
func scanGlob(ctx context.Context, root string, displayRoot string, matcher *regexp.Regexp, includeDirs bool, exclude readExcluder) ([]string, error) {
|
||||
matches := []string{}
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
// Checked first, ahead of walkErr: an unscoped scan over a large tree can
|
||||
// run long enough that cancelling the run must stop the walk promptly
|
||||
// rather than visiting every remaining entry to completion.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if walkErr != nil {
|
||||
if path == root {
|
||||
return walkErr
|
||||
|
||||
+33
-10
@@ -55,19 +55,19 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
|
||||
}
|
||||
}
|
||||
|
||||
func (tool grepTool) Run(_ context.Context, args map[string]any) Result {
|
||||
return tool.runWith(args, readExcluder{})
|
||||
func (tool grepTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.runWith(ctx, args, readExcluder{})
|
||||
}
|
||||
|
||||
// RunWithSandbox runs the search while skipping subtrees the sandbox policy
|
||||
// denies reads to (DenyRead), so grep never surfaces content from a read-denied
|
||||
// path. With no DenyRead configured the excluder is a no-op and behavior is
|
||||
// unchanged.
|
||||
func (tool grepTool) RunWithSandbox(_ context.Context, args map[string]any, engine *sandbox.Engine) Result {
|
||||
return tool.runWith(args, sandboxReadExcluder(engine))
|
||||
func (tool grepTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
|
||||
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
|
||||
}
|
||||
|
||||
func (tool grepTool) runWith(args map[string]any, exclude readExcluder) Result {
|
||||
func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
|
||||
pattern, err := aliasedStringArg(args, []string{"pattern", "query", "regex", "search", "expression"}, "", true, false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for grep: " + err.Error())
|
||||
@@ -138,15 +138,24 @@ func (tool grepTool) runWith(args map[string]any, exclude readExcluder) Result {
|
||||
}
|
||||
}
|
||||
|
||||
files, err := grepFiles(resolvedRoot, target, globMatcher, exclude)
|
||||
files, err := grepFiles(ctx, resolvedRoot, target, globMatcher, exclude)
|
||||
if err != nil {
|
||||
if res, ok := searchCancelledResult("grep", err); ok {
|
||||
return res
|
||||
}
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
|
||||
// resolveScopedPath returns an absolute displayRoot when the target resolved
|
||||
// to an extra (non-workspace) granted root; emit absolute match paths there so
|
||||
// they survive a round-trip through read_file/edit_file (mirrors glob).
|
||||
matches := collectGrepMatches(resolvedRoot, filepath.IsAbs(displayRoot), files, compiled)
|
||||
matches, err := collectGrepMatches(ctx, resolvedRoot, filepath.IsAbs(displayRoot), files, compiled)
|
||||
if err != nil {
|
||||
if res, ok := searchCancelledResult("grep", err); ok {
|
||||
return res
|
||||
}
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
if outputMode == "count" {
|
||||
return okResult("0 matches found")
|
||||
@@ -261,7 +270,7 @@ func confineGrepFile(resolvedRoot string, path string) (string, string, bool) {
|
||||
return filepath.ToSlash(relative), resolved, true
|
||||
}
|
||||
|
||||
func grepFiles(resolvedRoot string, target string, globMatcher *regexp.Regexp, exclude readExcluder) ([]string, error) {
|
||||
func grepFiles(ctx context.Context, resolvedRoot string, target string, globMatcher *regexp.Regexp, exclude readExcluder) ([]string, error) {
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -288,6 +297,13 @@ func grepFiles(resolvedRoot string, target string, globMatcher *regexp.Regexp, e
|
||||
|
||||
files := []string{}
|
||||
err = filepath.WalkDir(target, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
// Checked first, ahead of walkErr: an unscoped search over a large tree
|
||||
// (e.g. a broad parent directory, not just the workspace) can run long
|
||||
// enough that cancelling the run must stop the walk promptly rather than
|
||||
// visiting every remaining entry to completion.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if walkErr != nil {
|
||||
if path == target {
|
||||
return walkErr
|
||||
@@ -342,9 +358,16 @@ func grepFiles(resolvedRoot string, target string, globMatcher *regexp.Regexp, e
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func collectGrepMatches(resolvedRoot string, absolutePaths bool, files []string, compiled *regexp.Regexp) []grepMatch {
|
||||
func collectGrepMatches(ctx context.Context, resolvedRoot string, absolutePaths bool, files []string, compiled *regexp.Regexp) ([]grepMatch, error) {
|
||||
matches := []grepMatch{}
|
||||
for _, file := range files {
|
||||
// Checked per file, matching the walk's per-entry check in grepFiles: a
|
||||
// broad search can already have collected many files before the run is
|
||||
// cancelled, and reading/regex-scanning each one is real work that must
|
||||
// stop promptly rather than running to completion.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return matches, err
|
||||
}
|
||||
// Re-confine at read time (defense-in-depth) AND to compute the clean
|
||||
// workspace-relative path used in output.
|
||||
relative, resolvedPath, ok := confineGrepFile(resolvedRoot, file)
|
||||
@@ -381,5 +404,5 @@ func collectGrepMatches(resolvedRoot string, absolutePaths bool, files []string,
|
||||
})
|
||||
}
|
||||
}
|
||||
return matches
|
||||
return matches, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
// inlineDiagnostics renders the post-write diagnostics block a mutating tool
|
||||
// appends to its output. Empty when no Diagnostics callback is wired, the file
|
||||
// is clean, or no language server is available — the tool output is then
|
||||
// byte-identical to the pre-diagnostics behavior.
|
||||
func inlineDiagnostics(ctx context.Context, options RunOptions, absolutePath, relativePath string) string {
|
||||
if options.Diagnostics == nil {
|
||||
return ""
|
||||
}
|
||||
block := options.Diagnostics(ctx, absolutePath)
|
||||
if block == "" {
|
||||
return ""
|
||||
}
|
||||
return "\n\nDiagnostics in " + relativePath + " after this change (fix any errors you introduced):\n" + block
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A wired Diagnostics callback must surface its block in the tool output for
|
||||
// both mutating tools, and a clean file (empty block) must leave the output
|
||||
// byte-identical to the pre-diagnostics behavior.
|
||||
func TestMutatingToolsAppendInlineDiagnostics(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostics := func(_ context.Context, absPath string) string {
|
||||
return absPath + ":1:1: error: undefined: x"
|
||||
}
|
||||
|
||||
editResult := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"old_string": "package a",
|
||||
"new_string": "package b",
|
||||
}, RunOptions{Diagnostics: diagnostics})
|
||||
if editResult.Status != StatusOK {
|
||||
t.Fatalf("edit failed: %q", editResult.Output)
|
||||
}
|
||||
if !strings.Contains(editResult.Output, "Diagnostics in a.go after this change") || !strings.Contains(editResult.Output, "undefined: x") {
|
||||
t.Fatalf("edit output missing diagnostics block: %q", editResult.Output)
|
||||
}
|
||||
|
||||
writeResult := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "b.go",
|
||||
"content": "package b\n",
|
||||
}, RunOptions{Diagnostics: diagnostics})
|
||||
if writeResult.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", writeResult.Output)
|
||||
}
|
||||
if !strings.Contains(writeResult.Output, "Diagnostics in b.go after this change") {
|
||||
t.Fatalf("write output missing diagnostics block: %q", writeResult.Output)
|
||||
}
|
||||
|
||||
clean := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "c.go",
|
||||
"content": "package c\n",
|
||||
}, RunOptions{Diagnostics: func(context.Context, string) string { return "" }})
|
||||
if strings.Contains(clean.Output, "Diagnostics") {
|
||||
t.Fatalf("clean file must not gain a diagnostics block: %q", clean.Output)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ type RunOptions struct {
|
||||
// tool-call progress in the specialist card. nil is a no-op (the default
|
||||
// for every non-Task tool).
|
||||
Progress func(streamjson.Event)
|
||||
// Diagnostics, when set, returns a formatted language-diagnostics block for
|
||||
// a file a mutating tool just wrote ("" when clean or no server available).
|
||||
// edit_file/write_file append it to their output so the model sees an error
|
||||
// it introduced in the same turn instead of waiting for a later verification
|
||||
// pass. nil disables inline diagnostics.
|
||||
Diagnostics func(ctx context.Context, absPath string) string
|
||||
}
|
||||
|
||||
type sandboxAwareTool interface {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func searchCancelledResult(tool string, err error) (Result, bool) {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return Result{Status: StatusError, Output: "Error: " + tool + " cancelled."}, true
|
||||
}
|
||||
return Result{}, false
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// countingCancelContext returns context.Canceled from Err() once it has been
|
||||
// called more than "remaining" times, letting a test deterministically
|
||||
// cancel a loop after a fixed number of iterations instead of relying on
|
||||
// timing. A context.WithTimeout test would be flaky under a loaded or
|
||||
// shared CI runner, exactly the kind of test PR #464 was criticized for
|
||||
// elsewhere (TestCollectRespectsDeadlineUnderContinuousOutput).
|
||||
type countingCancelContext struct {
|
||||
context.Context
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (c *countingCancelContext) Err() error {
|
||||
c.remaining--
|
||||
if c.remaining < 0 {
|
||||
return context.Canceled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildLargeSearchTree creates n files, each containing a grep-matchable
|
||||
// line, so a walk that does NOT respect cancellation would have plenty of
|
||||
// work left to do (and matches to find) after the very first entry.
|
||||
func buildLargeSearchTree(t *testing.T, n int) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for i := 0; i < n; i++ {
|
||||
path := filepath.Join(root, "file"+strconv.Itoa(i)+".txt")
|
||||
if err := os.WriteFile(path, []byte("needle\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// A cancelled run must stop the filesystem walk promptly instead of visiting
|
||||
// every remaining entry to completion. Before this fix, grep's Run/
|
||||
// RunWithSandbox discarded their context entirely, so cancelling the run
|
||||
// (Ctrl+C / /exit) had no effect on an in-flight, unscoped search: the walk
|
||||
// ran to completion regardless, and — because the TUI's exit path waits for
|
||||
// the cancelled run's own response before it can quit — the whole
|
||||
// application was stuck until the walk finished on its own.
|
||||
func TestGrepStopsWalkOnCancelledContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 500)
|
||||
tool := NewGrepTool(root)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // already cancelled before the walk starts
|
||||
|
||||
result := tool.Run(ctx, map[string]any{"pattern": "needle"})
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if result.Output != "Error: grep cancelled." {
|
||||
t.Fatalf("output = %q, want the cancellation message", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrepRunWithSandboxStopsWalkOnCancelledContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 500)
|
||||
tool := NewGrepTool(root)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
sandboxed := tool.(sandboxAwareTool)
|
||||
result := sandboxed.RunWithSandbox(ctx, map[string]any{"pattern": "needle"}, nil)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if result.Output != "Error: grep cancelled." {
|
||||
t.Fatalf("output = %q, want the cancellation message", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// grep with a live (non-cancelled) context must still work normally: this
|
||||
// fix only short-circuits on cancellation, it does not change matching.
|
||||
func TestGrepStillMatchesWithLiveContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 3)
|
||||
tool := NewGrepTool(root)
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"pattern": "needle"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("status = %q, want ok; output=%q", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// Before this fix, only grepFiles' walk checked ctx: once the walk had
|
||||
// finished discovering candidate files, collectGrepMatches read and
|
||||
// regex-scanned every one of them to completion regardless of cancellation.
|
||||
// This proves the match-collection phase itself stops partway through,
|
||||
// not only when the context was already cancelled before the walk started.
|
||||
// grepFiles must stop mid-walk once ctx is cancelled, not only when the
|
||||
// context was already cancelled before WalkDir starts.
|
||||
func TestGrepFilesStopsMidWalkOnCancelledContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 50)
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks: %v", err)
|
||||
}
|
||||
|
||||
const allowed = 5
|
||||
ctx := &countingCancelContext{Context: context.Background(), remaining: allowed}
|
||||
files, err := grepFiles(ctx, resolvedRoot, root, nil, readExcluder{})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
if len(files) >= 50 {
|
||||
t.Fatalf("files = %d; walk should stop mid-traversal instead of visiting every entry", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrepCollectMatchesStopsMidCollectionOnCancelledContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 50)
|
||||
files := make([]string, 50)
|
||||
for i := range files {
|
||||
files[i] = filepath.Join(root, "file"+strconv.Itoa(i)+".txt")
|
||||
}
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks: %v", err)
|
||||
}
|
||||
compiled := regexp.MustCompile("needle")
|
||||
|
||||
const allowed = 5
|
||||
ctx := &countingCancelContext{Context: context.Background(), remaining: allowed}
|
||||
matches, err := collectGrepMatches(ctx, resolvedRoot, false, files, compiled)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
if len(matches) != allowed {
|
||||
t.Fatalf("matches = %d, want exactly %d collected before cancellation stopped the loop", len(matches), allowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanGlobStopsMidWalkOnCancelledContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 50)
|
||||
matcher, err := compileGlob("**/*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("compileGlob: %v", err)
|
||||
}
|
||||
|
||||
const allowed = 5
|
||||
ctx := &countingCancelContext{Context: context.Background(), remaining: allowed}
|
||||
matches, err := scanGlob(ctx, root, root, matcher, false, readExcluder{})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
if len(matches) >= 50 {
|
||||
t.Fatalf("matches = %d; walk should stop mid-traversal instead of visiting every entry", len(matches))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobStopsWalkOnCancelledContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 500)
|
||||
tool := NewGlobTool(root)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
result := tool.Run(ctx, map[string]any{"pattern": "**/*.txt"})
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if result.Output != "Error: glob cancelled." {
|
||||
t.Fatalf("output = %q, want the cancellation message", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobRunWithSandboxStopsWalkOnCancelledContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 500)
|
||||
tool := NewGlobTool(root)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
sandboxed := tool.(sandboxAwareTool)
|
||||
result := sandboxed.RunWithSandbox(ctx, map[string]any{"pattern": "**/*.txt"}, nil)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if result.Output != "Error: glob cancelled." {
|
||||
t.Fatalf("output = %q, want the cancellation message", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobStillMatchesWithLiveContext(t *testing.T) {
|
||||
root := buildLargeSearchTree(t, 3)
|
||||
tool := NewGlobTool(root)
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"pattern": "**/*.txt"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("status = %q, want ok; output=%q", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ type shellIssue struct {
|
||||
var (
|
||||
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
|
||||
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
|
||||
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
|
||||
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
|
||||
// which sound similar enough that a model might reach for the POSIX name
|
||||
// instead) — most often seen piped in, e.g. `git log ... | head`.
|
||||
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
|
||||
)
|
||||
|
||||
func detectShellRuntime(goos string) shellRuntime {
|
||||
@@ -57,6 +62,13 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
|
||||
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
|
||||
}
|
||||
}
|
||||
if windowsPosixUtilityPattern.MatchString(trimmed) {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
|
||||
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
// Spill-to-disk for truncated tool output. When a command produces more than
|
||||
// its context budget, the tail was previously simply gone — the model's only
|
||||
// recourse was re-running the (possibly expensive or non-idempotent) command
|
||||
// with a bigger budget. Instead, the full output is written to a cache file
|
||||
// whose path is included in the truncation notice, so the model can grep or
|
||||
// read_file the remainder. Spilling is best-effort: on any error truncation
|
||||
// behaves exactly as before, just without the file hint.
|
||||
|
||||
// spillRetention is how long spilled outputs are kept; files older than this
|
||||
// are opportunistically removed whenever a new spill happens.
|
||||
const spillRetention = 7 * 24 * time.Hour
|
||||
|
||||
// spillDir returns the shared spill directory, creating it on first use.
|
||||
func spillDir() (string, error) {
|
||||
dir := filepath.Join(os.TempDir(), "zero-tool-output")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// spillTruncatedOutput writes the full pre-truncation output to the spill
|
||||
// directory and returns the file path, or "" when spilling fails. Output is
|
||||
// scrubbed with the same configured-key redaction the registry applies at the
|
||||
// tool boundary, so a spilled file never holds a secret the transcript would
|
||||
// have hidden.
|
||||
func spillTruncatedOutput(toolName, output string) string {
|
||||
dir, err := spillDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sweepSpillDir(dir)
|
||||
prefix := strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, toolName)
|
||||
file, err := os.CreateTemp(dir, prefix+"-*.txt")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
scrubbed := redaction.RedactString(output, redaction.Options{})
|
||||
if _, err := file.WriteString(scrubbed); err != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
return ""
|
||||
}
|
||||
return file.Name()
|
||||
}
|
||||
|
||||
// sweepSpillDir removes spill files older than spillRetention. Best-effort:
|
||||
// errors are ignored, the directory is small, and a sweep runs only when a new
|
||||
// spill is about to happen anyway.
|
||||
func sweepSpillDir(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-spillRetention)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTruncateExecOutputSpillWritesFullOutput(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
long := strings.Repeat("line of build output\n", 5000) // ~100KB > 40KB budget
|
||||
|
||||
truncated, wasTruncated := truncateExecOutputSpill(long, defaultMaxOutputTokens, "bash")
|
||||
if !wasTruncated {
|
||||
t.Fatal("output over budget must truncate")
|
||||
}
|
||||
if !strings.Contains(truncated, "full output saved to ") {
|
||||
t.Fatalf("truncation notice missing spill path: %q", truncated[:200])
|
||||
}
|
||||
// Extract the path and verify the file holds the complete output.
|
||||
start := strings.Index(truncated, "full output saved to ") + len("full output saved to ")
|
||||
end := strings.Index(truncated[start:], " (grep")
|
||||
spillPath := truncated[start : start+end]
|
||||
content, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if string(content) != long {
|
||||
t.Fatalf("spill file must hold the full output: got %d bytes, want %d", len(content), len(long))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateExecOutputUnderBudgetUnchanged(t *testing.T) {
|
||||
output, wasTruncated := truncateExecOutputSpill("short output", defaultMaxOutputTokens, "bash")
|
||||
if wasTruncated || output != "short output" {
|
||||
t.Fatalf("under-budget output must pass through untouched: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBashOutputCapsVerboseOutput(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
long := strings.Repeat("x", 200*1024)
|
||||
formatted := formatBashOutput(long, "", 0)
|
||||
if len(formatted) > defaultMaxOutputTokens*4+1024 {
|
||||
t.Fatalf("bash output must be capped near the token budget, got %d bytes", len(formatted))
|
||||
}
|
||||
if !strings.Contains(formatted, "output truncated") {
|
||||
t.Fatal("capped bash output must carry the truncation notice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldFile := filepath.Join(dir, "bash-old.txt")
|
||||
newFile := filepath.Join(dir, "bash-new.txt")
|
||||
for _, path := range []string{oldFile, newFile} {
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stale := time.Now().Add(-spillRetention - time.Hour)
|
||||
if err := os.Chtimes(oldFile, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sweepSpillDir(dir)
|
||||
|
||||
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
||||
t.Fatal("stale spill file must be removed")
|
||||
}
|
||||
if _, err := os.Stat(newFile); err != nil {
|
||||
t.Fatal("fresh spill file must survive the sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpillTruncatedOutputWritesFile(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
path := spillTruncatedOutput("exec_command", "some output body")
|
||||
if path == "" {
|
||||
t.Fatal("spill must return a file path")
|
||||
}
|
||||
if base := filepath.Base(path); !strings.HasPrefix(base, "exec_command-") {
|
||||
t.Fatalf("spill file name must carry the tool prefix: %s", base)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "some output body" {
|
||||
t.Fatalf("unexpected spill content: %q", content)
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWebFetchMaxBytes = 64 * 1024
|
||||
maxWebFetchMaxBytes = 512 * 1024
|
||||
webFetchTimeout = 10 * time.Second
|
||||
// Raw-body budgets. HTML responses are converted to markdown before they
|
||||
// reach the model (see web_fetch_markdown.go), so a generous raw budget
|
||||
// does not translate into a generous context cost — conversion typically
|
||||
// shrinks a page by an order of magnitude. 64KiB of raw HTML often held
|
||||
// nothing but a page's <head>, which starved research tasks.
|
||||
defaultWebFetchMaxBytes = 256 * 1024
|
||||
maxWebFetchMaxBytes = 2 * 1024 * 1024
|
||||
webFetchTimeout = 30 * time.Second
|
||||
webFetchRedirectLimit = 5
|
||||
webFetchErrorBodyLimit = 4 * 1024
|
||||
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply."
|
||||
@@ -115,11 +120,17 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
|
||||
},
|
||||
"max_bytes": {
|
||||
Type: "integer",
|
||||
Description: "Maximum response body bytes to return.",
|
||||
Description: "Maximum raw response body bytes to download before conversion.",
|
||||
Default: defaultWebFetchMaxBytes,
|
||||
Minimum: intPtr(1),
|
||||
Maximum: intPtr(maxWebFetchMaxBytes),
|
||||
},
|
||||
"format": {
|
||||
Type: "string",
|
||||
Description: "auto (default): HTML responses are converted to compact markdown, everything else is returned as-is. raw: never convert. markdown: force conversion.",
|
||||
Enum: []string{"auto", "raw", "markdown"},
|
||||
Default: "auto",
|
||||
},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
AdditionalProperties: false,
|
||||
@@ -170,6 +181,18 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
||||
}
|
||||
format, err := stringArg(args, "format", "auto", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
||||
}
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
switch format {
|
||||
case "", "auto":
|
||||
format = "auto"
|
||||
case "raw", "markdown":
|
||||
default:
|
||||
return errorResult(`Error: Invalid arguments for web_fetch: format must be "auto", "raw", or "markdown".`)
|
||||
}
|
||||
if err := validateWebFetchURLBeforePermission(rawURL); err != nil {
|
||||
return errorResult("Error: Unsafe URL for web_fetch: " + err.Error())
|
||||
}
|
||||
@@ -224,14 +247,28 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
}
|
||||
body = redactWebFetchText(body)
|
||||
|
||||
output := strings.Join([]string{
|
||||
// HTML responses are converted to compact markdown by default: raw HTML is
|
||||
// mostly markup, so conversion typically shrinks the page by an order of
|
||||
// magnitude and the model reads content instead of boilerplate. format=raw
|
||||
// is the escape hatch for pages the converter mangles.
|
||||
converted := false
|
||||
if format == "markdown" || (format == "auto" && looksLikeHTML(contentType, body)) {
|
||||
if markdown := htmlToMarkdown(body); markdown != "" {
|
||||
body = markdown
|
||||
converted = true
|
||||
}
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
"URL: " + finalURL,
|
||||
"Status: " + webFetchStatusLine(response),
|
||||
"Content-Type: " + firstNonEmptyString(contentType, "unknown"),
|
||||
"Bytes: " + strconv.Itoa(len(body)),
|
||||
"",
|
||||
body,
|
||||
}, "\n")
|
||||
}
|
||||
if converted {
|
||||
headers = append(headers, "Converted: html -> markdown (pass format: \"raw\" for the original HTML)")
|
||||
}
|
||||
output := strings.Join(append(headers, "", body), "\n")
|
||||
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
@@ -243,6 +280,7 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
"content_type": contentType,
|
||||
"bytes": strconv.Itoa(len(body)),
|
||||
"truncated": strconv.FormatBool(truncated),
|
||||
"converted": strconv.FormatBool(converted),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTML-to-markdown conversion for web_fetch. Raw HTML is mostly boilerplate —
|
||||
// on a typical page well under a tenth of the bytes are readable content — so
|
||||
// returning it raw wastes nearly the whole response budget on markup. The
|
||||
// converter is deliberately dependency-free (regex passes + stdlib entity
|
||||
// unescaping, no HTML parser): the output feeds a language model, not a
|
||||
// browser, so best-effort structure (headings, links, list markers) is enough,
|
||||
// and the format=raw escape hatch covers pages it mangles.
|
||||
|
||||
var (
|
||||
webFetchDropBlockRe = regexp.MustCompile(`(?is)<(script|style|head|noscript|svg|template|iframe)\b[^>]*>.*?</\s*(?:script|style|head|noscript|svg|template|iframe)\s*>`)
|
||||
webFetchCommentRe = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
webFetchAnchorRe = regexp.MustCompile(`(?is)<a\b[^>]*?href\s*=\s*["']?([^"'\s>]+)["']?[^>]*>(.*?)</\s*a\s*>`)
|
||||
webFetchHeadingRe = regexp.MustCompile(`(?is)<h([1-6])[^>]*>(.*?)</\s*h[1-6]\s*>`)
|
||||
webFetchListItemRe = regexp.MustCompile(`(?i)<li\b[^>]*>`)
|
||||
webFetchBreakRe = regexp.MustCompile(`(?i)<(?:br|hr)\s*/?>`)
|
||||
webFetchBlockTagRe = regexp.MustCompile(`(?i)</?(?:p|div|section|article|main|header|footer|nav|aside|table|tr|ul|ol|blockquote|pre|figure|form)\b[^>]*>`)
|
||||
webFetchAnyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
webFetchSpaceRunRe = regexp.MustCompile(`[ \t]+`)
|
||||
webFetchBlankRunRe = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
// htmlToMarkdown converts an HTML document to compact markdown-flavored text:
|
||||
// headings become #-prefixed lines, anchors become [text](href), list items
|
||||
// become "- " bullets, block boundaries become blank lines, all other markup
|
||||
// is stripped, and entities are unescaped.
|
||||
func htmlToMarkdown(body string) string {
|
||||
text := webFetchCommentRe.ReplaceAllString(body, " ")
|
||||
text = webFetchDropBlockRe.ReplaceAllString(text, " ")
|
||||
text = webFetchHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||
groups := webFetchHeadingRe.FindStringSubmatch(match)
|
||||
level := int(groups[1][0] - '0')
|
||||
if level < 1 || level > 6 {
|
||||
level = 1
|
||||
}
|
||||
title := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
|
||||
return "\n\n" + strings.Repeat("#", level) + " " + title + "\n\n"
|
||||
})
|
||||
text = webFetchAnchorRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||
groups := webFetchAnchorRe.FindStringSubmatch(match)
|
||||
href := groups[1]
|
||||
label := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
|
||||
if label == "" {
|
||||
return " "
|
||||
}
|
||||
if strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") {
|
||||
return label
|
||||
}
|
||||
return fmt.Sprintf("[%s](%s)", label, href)
|
||||
})
|
||||
text = webFetchListItemRe.ReplaceAllString(text, "\n- ")
|
||||
text = webFetchBreakRe.ReplaceAllString(text, "\n")
|
||||
text = webFetchBlockTagRe.ReplaceAllString(text, "\n\n")
|
||||
text = webFetchAnyTagRe.ReplaceAllString(text, " ")
|
||||
text = html.UnescapeString(text)
|
||||
|
||||
// Whitespace normalization: collapse space runs, trim line edges, cap blank
|
||||
// runs at one empty line.
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimSpace(webFetchSpaceRunRe.ReplaceAllString(line, " "))
|
||||
}
|
||||
text = strings.Join(lines, "\n")
|
||||
text = webFetchBlankRunRe.ReplaceAllString(text, "\n\n")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// looksLikeHTML reports whether a response should be treated as an HTML
|
||||
// document, by Content-Type first and a body sniff as fallback (servers
|
||||
// mislabel HTML as text/plain often enough to matter).
|
||||
func looksLikeHTML(contentType, body string) bool {
|
||||
lowered := strings.ToLower(contentType)
|
||||
if strings.Contains(lowered, "text/html") || strings.Contains(lowered, "application/xhtml") {
|
||||
return true
|
||||
}
|
||||
if lowered != "" && !strings.Contains(lowered, "text/plain") {
|
||||
return false
|
||||
}
|
||||
head := strings.ToLower(strings.TrimSpace(body))
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
return strings.Contains(head, "<!doctype html") || strings.Contains(head, "<html")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleHTML = `<!DOCTYPE html>
|
||||
<html><head><title>ignored</title><style>body{color:red}</style></head>
|
||||
<body>
|
||||
<script>var tracking = "noise";</script>
|
||||
<h1>Main Title</h1>
|
||||
<p>First paragraph with a <a href="https://example.com/docs">docs link</a> and & entity.</p>
|
||||
<ul><li>alpha</li><li>beta</li></ul>
|
||||
<!-- hidden comment -->
|
||||
<h2>Section</h2>
|
||||
<p>Body with extra spaces.</p>
|
||||
</body></html>`
|
||||
|
||||
func TestHTMLToMarkdown(t *testing.T) {
|
||||
markdown := htmlToMarkdown(sampleHTML)
|
||||
|
||||
for _, want := range []string{
|
||||
"# Main Title",
|
||||
"## Section",
|
||||
"[docs link](https://example.com/docs)",
|
||||
"- alpha",
|
||||
"- beta",
|
||||
"with a", // paragraph text survives
|
||||
"& entity",
|
||||
} {
|
||||
if !strings.Contains(markdown, want) {
|
||||
t.Fatalf("markdown missing %q:\n%s", want, markdown)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"tracking", "color:red", "hidden comment", "<p>", "</html>", "ignored"} {
|
||||
if strings.Contains(markdown, banned) {
|
||||
t.Fatalf("markdown must not contain %q:\n%s", banned, markdown)
|
||||
}
|
||||
}
|
||||
if strings.Contains(markdown, "extra spaces") {
|
||||
t.Fatalf("space runs must collapse:\n%s", markdown)
|
||||
}
|
||||
if strings.Contains(markdown, "\n\n\n") {
|
||||
t.Fatalf("blank-line runs must be capped:\n%s", markdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
contentType string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"text/html; charset=utf-8", "anything", true},
|
||||
{"application/xhtml+xml", "anything", true},
|
||||
{"application/json", `{"html":"<html>"}`, false},
|
||||
{"text/plain", "<!doctype html><html>", true}, // mislabeled HTML sniffed
|
||||
{"text/plain", "just text", false},
|
||||
{"", "<html><body>x</body></html>", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := looksLikeHTML(c.contentType, c.body); got != c.want {
|
||||
t.Errorf("looksLikeHTML(%q, %.20q) = %v, want %v", c.contentType, c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchConvertsHTMLByDefault(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/html; charset=utf-8", sampleHTML), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "# Main Title") || strings.Contains(result.Output, "<h1>") {
|
||||
t.Fatalf("HTML must convert to markdown by default:\n%s", result.Output)
|
||||
}
|
||||
if result.Meta["converted"] != "true" {
|
||||
t.Fatalf("converted meta must be true: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchRawFormatSkipsConversion(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/html", sampleHTML), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page", "format": "raw"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "<h1>Main Title</h1>") {
|
||||
t.Fatalf("format=raw must keep original HTML:\n%s", result.Output)
|
||||
}
|
||||
if result.Meta["converted"] != "false" {
|
||||
t.Fatalf("converted meta must be false: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchLeavesNonHTMLUntouched(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "application/json", `{"key":"<b>value</b>"}`), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/api"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, `{"key":"<b>value</b>"}`) {
|
||||
t.Fatalf("non-HTML must pass through untouched:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchRejectsInvalidFormat(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/plain", "x"), nil
|
||||
}))
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com", "format": "xml"})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "format must be") {
|
||||
t.Fatalf("invalid format must be rejected: %s %s", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func (tool writeFileTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
}
|
||||
|
||||
func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
||||
func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
|
||||
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
||||
@@ -110,6 +110,10 @@ func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
content = maybeFormatWrittenFile(ctx, absolutePath, content)
|
||||
// Baseline the freshly written content so a later edit/overwrite in this
|
||||
// session compares against what is now on disk.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
@@ -126,6 +130,7 @@ func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
lines++
|
||||
}
|
||||
summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines)
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
// Card-only preview: a real unified diff (all-green for a create, red/green for
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestParseBindingLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string // expected Label()
|
||||
}{
|
||||
{"option+o", "Alt+O"},
|
||||
{"ctrl+o", "Ctrl+O"},
|
||||
{"ctrl+e", "Ctrl+E"},
|
||||
{"option+b", "Alt+B"},
|
||||
{"option+O", "Alt+O"},
|
||||
{"alt+o", "Alt+O"},
|
||||
{"cmd+o", "Cmd+O"},
|
||||
{"super+o", "Cmd+O"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
p := parseBinding(tt.input)
|
||||
got := p.Label()
|
||||
if got != tt.want {
|
||||
t.Errorf("parseBinding(%q).Label() = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
if p.isZero() {
|
||||
t.Errorf("parseBinding(%q).isZero() = true, want false", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionOMatchesOptionO(t *testing.T) {
|
||||
p := parseBinding("option+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// Simulate iTerm2 with "Option as Meta" pressing Option+O
|
||||
// Terminal sends ESC o → KeyPressEvent{Code: 'o', Mod: ModAlt}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
|
||||
|
||||
if !matcher(msg) {
|
||||
t.Errorf("option+o matcher should match {Code:'o', Mod:ModAlt}")
|
||||
}
|
||||
|
||||
// Should NOT match plain 'o'
|
||||
msg2 := tea.KeyPressMsg(tea.Key{Code: 'o'})
|
||||
if matcher(msg2) {
|
||||
t.Errorf("option+o matcher should NOT match {Code:'o'} without ModAlt")
|
||||
}
|
||||
|
||||
// Should NOT match ctrl+o
|
||||
msg3 := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if matcher(msg3) {
|
||||
t.Errorf("option+o matcher should NOT match {Code:'o', Mod:ModCtrl}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionOMatchesComposedCharacters(t *testing.T) {
|
||||
p := parseBinding("option+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// On macOS without "Option as Meta", Option+O produces ø (U+00F8)
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 0xF8, Text: "ø"})
|
||||
if matcher(msg) {
|
||||
t.Logf("NOTE: option+o matcher also matches option+ø (macOS compose behavior)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlEMatchesDefault(t *testing.T) {
|
||||
p := parseBinding("ctrl+e")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// Terminal sends Ctrl+E → byte 0x05 → KeyPressEvent{Code: 'e', Mod: ModCtrl}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl})
|
||||
|
||||
if !matcher(msg) {
|
||||
t.Errorf("ctrl+e matcher should match {Code:'e', Mod:ModCtrl}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlBindingRejectsSupersetModifiers(t *testing.T) {
|
||||
// Configured ctrl+e must NOT match ctrl+alt+e or ctrl+shift+e
|
||||
// (Mod.Contains superset correctness for the ctrl-letter fast path).
|
||||
p := parseBinding("ctrl+e")
|
||||
matcher := p.Matcher()
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"ctrl+alt+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt})},
|
||||
{"ctrl+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModShift})},
|
||||
{"ctrl+alt+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt | tea.ModShift})},
|
||||
{"ctrl+cmd+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModSuper})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("ctrl+e matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltBindingRejectsSupersetModifiers(t *testing.T) {
|
||||
// Configured alt+o must NOT match alt+shift+o (code path, exact mod).
|
||||
p := parseBinding("alt+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"alt+shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModShift})},
|
||||
{"alt+ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModCtrl})},
|
||||
{"alt+cmd+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModSuper})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("alt+o matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainKeyBindingRejectsModifiers(t *testing.T) {
|
||||
// A configured plain-key binding (no modifiers, e.g. "o") should only
|
||||
// match when Mod is exactly 0, not when a modifier is held.
|
||||
p := parseBinding("o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
plain := tea.KeyPressMsg(tea.Key{Code: 'o'})
|
||||
if !matcher(plain) {
|
||||
t.Errorf("'o' matcher should match {Code:'o'} with no modifiers")
|
||||
}
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})},
|
||||
{"alt+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})},
|
||||
{"shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModShift})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("'o' matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBindingUTF8Character(t *testing.T) {
|
||||
// A 2-byte UTF-8 key like é (U+00E9) should parse as a single character,
|
||||
// not be rejected by a byte-length check.
|
||||
p := parseBinding("é")
|
||||
if p.isZero() {
|
||||
t.Errorf("parseBinding('é') should not be zero — it's a valid single character")
|
||||
}
|
||||
if p.code != 'é' {
|
||||
t.Errorf("parseBinding('é').code = %d (U+%04X), want %d (U+00E9)", p.code, p.code, 'é')
|
||||
}
|
||||
if p.text != "" {
|
||||
t.Errorf("parseBinding('é').text = %q, want empty string", p.text)
|
||||
}
|
||||
|
||||
// Also verify the matcher works
|
||||
matcher := p.Matcher()
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é"})
|
||||
if !matcher(msg) {
|
||||
t.Errorf("'é' matcher should match {Code:'é', Text:'é'}")
|
||||
}
|
||||
|
||||
// Should not match with a modifier
|
||||
msgAlt := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é", Mod: tea.ModAlt})
|
||||
if matcher(msgAlt) {
|
||||
t.Errorf("'é' matcher should NOT match with modifier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRejectsSupersetModifiers(t *testing.T) {
|
||||
// Integration: model.keyMatch with a configured binding must reject
|
||||
// modifier supersets.
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+o",
|
||||
}
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
defaultFn := func(tea.KeyMsg) bool { return false }
|
||||
m := model{keyBindings: bindings}
|
||||
|
||||
// Ctrl+O should match
|
||||
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if !m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
|
||||
t.Errorf("keyMatch should match ctrl+o when configured")
|
||||
}
|
||||
|
||||
// Ctrl+Alt+O should NOT match
|
||||
msgCtrlAltO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl | tea.ModAlt})
|
||||
if m.keyMatch(bindings.toggleDetailed, msgCtrlAltO, defaultFn) {
|
||||
t.Errorf("keyMatch should NOT match ctrl+alt+o when ctrl+o is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigToBindingPipeline(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
ToggleMouse: "ctrl+e",
|
||||
CycleReasoning: "ctrl+t",
|
||||
TogglePlan: "ctrl+p",
|
||||
ToggleSidebar: "option+b",
|
||||
}
|
||||
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
binding parsedBinding
|
||||
wantKey string
|
||||
}{
|
||||
{"toggleDetailed", bindings.toggleDetailed, "Alt+O"},
|
||||
{"toggleMouse", bindings.toggleMouse, "Ctrl+E"},
|
||||
{"cycleReasoning", bindings.cycleReasoning, "Ctrl+T"},
|
||||
{"togglePlan", bindings.togglePlan, "Ctrl+P"},
|
||||
{"toggleSidebar", bindings.toggleSidebar, "Alt+B"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.binding.isZero() {
|
||||
t.Errorf("%s binding is zero (unset) — config not loaded", tt.name)
|
||||
}
|
||||
if got := tt.binding.Label(); got != tt.wantKey {
|
||||
t.Errorf("%s.Label() = %q, want %q", tt.name, got, tt.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpBindingSubstitution(t *testing.T) {
|
||||
// Verify that the help overlay correctly shows user-configured bindings
|
||||
// by checking that Label() returns non-empty for config-sourced bindings.
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
ToggleSidebar: "option+b",
|
||||
}
|
||||
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
if l := bindings.toggleDetailed.Label(); l != "Alt+O" {
|
||||
t.Errorf("toggleDetailed.Label() = %q, want %q", l, "Alt+O")
|
||||
}
|
||||
if l := bindings.toggleSidebar.Label(); l != "Alt+B" {
|
||||
t.Errorf("toggleSidebar.Label() = %q, want %q", l, "Alt+B")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchOptionO is an integration-style test: it verifies that pressing
|
||||
// Option+O dispatches as toggleDetailed when configured.
|
||||
func TestDispatchOptionO(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
}
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
// Simulate Option+O in iTerm2 with "Option as Meta" → ESC o → {Code:'o', Mod:ModAlt}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
|
||||
defaultFn := func(tea.KeyMsg) bool { return false }
|
||||
|
||||
// This is the dispatch check the model uses
|
||||
m := model{keyBindings: bindings}
|
||||
if !m.keyMatch(bindings.toggleDetailed, msg, defaultFn) {
|
||||
t.Errorf("model.keyMatch should match option+o")
|
||||
}
|
||||
|
||||
// Also verify default doesn't match
|
||||
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
|
||||
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
|
||||
}
|
||||
}
|
||||
@@ -377,7 +377,7 @@ func TestContextCommandCardHandlesNilRegistryAndStableStyle(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Context",
|
||||
"0 tools",
|
||||
"style balanced",
|
||||
"style concise",
|
||||
"root unknown",
|
||||
} {
|
||||
assertContains(t, text, want)
|
||||
|
||||
@@ -559,6 +559,21 @@ func (m model) modelText(args string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// avgTurnLatencyText reports the session's rolling average turn wall-time for
|
||||
// /context — the "is it slow?" signal a user otherwise can only feel. "n/a" until
|
||||
// a turn has completed.
|
||||
func (m model) avgTurnLatencyText() string {
|
||||
if m.turnLatencyCount == 0 {
|
||||
return "n/a"
|
||||
}
|
||||
avgSeconds := m.turnLatencySum.Seconds() / float64(m.turnLatencyCount)
|
||||
if m.turnTTFTCount > 0 {
|
||||
ttftSeconds := m.turnTTFTSum.Seconds() / float64(m.turnTTFTCount)
|
||||
return fmt.Sprintf("%.1fs avg (%.1fs to first token, %d turns)", avgSeconds, ttftSeconds, m.turnLatencyCount)
|
||||
}
|
||||
return fmt.Sprintf("%.1fs avg (%d turns)", avgSeconds, m.turnLatencyCount)
|
||||
}
|
||||
|
||||
func (m model) contextText() string {
|
||||
toolCount := len(m.registeredTools())
|
||||
return renderCommandCardTranscript(commandCard{
|
||||
@@ -578,6 +593,8 @@ func (m model) contextText() string {
|
||||
{Key: "effort", Value: m.effortDisplay()},
|
||||
{Key: "style", Value: displayValue(m.responseStyle, defaultResponseStyle)},
|
||||
{Key: "usage", Value: m.usageSummaryText()},
|
||||
{Key: "cache", Value: m.cacheEfficiencyText()},
|
||||
{Key: "latency", Value: m.avgTurnLatencyText()},
|
||||
{Key: "max turns", Value: fmt.Sprint(m.agentOptions.MaxTurns)},
|
||||
},
|
||||
},
|
||||
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user