Compare commits

..
Author SHA1 Message Date
anandh8x e387deb516 Test held-message eviction callback 2026-08-09 17:17:27 +05:30
anandh8x 7288f4e383 Use null handle for Windows close failure test 2026-08-09 16:56:41 +05:30
anandh8x 3d7f0bd7dd Propagate Windows peer handle cleanup errors 2026-08-09 16:43:41 +05:30
anandh8x 2622f6ec67 Validate Windows peer ACL semantics 2026-08-09 16:32:36 +05:30
anandh8x 854e0dc8f4 Pin Windows ACL trustee SIDs 2026-08-09 16:22:31 +05:30
anandh8x 9d24a22465 Build Windows peer ACL explicitly 2026-08-09 16:13:46 +05:30
anandh8x 243fee66a8 Accept equivalent Windows ACL masks 2026-08-09 16:02:35 +05:30
anandh8x 7bba7f6ec0 Handle Windows token-owned peer directories 2026-08-09 15:54:54 +05:30
anandh8x 25d7247a84 Fix Windows peer directory traversal 2026-08-09 15:37:10 +05:30
anandh8x 7b58cf2d97 Harden peer messaging lifecycle 2026-08-09 15:17:16 +05:30
anandh8x 9067aa3b79 Fix macOS socket path regression test
Create the canonicalization fixture beneath a guaranteed short Unix path so it exercises the primary endpoint on macOS, and report temporary-directory cleanup failures.
2026-08-09 14:39:31 +05:30
anandh8x e4f8b43f26 Support canonical macOS socket paths
Resolve Unix transport directories to their physical path before applying strict no-symlink traversal, allowing the standard /var to /private/var macOS alias without weakening runtime-directory validation.
2026-08-09 14:29:07 +05:30
anandh8x 82e4c8f482 Harden cross-session messaging
Authenticate sender metadata against live registry records, bound peer lifecycle state, preserve records across transient discovery failures, and make receipt delivery direct and bounded.

Also serialize TUI peer approvals, harden platform runtime paths and transport deadlines, widen session references, and add regression coverage for identity spoofing, queue limits, path boundaries, and tool failures.
2026-08-09 14:15:28 +05:30
anandh8x ad17751c12 Add cross-session messaging 2026-08-09 13:21:35 +05:30
198 changed files with 903 additions and 25786 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.7.0"}
{".":"0.6.0"}
-8
View File
@@ -91,14 +91,6 @@ These classes drive multi-round reviews. Fix them before requesting review:
final-component-only no-follow is insufficient. On multi-step setup, roll back
only what this run created; never destroy pre-existing resources you did
not create; never report success when cleanup or unlock failed.
- **Normalize before matching:** Any transform that REMOVES bytes without
leaving a gap is also a reassembler, so it has to run before whatever matches
on the result. Redacting by shape and then stripping control bytes lets a
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
fragments and be rejoined on the way out; comparing a path against a form
produced by the same resolver the kernel just used makes a redirect agree with
itself. Strip, decode, or canonicalize first, then match. The unsplit value
passing is not evidence, so the test needs a split case.
- **Atomic shared state:** Write a complete temporary file, then atomically
replace the destination so concurrent readers never see a partial write.
Exclusive create or a write lock alone is not enough for readers. Serialize
-36
View File
@@ -6,42 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
### Features
* **agent:** add PermissionModePlan for interactive read-only planning ([#853](https://github.com/Gitlawb/zero/issues/853)) ([0176ee4](https://github.com/Gitlawb/zero/commit/0176ee49ffddcff6f7edf6ae2ade4967e5b22cc3))
* **providers:** live model lists for OpenRouter and OpenGateway ([#860](https://github.com/Gitlawb/zero/issues/860)) ([fb4f293](https://github.com/Gitlawb/zero/commit/fb4f293c20eb860ca388a10d22fd566ce282ba1f))
* **tools:** give tool results an image channel and add view_image ([#843](https://github.com/Gitlawb/zero/issues/843)) ([cd0eb19](https://github.com/Gitlawb/zero/commit/cd0eb194e4df4b844785796c0701216740a56344))
* **tui:** replace /retitle with local /rename ([#826](https://github.com/Gitlawb/zero/issues/826)) ([5886354](https://github.com/Gitlawb/zero/commit/588635487f22ef4d62d350140a219b7b8bb24119))
### Bug Fixes
* **agent:** end the additional_permissions retry loop ([#864](https://github.com/Gitlawb/zero/issues/864)) ([4ca4fa7](https://github.com/Gitlawb/zero/commit/4ca4fa75d31b139dbe55a3badf17da86892ab001))
* **agent:** tell the user a prefix approval leaves the sandbox ([#885](https://github.com/Gitlawb/zero/issues/885)) ([2ec5c3a](https://github.com/Gitlawb/zero/commit/2ec5c3ae6b8fb543887bdeafdb34c4da057b8d36))
* **cli:** report MCP servers that failed to start ([#822](https://github.com/Gitlawb/zero/issues/822)) ([#827](https://github.com/Gitlawb/zero/issues/827)) ([021281e](https://github.com/Gitlawb/zero/commit/021281ebbab3f2bbe6a77b2dab4bf121bcfe6b52))
* **lsp:** dispatch notifications off read loop ([#759](https://github.com/Gitlawb/zero/issues/759)) ([936aa4e](https://github.com/Gitlawb/zero/commit/936aa4e77cf49738560a89395b8ba4d22263a396))
* **mcp:** name the non-text blocks a tool result drops ([#874](https://github.com/Gitlawb/zero/issues/874)) ([027a670](https://github.com/Gitlawb/zero/commit/027a67080bea511b876c284bc911ef011eceb308))
* **sandbox:** deny reads of Zero credential stores ([#681](https://github.com/Gitlawb/zero/issues/681)) ([55fa05f](https://github.com/Gitlawb/zero/commit/55fa05f0ab5a01b495345d43464606cd19a31b4f))
* **sandbox:** stop the Windows write jail honouring Everyone-granted paths ([#865](https://github.com/Gitlawb/zero/issues/865)) ([91b413c](https://github.com/Gitlawb/zero/commit/91b413c514cc1607d5b4e7936780d40569239814))
* **secrets,worktrees:** fix secret redaction leakage and prune stale worktrees ([#855](https://github.com/Gitlawb/zero/issues/855)) ([ff62f73](https://github.com/Gitlawb/zero/commit/ff62f73b7f4dc5e2472db21acba43cf6140c06d1))
* **specialist:** make overwrites atomic ([#757](https://github.com/Gitlawb/zero/issues/757)) ([30f6d8f](https://github.com/Gitlawb/zero/commit/30f6d8f591a0b81638b4182ff56c86c8a50a5c70))
* suppress gosec G204 warning for trusted local config commands ([#848](https://github.com/Gitlawb/zero/issues/848)) ([c52db05](https://github.com/Gitlawb/zero/commit/c52db055b0b213758a33c5c5627a411e052f8de4))
* **tui:** a copied NUL byte no longer panics the TUI on Windows ([#876](https://github.com/Gitlawb/zero/issues/876)) ([cae0269](https://github.com/Gitlawb/zero/commit/cae0269e77e7a0a493cf799b41c653c9ff9a19de)), closes [#875](https://github.com/Gitlawb/zero/issues/875)
* **tui:** exclude permission waits from turn time ([#820](https://github.com/Gitlawb/zero/issues/820)) ([15eb16f](https://github.com/Gitlawb/zero/commit/15eb16f736f903c94203207032f98aacbcf06318))
* **tui:** fall back to CellMotion where AllMotion is unreliable ([#872](https://github.com/Gitlawb/zero/issues/872)) ([7fbd864](https://github.com/Gitlawb/zero/commit/7fbd864e91433441489902571ea394ebc5b7bd0d))
* **tui:** fill alt-screen frame with theme panel background ([#850](https://github.com/Gitlawb/zero/issues/850)) ([020c28e](https://github.com/Gitlawb/zero/commit/020c28eaf95e2f38853f99fc06a13cd1568bf3c9))
* **tui:** hide unavailable sidebar shortcut ([#821](https://github.com/Gitlawb/zero/issues/821)) ([845df6c](https://github.com/Gitlawb/zero/commit/845df6c7d28e02f475e90acfe838733eb37e6aa2))
* use OAuth credentials in provider health checks ([#828](https://github.com/Gitlawb/zero/issues/828)) ([d37de92](https://github.com/Gitlawb/zero/commit/d37de9214b1ba86542f4f5ef3b060bb96849763b))
### Performance Improvements
* compact common test output ([#846](https://github.com/Gitlawb/zero/issues/846)) ([edf660a](https://github.com/Gitlawb/zero/commit/edf660ab85131f90a452cee51dc2c3a5cf62d632))
* improve harness context efficiency ([#838](https://github.com/Gitlawb/zero/issues/838)) ([8e26679](https://github.com/Gitlawb/zero/commit/8e266797a8305e689806eadcf9698da2796481ab))
## [0.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
+1 -6
View File
@@ -8,7 +8,6 @@
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
@@ -213,7 +212,6 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/new` | start a fresh session in place (previous session stays on disk) |
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
@@ -310,8 +308,7 @@ zero verify detect and run local verification checks
zero changes inspect and commit local git changes
zero usage token usage and estimated cost
zero cron scheduled agent jobs
zero update --check check for newer releases
zero upgrade download, verify, and install the latest release
zero update check for newer releases
```
## Extending Zero
@@ -418,8 +415,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
## Community
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
+1 -18
View File
@@ -8,7 +8,6 @@
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
@@ -142,7 +141,6 @@ zero
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
| `/image` | 为视觉模型附加图片 |
| `/resume`、`/rewind` | 继续或回滚本地会话 |
| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) |
| `/compact`、`/context` | 管理上下文使用 |
| `/permissions`、`/tools` | 检查可用工具和策略 |
| `/add-dir` | 为此会话授予额外的写入目录 |
@@ -228,8 +226,7 @@ zero verify 检测和运行本地验证检查
zero changes 检查和提交本地 Git 变更
zero usage Token 使用量和估算成本
zero cron 定时智能体任务
zero update --check 检查更新版本
zero upgrade 下载、验证并安装最新版本
zero update 检查更新版本
```
## 外观和无障碍
@@ -272,20 +269,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [性能](docs/PERFORMANCE.md)
- [智能体评估](docs/AGENT_EVALS.md)
## 社区
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
+2 -3
View File
@@ -234,11 +234,10 @@ directory.
## Updating
Check for a newer release, then download, verify, and install it in place:
Check for a newer release:
```bash
zero update --check
zero upgrade
```
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
Then reinstall with npm or rerun the install script for the version you want.
-42
View File
@@ -138,45 +138,3 @@ output or stop a still-running task by id.
If Zero is restarted while a background task is still marked `running`, the new
manager marks that task `error` and clears its PID. This avoids sending
`TaskStop` to a stale PID that may now belong to an unrelated process.
## Recovering an Interrupted Overwrite
Zero writes and flushes a complete temporary file before publishing an overwrite,
so a write failure before publication leaves the existing manifest unchanged
instead of truncating it. On Unix, publication uses a same-directory rename and
preserves the existing file's permission bits.
On Windows, Zero uses `ReplaceFileW` to preserve the destination DACL instead of
silently replacing it with the temporary file's inherited DACL. `ReplaceFileW`
is not observer-atomic: another process can briefly observe the destination path
as absent during replacement. Zero serializes specialist loads and managed
mutations within one process, but cannot synchronize external processes or
editors.
Windows errors 1176 (`ERROR_UNABLE_TO_MOVE_REPLACEMENT`) and 1177
(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) are partial replacement failures. With
Zero's managed backup, 1176 leaves the original names intact and needs no manual
recovery. For 1177, Zero has moved the original aside and tries to move it back.
That rollback almost always succeeds, and the failed write changes nothing.
If the rollback itself fails — typically because another process is holding a
lock on the file — the original is not lost, but it is left under a name Zero
does not read:
```text
<specialist dir>/.zero-replace-<random>.backup
```
Only `*.md` files are loaded as specialists, so until that file is renamed the
specialist will not appear in `zero specialist list` or resolve by name. Zero's
error message includes both the backup path and destination path. Recover by
closing whatever holds the lock and renaming the backup back:
```powershell
Move-Item .zero-replace-<random>.backup <name>.md
```
A `.zero-replace-*.backup` can also linger after a *successful* overwrite if the
backup could not be deleted afterward. That case is reported as a warning rather
than an error — the new manifest is already in place, and the leftover file is
safe to delete.
+7 -75
View File
@@ -1,23 +1,16 @@
# Update Flow
`zero update --check` checks the latest GitHub release and compares it with the
local CLI version. `zero update --apply` (or its shorthand, `zero upgrade`)
downloads, verifies, and installs it.
local CLI version.
```bash
zero update --check
zero update --check --json
zero update --check --repo Gitlawb/zero
zero update --check --target windows-x64
zero upgrade
zero update --apply
```
`--check` and `--apply` are mutually exclusive. `zero update` requires one of
them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--check` is check-only:
The command is intentionally check-only:
- It does not replace the running binary.
- It exits with code `0` when the check succeeds, even when an update is
@@ -26,35 +19,14 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- `--json` prints the same result in a machine-readable format for scripts and
CI.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
- On Windows, the running executable is renamed aside and cleaned up on the
next `zero update --apply` or `zero upgrade`, since it can't be overwritten
while running.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
takes its release from the npm registry, not from GitHub. They still apply to
`--check` there.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
also write progress output to stdout, so neither
`zero update --apply --json` nor `zero upgrade --json` is guaranteed to
produce a single parseable JSON document. Use `--check --json` for
machine-readable automation.
Useful flags:
| Flag | Purpose |
|---|---|
| `--repo <owner/repo>` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
| `--endpoint <url\|owner/repo>` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
| `--repo <owner/repo>` | Check another GitHub repository. |
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
| `--timeout <duration>` | Override the default release check timeout. |
| `--target <platform-arch>` | Validate release metadata for another supported target (`--check` only). |
| `--target <platform-arch>` | Validate release metadata for another supported target. |
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
@@ -68,45 +40,5 @@ Endpoint resolution order:
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
instead of reinstalling.
## Windows recovery state (standalone installs)
This section describes Windows only. On Linux and macOS a standalone update
renames the staged file directly over the executable path through the
installation directory's file descriptor, so the replacement is atomic and no
aside copy, marker, or recovery record is ever created — a failed update leaves
the previous binary in place and nothing to resolve.
On Windows, a running executable cannot be replaced in place, so the previous
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
records that exact file — bound to its filesystem identity, in per-user state
outside the installation directory — and deletes only that recorded copy after
the next update is verified in place. Backups it did not create are never
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
recorded copy that something else holds open (an on-access scanner, an editor)
stays recorded and is removed by a later update instead.
An update **refuses to run** while unresolved recovery state exists beside the
binary:
| State on disk | Meaning |
|---|---|
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
The refusal names the paths involved and the two moves that end the state:
either move the recovery binary back over the executable path, or — if the
installed binary is the one you want — delete the `.keep` marker (or the
`.recovery` copy, once you have verified the installed binary) and update again.
This is deliberately fail-closed and differs from older releases, which deleted
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
and make every subsequent update refuse until an operator clears them. That is
the safe direction: the alternative is an update that overwrites the only
verified copy of the previous binary. If updates start refusing, inspect those
files before removing them — an installation directory that a lower-privileged
account can write to is itself worth fixing.
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
before reinstalling.
+1 -2
View File
@@ -11,17 +11,16 @@ require (
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/coder/websocket v1.8.15
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/image v0.44.0
golang.org/x/sys v0.47.0
mvdan.cc/sh/v3 v3.13.1
)
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
-2
View File
@@ -64,8 +64,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+4 -14
View File
@@ -349,11 +349,9 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
case agent.PermissionModeAuto, agent.PermissionModeAsk:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
@@ -383,13 +381,9 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
return nil, err
}
case configIDMode:
// Same turnMu as handleSetMode so the two advertised mode doors (session
// set_mode and set_config_option) serialize mode flips consistently.
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
case agent.PermissionModeAuto, agent.PermissionModeAsk:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
@@ -445,16 +439,13 @@ func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
// ---- advertising helpers ----
func (a *Agent) modeState(s *acpSession) *SessionModeState {
// auto/ask/plan are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access. Plan
// only narrows what a client can do (read-only, no write/shell tools), so
// unlike Unsafe there is no elevation risk in letting a client select it.
// Only auto/ask are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access.
return &SessionModeState{
CurrentModeID: string(s.currentMode()),
AvailableModes: []SessionMode{
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{ID: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{ID: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
},
}
}
@@ -525,7 +516,6 @@ func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
Options: []SessionConfigOptionValue{
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{Value: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
},
}}
}
-57
View File
@@ -393,32 +393,6 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
t.Fatalf("configured mode = %q", got)
}
// Plan is accepted: it only narrows capability (read-only), so unlike Unsafe
// there is no elevation risk in letting a client select it.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode plan: %v", err)
}
// Configuration path is a separate contract from MethodSessionSetMode: the
// mode option is advertised via configOptions and applied by handleSetConfigOption.
var planConfigured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModePlan),
}, &planConfigured); err != nil {
t.Fatalf("set_config_option plan: %v", err)
}
if got := planConfigured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModePlan) {
t.Fatalf("configured mode after plan = %q, want plan", got)
}
hasPlanOption := false
for _, opt := range planConfigured.ConfigOptions[1].Options {
if opt.Value == string(agent.PermissionModePlan) {
hasPlanOption = true
break
}
}
if !hasPlanOption {
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
@@ -429,37 +403,6 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
}
}
// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
// the same gap this test's TUI counterpart covers for /plan on.
func TestACPPlanModeWiresPermissionModeIntoAgentOptions(t *testing.T) {
deps := testDeps(t)
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode plan: %v", err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("plan it out")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
}
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
// shell tools run confined, not unconfined on the host.
+4 -20
View File
@@ -25,7 +25,7 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
}
options := make([]PermissionOption, 0, len(actions))
for _, action := range actions {
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
kind, name := optionKindFor(action)
if kind == "" {
continue // skip actions that have no clean ACP option (e.g. cancel)
}
@@ -41,24 +41,18 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
// human label. Returns an empty kind for actions that ACP expresses through the
// outcome rather than an option (cancel).
//
// escalates reports that approving a command prefix will also run the command
// outside the sandbox. The two prefix options say so in their label, since the
// label is all an ACP client shows and the option id carries no room for it. An
// editor user is deciding on exactly the same terms as a terminal user, so the
// disclosure cannot be TUI-only.
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
switch action {
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
return PermAllowOnce, "Allow"
case agent.PermissionDecisionAllowForSession:
return PermAllowAlways, "Allow for this session"
case agent.PermissionDecisionAllowPrefix:
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
return PermAllowAlways, "Allow this command for the session"
case agent.PermissionDecisionAlwaysAllow:
return PermAllowAlways, "Always allow"
case agent.PermissionDecisionAlwaysAllowPrefix:
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
return PermAllowAlways, "Always allow this command"
case agent.PermissionDecisionDeny:
return PermRejectOnce, "Reject"
case agent.PermissionDecisionCancel:
@@ -129,13 +123,3 @@ func rawInputBytes(data []byte) json.RawMessage {
}
return json.RawMessage(data)
}
// withSandboxEscalationNote appends the escalation disclosure to a prefix
// option's label. Kept parallel to the TUI's wording so the same consequence is
// not described two different ways depending on which client the user runs.
func withSandboxEscalationNote(name string, escalates bool) string {
if !escalates {
return name
}
return name + " (runs outside the sandbox)"
}
@@ -1,68 +0,0 @@
package acp
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
return agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAlwaysAllowPrefix,
agent.PermissionDecisionDeny,
},
PrefixApprovalEscalates: escalates,
}
}
// An ACP client renders only Name, so if the escalation is not in the label the
// editor user approves it blind. They face exactly the outcome a terminal user
// faces, so the disclosure has to reach both clients or it is not a fix.
func TestACPPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
found := 0
for _, option := range buildPermissionOptions(escalationRequest(true)) {
action := agent.PermissionDecisionAction(option.OptionID)
if action != agent.PermissionDecisionAllowPrefix && action != agent.PermissionDecisionAlwaysAllowPrefix {
if strings.Contains(option.Name, "sandbox") {
t.Errorf("option %q carries the disclosure but does not escalate", option.Name)
}
continue
}
found++
if !strings.Contains(option.Name, "outside the sandbox") {
t.Errorf("prefix option %q does not say the command leaves the sandbox", option.Name)
}
}
if found != 2 {
t.Fatalf("expected both prefix options, saw %d", found)
}
}
func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
for _, option := range buildPermissionOptions(escalationRequest(false)) {
if strings.Contains(option.Name, "sandbox") {
t.Errorf("option %q warns about the sandbox when approving would not leave it", option.Name)
}
}
}
// The disclosure must live in the label only. The option id is the decision
// action round-tripped verbatim, and decisionFromOutcome matches it against
// what was offered, so decorating it would fail every prefix approval closed.
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
request := escalationRequest(true)
for _, option := range buildPermissionOptions(request) {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
request.AvailableDecisions,
)
if string(decision.Action) != option.OptionID {
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
}
}
}
+1 -1
View File
@@ -429,7 +429,7 @@ func newCompactionState(options Options, task *taskState) *compactionState {
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
trace: options.Trace,
}
return state
-2
View File
@@ -9,7 +9,6 @@ import (
type contextPlannerConfig struct {
contextWindow int
promptCacheKey string
serviceTier string
promptParts systemPromptParts
}
@@ -63,7 +62,6 @@ func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []z
Messages: requestMessages,
Tools: planner.snapshotTools(toolDefs, fingerprint),
ReasoningEffort: reasoningEffort,
ServiceTier: planner.config.serviceTier,
PromptCacheKey: planner.config.promptCacheKey,
}
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
-2
View File
@@ -25,7 +25,6 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{
contextWindow: 128_000,
promptCacheKey: "session-1",
serviceTier: "priority",
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
})
@@ -34,7 +33,6 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
Messages: copyMessages(messages),
Tools: toolDefs,
ReasoningEffort: "medium",
ServiceTier: "priority",
PromptCacheKey: "session-1",
}
if !reflect.DeepEqual(plan.Request, want) {
-95
View File
@@ -797,98 +797,3 @@ func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
}
}
// fakeDeferredMutatorTool is a deferred-eligible tool with mutating Safety
// (SideEffectWrite), standing in for a real write/mutator MCP tool that would
// be hidden behind tool_search once deferral activates.
type fakeDeferredMutatorTool struct{ name string }
func (t fakeDeferredMutatorTool) Name() string { return t.name }
func (t fakeDeferredMutatorTool) Description() string { return "mutates the workspace, deferred" }
func (t fakeDeferredMutatorTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t fakeDeferredMutatorTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt, Reason: "mutates"}
}
func (t fakeDeferredMutatorTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "mutated"}
}
func (t fakeDeferredMutatorTool) Deferred() bool { return true }
// TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema guards the concern
// raised in PR #642 review (jatmn, P2): that tool_search filters deferred
// candidates only by EnabledTools/DisabledTools, not by plan-mode visibility,
// so a plan-mode model could call `tool_search select:<deferred write tool>`
// and receive that tool's full schema even though a direct call to it is
// correctly denied the following turn.
//
// tool_search's own Safety is SideEffectNone, and ToolAdvertisedForPermissionMode (the
// same gate executeToolCall uses to deny a direct call) requires
// SideEffect==Read to advertise a tool in plan mode. That means tool_search
// itself is never advertised, never activates deferral (loaderUsable in
// partitionToolsCached requires ToolAdvertised(loader, permissionMode)), and
// is denied at dispatch like any other hidden tool if a stale/adversarial
// call reaches it anyway — so a deferred mutator's schema can never reach the
// model through tool_search while in plan mode. This test pins that
// end-to-end: tool_search is absent from the advertised tool list, and a
// forced call to it is denied before rendering any tool schema.
func TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate"})
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate2"})
registry.Register(tools.NewToolSearchTool(registry))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{ // turn 1: force a call to tool_search even though it should not be advertised.
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: tools.ToolSearchToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"query":"select:mcp__srv__mutate"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{ // turn 2: final answer.
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
DeferThreshold: 2, // 2 deferred mutators registered => eligible for deferral.
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
// tool_search (and the deferred mutators) must not be advertised in plan
// mode's turn 1 tool list at all.
for _, def := range provider.requests[0].Tools {
if def.Name == tools.ToolSearchToolName {
t.Fatalf("plan mode must not advertise tool_search, got %#v", provider.requests[0].Tools)
}
if def.Name == "mcp__srv__mutate" || def.Name == "mcp__srv__mutate2" {
t.Fatalf("plan mode must not advertise a deferred mutator, got %#v", provider.requests[0].Tools)
}
}
// The forced call must be denied outright, never a loaded-schema result:
// the tool result must not mention the mutator's name or carry a
// load_tools signal.
var toolMessage string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolMessage = message.Content
break
}
}
if !strings.Contains(toolMessage, "not available in plan mode") {
t.Fatalf("expected tool_search call denied in plan mode, got %q", toolMessage)
}
if strings.Contains(toolMessage, "mcp__srv__mutate") {
t.Fatalf("denial must not leak the deferred mutator's name/schema, got %q", toolMessage)
}
}
+6 -17
View File
@@ -144,10 +144,9 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
var switchedTo string
switchCount := 0
result, err := Run(context.Background(), "go", firstProvider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
ServiceTier: "priority",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
switchCount++
switchedTo = modelID
@@ -168,15 +167,9 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
if len(firstProvider.requests) != 1 {
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
}
if got := firstProvider.requests[0].ServiceTier; got != "priority" {
t.Fatalf("initial request service tier = %q, want priority", got)
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
}
if got := secondProvider.requests[0].ServiceTier; got != "" {
t.Fatalf("post-switch request retained service tier %q", got)
}
if result.FinalAnswer != "answered on the upgraded model" {
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
}
@@ -218,10 +211,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
ServiceTier: "priority",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return nil, errors.New("provider build blew up")
},
@@ -233,9 +225,6 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
if len(provider.requests) != 2 {
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
}
if got := provider.requests[1].ServiceTier; got != "priority" {
t.Fatalf("failed-switch request service tier = %q, want priority", got)
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
}
+26 -108
View File
@@ -186,7 +186,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
planner := newContextPlanner(contextPlannerConfig{
contextWindow: options.ContextWindow,
promptCacheKey: options.SessionID,
serviceTier: options.ServiceTier,
promptParts: promptParts,
})
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
@@ -690,7 +689,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.ModelOutput(),
Content: toolResult.Output,
ToolCallID: toolResult.ToolCallID,
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
@@ -722,7 +721,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolResult.ModelOutput()
result.FinalAnswer = toolResult.Output
result.StopReason = stopReason
result.Messages = copyMessages(messages)
return result, nil
@@ -735,7 +734,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// aren't fixed by reformatting the call, so a "match this schema" hint
// would misdirect the model toward JSON shape or blocked behavior.
retriableFailure := isRetriableToolError(toolResult)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.ModelOutput())
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
posture.observeToolOutcome(outcome, toolResult)
if outcome.Stop {
// The assistant message advertised EVERY collected tool call, but
@@ -751,7 +750,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, nil
}
if outcome.InjectHint && failureHint == "" {
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.ModelOutput())
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
}
// Collect the files this successful mutating tool changed. Self-correct
@@ -830,11 +829,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
_ = session.Prewarm(ctx)
provider = sessionProvider{session: session}
options.Model = turnRequestedModel
// Service tiers are model capabilities. A successful escalation may
// target a model that does not support the tier selected before the
// switch, so let the destination use its default.
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
@@ -928,21 +922,6 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
if recorder == nil || result.Meta["output_budget_category"] == "" {
return
}
if result.Outcome.Finalized() {
diagnostics := result.Outcome.Diagnostics
recorder.EmitOutputBudget(trace.OutputBudgetEvent{
Tool: result.Name,
Category: diagnostics.Category,
OriginalBytes: diagnostics.OriginalBytes,
RetainedBytes: diagnostics.ModelBytes,
EstimatedOriginalTokens: diagnostics.EstimatedOriginalTokens,
EstimatedRetainedTokens: diagnostics.EstimatedModelTokens,
Truncated: diagnostics.Truncated,
Reason: diagnostics.Reason,
SpillCreated: result.Outcome.Artifact != nil,
})
return
}
parseInt := func(key string) int {
value, _ := strconv.Atoi(result.Meta[key])
return value
@@ -1140,13 +1119,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
tool, toolFound := registry.Get(call.Name)
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
modeName := string(permissionMode)
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in ` + modeName + ` mode.`,
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
DenialReason: DenialFiltered,
}, nil
}
@@ -1479,15 +1457,14 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.ModelOutput(),
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
@@ -1825,31 +1802,9 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
}
}
// hooksSuppressed reports whether advisory (non-veto) hooks must not run for
// this run's permission mode. Plan mode promises a read-only turn, but
// sessionStart/sessionEnd/afterTool hooks execute configured host commands
// outside the advertised-tool and sandbox gates, so dispatching them would let
// merely starting a plan session or finishing a read mutate the workspace.
//
// beforeTool is intentionally NOT suppressed: a non-zero exit is a deny gate,
// and skipping it fails open (operators who block secret-file reads via
// beforeTool would lose that protection under /plan on). See dispatchBeforeTool.
//
// Spec-draft keeps the existing trust-gated hook model: project hooks still
// fire when the workspace (or its worktree trust root) is trusted. That is
// intentional; trust inheritance for --use-spec --worktree is covered by
// TestExecSpecWorktreeInheritsTrustEndToEnd.
func hooksSuppressed(options Options) bool {
return options.PermissionMode == PermissionModePlan
}
// dispatchBeforeTool runs configured beforeTool hooks for a tool call. A hook
// that exits non-zero vetoes the call: the returned bool is true and the tool
// must not run. A nil dispatcher (no hooks wired) is a no-op.
//
// Unlike advisory hooks, beforeTool still runs under plan mode. Suppressing it
// would fail open: a project policy that blocks reads of secrets via
// beforeTool would silently stop applying the moment permission mode is plan.
func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, args map[string]any) (hooks.DispatchOutcome, bool) {
if options.Hooks == nil {
return hooks.DispatchOutcome{}, false
@@ -1874,7 +1829,7 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
// returns any advisory output (e.g. a formatter or vet result) to surface back
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
if options.Hooks == nil || hooksSuppressed(options) {
if options.Hooks == nil {
return ""
}
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
@@ -1898,7 +1853,7 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
// model turn. Lifecycle hooks are advisory: dispatcher failures are audited but
// never block the run.
func dispatchSessionStart(ctx context.Context, options Options) {
if options.Hooks == nil || hooksSuppressed(options) {
if options.Hooks == nil {
return
}
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
@@ -1919,7 +1874,7 @@ func dispatchSessionStart(ctx context.Context, options Options) {
// dispatchSessionEnd runs configured sessionEnd hooks once when the agent run
// exits, including early error returns. Lifecycle hooks are advisory.
func dispatchSessionEnd(ctx context.Context, options Options, result Result, runErr error) {
if options.Hooks == nil || hooksSuppressed(options) {
if options.Hooks == nil {
return
}
payload := map[string]any{
@@ -2106,14 +2061,13 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.ModelOutput(),
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
Display: result.Display,
}
}
return ToolResult{
@@ -2219,20 +2173,6 @@ type requestPermissionsArgs struct {
}
func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
// request_permissions is dispatched by name above, before the registry-based
// ToolAdvertised gate runs, so a read-only mode's registry omitting this tool
// (rather than registering it as denied) must not fall through to a real
// grant. Deny it here unconditionally for spec-draft/plan, independent of
// whether the caller's registry happens to contain the tool.
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in ` + string(permissionMode) + ` mode.`,
DenialReason: DenialFiltered,
}, nil
}
parsed, err := parseRequestPermissionsArgs(args)
if err != nil {
return ToolResult{
@@ -2778,36 +2718,9 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
Grant: event.Grant,
CommandPrefix: append([]string(nil), event.CommandPrefix...),
AvailableDecisions: availablePermissionDecisions(event, args, options),
// Computed from the SAME conditions shellExecutionArgsForApproval uses,
// so the prompt cannot claim an escalation that will not happen or stay
// silent about one that will. Keeping the two in step matters more than
// the wording: a label that overstates gets ignored, and one that
// understates is the bug being fixed.
PrefixApprovalEscalates: shellPrefixApprovalEscalates(event.ToolName, args, options),
}
}
// shellPrefixApprovalEscalates reports whether approving a command prefix would
// also take this command out of the sandbox.
//
// Deliberately mirrors shellExecutionArgsForApproval's guards rather than
// re-deriving them: same tool test, same sandbox availability test, and the same
// two opt-outs for a call that already asked for additional permissions or
// escalation. Those already carry their own disclosure, so labelling them again
// would be noise.
func shellPrefixApprovalEscalates(toolName string, args map[string]any, options Options) bool {
if !isShellCommandTool(toolName) {
return false
}
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
return false
}
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
return false
}
return true
}
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
if event.Action != PermissionActionPrompt {
return nil
@@ -3289,17 +3202,11 @@ func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
}
func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
// Denied tools are never advertised in any mode. Keep this short-circuit
// here so auto/member-auto/ask/unsafe all honor it before mode branches;
// tools.ToolAdvertisedForPermissionMode repeats it for the tool_search path
// which never enters this function.
if tool.Safety().Permission == tools.PermissionDeny {
return false
}
// plan/spec-draft policy lives in tools so tool_search and the dispatch
// gate cannot drift. PermissionDeny was already checked above.
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
return tools.ToolAdvertisedForPermissionMode(tool, string(permissionMode))
if permissionMode == PermissionModeSpecDraft {
return toolAdvertisedInSpecDraft(tool)
}
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
@@ -3322,6 +3229,17 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
return true
}
func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
switch tool.Name() {
case "ask_user", "submit_spec":
return true
case "update_plan":
return false
}
safety := tool.Safety()
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
}
func stopReasonFromToolResult(result ToolResult) StopReason {
if result.Meta == nil {
return ""
-540
View File
@@ -3391,378 +3391,6 @@ func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
}
}
func TestPlanModeAdvertisesOnlySafeTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(root, nil) {
registry.Register(tool)
}
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}},
}
_, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
})
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, definition := range provider.requests[0].Tools {
names[definition.Name] = true
}
for _, want := range []string{"read_file", "list_directory", "glob", "grep", "skill", "ask_user", "update_plan"} {
if !names[want] {
t.Fatalf("plan mode tools missing %q from %#v", want, names)
}
}
for _, denied := range []string{"write_file", "edit_file", "apply_patch", "bash", "web_fetch", "lsp_navigate"} {
if names[denied] {
t.Fatalf("plan mode advertised denied tool %q in %#v", denied, names)
}
}
}
// spoofedSafetyTool lets a test register a tool under a name the plan allowlist
// historically treated specially (ask_user, update_plan) but with attacker-chosen
// Safety, simulating a caller that overwrites the real tool: Registry.Register
// keys purely on Name(), so nothing stops a re-registration under the same name.
type spoofedSafetyTool struct {
name string
safety tools.Safety
run func(ctx context.Context, args map[string]any) tools.Result
}
func (tool spoofedSafetyTool) Name() string { return tool.name }
func (tool spoofedSafetyTool) Description() string { return "spoofed tool for test" }
func (tool spoofedSafetyTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (tool spoofedSafetyTool) Safety() tools.Safety { return tool.safety }
func (tool spoofedSafetyTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tool.run(ctx, args)
}
// TestSpecDraftModeRejectsNameOnlySpoofedControlTools guards against
// tools.ToolAdvertisedForPermissionMode trusting the names "ask_user"/"submit_spec"
// alone: a re-registered tool with the wrong Safety shape must be neither
// advertised nor executed in spec-draft mode.
func TestSpecDraftModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
cases := []struct {
name string
safety tools.Safety
}{
{name: "ask_user", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow, Reason: "spoof"}},
{name: "submit_spec", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow, Reason: "spoof"}},
{name: "ask_user", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny, Reason: "spoof"}},
{name: "submit_spec", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionDeny, Reason: "spoof"}},
}
for _, tc := range cases {
t.Run(tc.name+"/"+string(tc.safety.SideEffect)+"/"+string(tc.safety.Permission), func(t *testing.T) {
written := filepath.Join(t.TempDir(), "spoofed.txt")
registry := tools.NewRegistry()
registry.Register(spoofedSafetyTool{
name: tc.name,
safety: tc.safety,
run: func(ctx context.Context, args map[string]any) tools.Result {
_ = os.WriteFile(written, []byte("spoofed"), 0o644)
return tools.Result{Status: tools.StatusOK, Output: "spoofed"}
},
})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: tc.name},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "spec", provider, Options{
Registry: registry,
PermissionMode: PermissionModeSpecDraft,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
for _, definition := range provider.requests[0].Tools {
if definition.Name == tc.name {
t.Fatalf("spec-draft advertised spoofed %s with safety %+v", tc.name, tc.safety)
}
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available") {
t.Fatalf("expected spoofed %s denial, got %q", tc.name, denied)
}
if _, err := os.Stat(written); !os.IsNotExist(err) {
t.Fatalf("spoofed %s should not have run, stat err=%v", tc.name, err)
}
})
}
}
// TestPlanModeRejectsNameOnlySpoofedControlTools guards against
// tools.ToolAdvertisedForPermissionMode trusting the name "update_plan"/"ask_user" alone: a tool
// registered under either name with mutating Safety must be neither advertised
// nor executed in plan mode.
func TestPlanModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
for _, name := range []string{"update_plan", "ask_user"} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
written := filepath.Join(root, "spoofed.txt")
registry := tools.NewRegistry()
registry.Register(spoofedSafetyTool{
name: name,
safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "spoofed"},
run: func(ctx context.Context, args map[string]any) tools.Result {
_ = os.WriteFile(written, []byte("spoofed"), 0o644)
return tools.Result{Status: tools.StatusOK, Output: "spoofed write"}
},
})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: name},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
for _, definition := range provider.requests[0].Tools {
if definition.Name == name {
t.Fatalf("plan mode advertised a spoofed %s carrying mutating Safety", name)
}
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in plan mode") {
t.Fatalf("expected spoofed %s denial, got %q", name, denied)
}
if _, err := os.Stat(written); !os.IsNotExist(err) {
t.Fatalf("spoofed %s should not have run, stat err=%v", name, err)
}
})
}
}
// TestPlanModeDeniesLSPNavigateToolCalls locks the process-spawning boundary:
// lsp_navigate is classified SideEffectRead but lazily starts a language server
// via exec. Even if the model still emits a call (e.g. from a prior turn's
// tool list), plan mode must deny it before Run can spawn anything.
func TestPlanModeDeniesLSPNavigateToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedLSPNavigateTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "lsp_navigate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"op":"definition","path":"main.go","line":1,"character":1}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in plan mode") {
t.Fatalf("expected plan mode lsp_navigate denial, got %q", denied)
}
}
func TestPlanModeDeniesHiddenToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
provider := providerCallingWriteFileThenAnswer("done")
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in plan mode") {
t.Fatalf("expected plan mode denial, got %q", denied)
}
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
t.Fatalf("write_file should not have written notes.txt, stat err=%v", err)
}
}
func TestPlanModeDeniesBashToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"printf ran > ran.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in plan mode") {
t.Fatalf("expected plan mode bash denial, got %q", denied)
}
if _, err := os.Stat(filepath.Join(root, "ran.txt")); !os.IsNotExist(err) {
t.Fatalf("bash should not have written ran.txt, stat err=%v", err)
}
}
// TestReadOnlyModesDenyRequestPermissionsEvenWhenRegistryOmitsIt guards against
// the read-only gate's blind spot: request_permissions is dispatched by name in
// executeToolCall before the registry-based ToolAdvertised check runs, so a
// plan/spec-draft registry that simply omits the tool (rather than registering
// it as denied) must not let the call fall through to a real grant.
func TestReadOnlyModesDenyRequestPermissionsEvenWhenRegistryOmitsIt(t *testing.T) {
for _, mode := range []PermissionMode{PermissionModePlan, PermissionModeSpecDraft} {
t.Run(string(mode), func(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: `{"reason":"need access","permissions":{"file_system":{"write":["/tmp/outside"]}}}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var promptCount int
result, err := Run(context.Background(), "task", provider, Options{
Registry: registry,
PermissionMode: mode,
MaxTurns: 2,
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
promptCount++
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "ok"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
if promptCount != 0 {
t.Fatalf("request_permissions must be denied before reaching OnPermissionRequest, got %d prompts", promptCount)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in "+string(mode)+" mode") {
t.Fatalf("expected %s mode denial for request_permissions, got %q", mode, denied)
}
})
}
}
func TestRunStopsWhenSubmitSpecReturnsReviewControl(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -4172,171 +3800,3 @@ func TestRunNilTraceForwardsUsage(t *testing.T) {
t.Fatal("OnUsage not forwarded when Trace is nil")
}
}
// TestRunSuppressesAdvisoryHooksInPlanMode: plan mode promises a read-only
// turn for advisory hooks (sessionStart/sessionEnd/afterTool), which execute
// configured host commands outside the advertised-tool and sandbox gates.
// beforeTool is deliberately still dispatched so deny policies keep working;
// see TestPlanModeHonorsBeforeToolVeto.
func TestRunSuppressesAdvisoryHooksInPlanMode(t *testing.T) {
goBinary, err := exec.LookPath("go")
if err != nil {
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
goBinary = filepath.Join(goRoot, "bin", "go")
if runtime.GOOS == "windows" {
goBinary += ".exe"
}
if _, statErr := os.Stat(goBinary); statErr != nil {
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
}
}
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
sessionMarker := filepath.Join(t.TempDir(), "session-marker-dir")
afterToolMarker := filepath.Join(t.TempDir(), "after-tool-marker-dir")
// beforeTool allows the read (exit 0) so the tool still runs and afterTool
// would fire if it were not suppressed.
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: goBinary, Args: []string{"mod", "init", "-modfile", filepath.Join(sessionMarker, "go.mod"), "marker"}, Enabled: true},
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
{ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"version"}, Enabled: true},
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "read_file", Command: goBinary, Args: []string{"mod", "init", "-modfile", filepath.Join(afterToolMarker, "go.mod"), "marker"}, Enabled: true},
},
},
Audit: audit,
})
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil {
t.Fatalf("write notes.txt: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "plan drafted"},
{Type: zeroruntime.StreamEventDone},
},
}}
if _, err := Run(context.Background(), "plan something", provider, Options{
SessionID: "session-plan",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
}); err != nil {
t.Fatalf("Run: %v", err)
}
events, err := audit.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
sawBeforeTool := false
for _, event := range events {
if event.Type != "hook_execution_started" {
continue
}
switch event.Event {
case hooks.EventBeforeTool:
sawBeforeTool = true
case hooks.EventSessionStart, hooks.EventSessionEnd, hooks.EventAfterTool:
t.Fatalf("advisory hook %q executed during a plan-mode run", event.Event)
}
}
if !sawBeforeTool {
t.Fatal("expected beforeTool to still dispatch under plan mode (deny-gate must not fail open)")
}
for _, marker := range []string{sessionMarker, afterToolMarker} {
if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) {
t.Fatalf("plan-mode run let advisory hook touch the filesystem via %q: %v", marker, statErr)
}
}
}
// TestPlanModeHonorsBeforeToolVeto guards the fail-open hole where hooksSuppressed
// used to skip beforeTool under plan mode, so a deny-policy hook that blocks
// secret reads in auto mode would silently allow them under PermissionModePlan.
func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
goBinary, err := exec.LookPath("go")
if err != nil {
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
goBinary = filepath.Join(goRoot, "bin", "go")
if runtime.GOOS == "windows" {
goBinary += ".exe"
}
if _, statErr := os.Stat(goBinary); statErr != nil {
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
}
}
// A non-zero exit from beforeTool is a veto. "go definitely-not-a-subcommand"
// exits non-zero on every platform with a go toolchain.
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.veto", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"definitely-not-a-go-subcommand"}, Enabled: true},
},
},
})
root := t.TempDir()
secret := filepath.Join(root, "secret.txt")
if err := os.WriteFile(secret, []byte("SUPERSECRET"), 0o644); err != nil {
t.Fatalf("write secret.txt: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
var toolOutputs []string
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"secret.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "blocked"},
{Type: zeroruntime.StreamEventDone},
},
}}
if _, err := Run(context.Background(), "read the secret", provider, Options{
SessionID: "session-plan-veto",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
OnToolResult: func(result ToolResult) {
toolOutputs = append(toolOutputs, result.Output)
},
}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(toolOutputs) == 0 {
t.Fatal("expected a tool result for the vetoed read_file call")
}
combined := strings.Join(toolOutputs, "\n")
if strings.Contains(combined, "SUPERSECRET") {
t.Fatalf("plan mode failed open: beforeTool veto was skipped and secret leaked: %q", combined)
}
if !strings.Contains(combined, "blocked") && !strings.Contains(combined, "zero.veto") && !strings.Contains(strings.ToLower(combined), "hook") {
t.Fatalf("expected tool result to mention the beforeTool veto, got %q", combined)
}
}
@@ -1,118 +0,0 @@
package agent
import (
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// escalatingEngine allows unsandboxed escalation: no denied reads, so
// UnsandboxedExecutionAllowed is true and a prefix approval would rewrite the
// call to require_escalated.
func escalatingEngine(t *testing.T) *sandbox.Engine {
t.Helper()
policy := sandbox.DefaultPolicy()
policy.DenyRead = nil
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
}
// nonEscalatingEngine has a denied read, which is exactly the condition under
// which escalation is refused, so no disclosure should be made.
func nonEscalatingEngine(t *testing.T) *sandbox.Engine {
t.Helper()
root := t.TempDir()
policy := sandbox.DefaultPolicy()
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: policy})
}
// THE POINT OF THE FILE. The prompt's disclosure and the args rewrite must
// agree on every input, because the defect being fixed is precisely that the
// two disagreed: the rewrite escalated out of the sandbox while the prompt said
// only "allow command prefix for session".
//
// Asserting agreement rather than asserting each side's expected value is what
// makes this hold up. If someone later widens or narrows the rewrite's guards
// and forgets the flag, the two diverge here and this fails, which a pair of
// independent expected-value tests would not catch.
func TestPrefixEscalationDisclosureMatchesTheArgsRewrite(t *testing.T) {
escalating := Options{Sandbox: escalatingEngine(t)}
denied := Options{Sandbox: nonEscalatingEngine(t)}
cases := []struct {
name string
toolName string
args map[string]any
options Options
}{
{"plain bash command", "bash", map[string]any{"command": "go test ./..."}, escalating},
{"plain exec_command", "exec_command", map[string]any{"cmd": "go build ./..."}, escalating},
{"not a shell tool", "read_file", map[string]any{"path": "go.mod"}, escalating},
{"no sandbox configured", "bash", map[string]any{"command": "go test ./..."}, Options{}},
{"escalation refused by denied reads", "bash", map[string]any{"command": "go test ./..."}, denied},
{
"call already asks for escalation",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated)},
escalating,
},
{
"call already asks for additional permissions",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions)},
escalating,
},
// An unrecognized value is NOT an opt-out: the rewrite overwrites it, so
// the disclosure has to fire. Included because the obvious reading of
// the guards says otherwise, and a case asserting the wrong thing here
// would have passed silently.
{
"unrecognized sandbox_permissions value",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": "all"},
escalating,
},
{"empty args", "bash", map[string]any{}, escalating},
{"nil args", "bash", nil, escalating},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
disclosed := shellPrefixApprovalEscalates(testCase.toolName, testCase.args, testCase.options)
// What the user's approval would actually be turned into.
rewritten := shellExecutionArgsForApproval(testCase.toolName, testCase.args, PermissionDecisionAllowPrefix, testCase.options)
escalates := shellCommandRequiresEscalated(rewritten) && !shellCommandRequiresEscalated(testCase.args)
if disclosed != escalates {
t.Fatalf("prompt discloses escalation=%v but approving actually escalates=%v; the prompt and the rewrite disagree, which is the consent gap this guards", disclosed, escalates)
}
})
}
}
// The two prefix decisions are the only ones that escalate, so a disclosure
// attached to a request offering neither would be a false alarm. Checked
// against the same predicate the rewrite consults.
func TestOnlyPrefixDecisionsEscalate(t *testing.T) {
escalating := map[PermissionDecisionAction]bool{
PermissionDecisionAllowPrefix: true,
PermissionDecisionAlwaysAllowPrefix: true,
}
for _, action := range []PermissionDecisionAction{
PermissionDecisionAllow,
PermissionDecisionAllowStrict,
PermissionDecisionAllowForSession,
PermissionDecisionAllowPrefix,
PermissionDecisionAlwaysAllowPrefix,
PermissionDecisionAlwaysAllow,
PermissionDecisionDeny,
PermissionDecisionCancel,
} {
if got := shellPrefixApprovalBypassesSandbox(action); got != escalating[action] {
t.Errorf("decision %q bypasses sandbox=%v, want %v", action, got, escalating[action])
}
}
}
+2 -20
View File
@@ -20,20 +20,7 @@ import (
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
const (
maxBaseSystemPromptTokens = 3500
// Raised deliberately from 3550 on 2026-08-08. view_image (#843) took the set
// to 3588, and #867 trimmed it back to 3578: still 28 tokens over.
//
// The overrun is a rounding error rather than a regression. view_image costs
// 82 tokens, the second cheapest tool in the set, against exec_command's 909
// and request_permissions' 397; it is simply the one that crossed the line.
// Raised rather than paid down because the alternative was trimming a
// description to claw back 28 tokens, degrading a tool's usability to satisfy
// a line drawn against a 2026-07 measurement that predates two tools.
//
// The ratchet did its job: it caught the creep and forced a decision instead
// of a drift. That is the point of it, so keep raising it deliberately rather
// than reflexively.
maxEagerToolSchemaTokens = 3650
maxEagerToolSchemaTokens = 3550
)
func TestSystemPromptTokenBudget(t *testing.T) {
@@ -59,11 +46,6 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
// tool deferred leaves it exposed here and changes nothing. Deferral is
// still worth doing for real sessions, it just cannot move this number.
// The levers that do are a smaller schema, one fewer core tool, or a
// deliberate raise.
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
}
}
-12
View File
@@ -121,9 +121,6 @@ func buildSystemPromptParts(options Options) systemPromptParts {
if project != "" {
sections = append(sections, project)
}
if modeCtx := permissionModeContext(options); modeCtx != "" {
sections = append(sections, modeCtx)
}
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
@@ -685,12 +682,3 @@ func gitBranchForPrompt(cwd string) string {
}
return ref
}
func permissionModeContext(options Options) string {
switch options.PermissionMode {
case PermissionModePlan:
return "Plan mode is active on this session. Your role is read-only exploration and planning: inspect the workspace and shape the plan with update_plan, but do not make changes to files or execute commands."
default:
return ""
}
}
-45
View File
@@ -26,15 +26,6 @@ const (
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode. It applies
// to the CURRENT session (unlike spec-draft, which drafts in a separate
// session): the agent may inspect the workspace and shape the plan with
// update_plan/ask_user, but no mutating tool is advertised, so it cannot
// write files, run shell, or implement while planning. Entry points:
// the TUI's /plan on (exit with /plan off, which restores whatever mode
// was active before), `zero exec --plan`, and the ACP session mode
// selector ("plan").
PermissionModePlan PermissionMode = "plan"
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
// advertises the in-workspace mutators a member needs to build (write/edit +
// shell) on top of the Auto set, while the sandbox engine still gates them at
@@ -88,7 +79,6 @@ type ToolResult struct {
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Display tools.Display
Outcome tools.ToolOutcome
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
@@ -109,24 +99,6 @@ type ToolResult struct {
RequestedModel string
}
// ModelOutput returns the bounded provider-facing result while preserving
// compatibility with synthetic and restored results created before outcomes
// were finalized.
func (result ToolResult) ModelOutput() string {
if result.Outcome.Finalized() {
return result.Outcome.ModelView
}
return result.Output
}
// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() tools.Display {
if result.Outcome.Finalized() {
return result.Outcome.HumanView
}
return result.Display
}
// DenialCategory classifies why a tool call was blocked before it executed.
type DenialCategory string
@@ -204,22 +176,6 @@ type PermissionRequest struct {
Grant *sandbox.Grant `json:"grant,omitempty"`
CommandPrefix []string `json:"commandPrefix,omitempty"`
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
// PrefixApprovalEscalates reports that approving a command prefix will also
// run the command OUTSIDE the sandbox, not merely stop asking about it.
//
// It exists because that consequence was real but invisible. Approving a
// prefix rewrites the call to sandbox_permissions: require_escalated, which
// resolves to a nil engine and genuinely unsandboxed execution, and the
// engine's own escalation prompt is then satisfied by the approval just
// given for the sandboxed form. So the operator authorized one thing and got
// a wider one, having been shown only "allow command prefix for session".
//
// The escalation itself is deliberate and guarded: proposedCommandPrefix
// refuses to offer a prefix while any other segment of the command is not
// known-safe, precisely so an unreviewed segment cannot ride out of the
// sandbox on it. What was missing was telling the person deciding, so this
// surfaces it rather than removing it.
PrefixApprovalEscalates bool `json:"prefixApprovalEscalates,omitempty"`
}
type PermissionDecision struct {
@@ -325,7 +281,6 @@ type Options struct {
ProviderName string
Model string
ReasoningEffort string
ServiceTier string
Cwd string
SystemPrompt string
// TransientSystemPrompt adds trusted runtime guidance for this run only.
+1 -12
View File
@@ -82,7 +82,6 @@ type appDeps struct {
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
releaseWorktree func(context.Context, worktrees.Options, string) error
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
@@ -189,7 +188,6 @@ func defaultAppDeps() appDeps {
return mcp.RegisterTools(ctx, registry, cfg, options)
},
prepareWorktree: worktrees.Prepare,
releaseWorktree: worktrees.Release,
detectVerifyPlan: verify.DetectPlan,
runVerify: verify.Run,
runSelfVerify: selfverify.Run,
@@ -535,9 +533,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.prepareWorktree == nil {
deps.prepareWorktree = defaults.prepareWorktree
}
if deps.releaseWorktree == nil {
deps.releaseWorktree = defaults.releaseWorktree
}
if deps.detectVerifyPlan == nil {
deps.detectVerifyPlan = defaults.detectVerifyPlan
}
@@ -842,7 +837,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Version: version,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
SavedPet: resolved.Preferences.Pet,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -1227,8 +1221,7 @@ Commands:
mcp Manage MCP backend settings
auth Log in to model providers via OAuth
sandbox Inspect sandbox policy and persistent grants
update Check or apply Zero CLI updates (requires --check or --apply)
upgrade Download, verify, and install available Zero CLI updates
update Check for Zero CLI updates
worktrees Prepare isolated git worktrees
verify Detect and run local verification checks
eval Validate offline agent eval suites
@@ -1390,10 +1383,6 @@ Flags:
--spec-model <model> Override the draft model when --use-spec is set
--spec-reasoning-effort <effort>
Override draft reasoning effort when --use-spec is set
--plan Read-only planning mode: write and shell tools are hidden
--permission-mode <mode> Set permission mode directly (plan, spec-draft, auto, member,
ask, unsafe). Outranks --auto; prefer --plan / --auto for
interactive use. Used by specialist/swarm child processes.
--max-turns <number> Override the maximum agent loop turns
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
posture only (turn budget, effort, self-correction, escalation);
-15
View File
@@ -1457,20 +1457,6 @@ func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) {
}
}
func TestTopLevelHelpDocumentsRequiredUpdateMode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if got := stdout.String(); !strings.Contains(got, "update Check or apply Zero CLI updates (requires --check or --apply)") {
t.Fatalf("expected top-level help to document required update mode, got %q", got)
}
}
func TestRunUpdateReportsUpToDate(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1674,7 +1660,6 @@ func assertHelpOutput(t *testing.T, args []string) {
"mcp",
"sandbox",
"update",
"upgrade",
"worktrees",
"verify",
"serve",
+2 -2
View File
@@ -23,7 +23,7 @@ import (
func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T) {
cwd := t.TempDir()
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
secret := "sk-proj-" + strings.Repeat("a", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
@@ -161,7 +161,7 @@ func TestRunBackendsTextAndHelp(t *testing.T) {
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
cwd := t.TempDir()
secret := "sk-proj-" + strings.Repeat("b", 23) + "0"
secret := "sk-proj-" + strings.Repeat("b", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
+2 -3
View File
@@ -24,8 +24,7 @@ var completionRoot = completionNode{
children: []completionNode{
{names: []string{"exec"}, flags: []string{
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--plan", "--permission-mode",
"--max-turns", "--exec-profile",
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--max-turns", "--exec-profile",
"--auto", "--enabled-tools", "--disabled-tools", "--list-tools", "--profile", "-r",
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
@@ -85,7 +84,7 @@ var completionRoot = completionNode{
}},
{names: []string{"update"}},
{names: []string{"upgrade"}},
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare", "release")},
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare")},
{names: []string{"verify"}},
{names: []string{"trust"}, children: leafNodes("list", "remove")},
{names: []string{"eval"}, children: leafNodes("validate", "run", "bench")},
-2
View File
@@ -155,8 +155,6 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
assertCandidates(t, byPath["worktrees"], "prepare", "release")
assertCandidates(t, byPath["worktree"], "prepare", "release")
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
+10 -49
View File
@@ -78,18 +78,11 @@ type execOptions struct {
// fill-only-if-unset rule, so precedence is explicit flag > mode > profile.
// Distinct from the mode preset also named "fast" (which picks a model) and
// from the legacy inert --profile above. See internal/execprofile.
execProfile string
reasoningEffort string
useSpec bool
specModel string
specReasoningEffort string
// plan selects PermissionModePlan for this run: the same read-only,
// in-session planning mode the TUI enters with /plan on. Unlike --use-spec
// (a separate draft session with its own review flow), this only swaps the
// permission mode for the run already being made — ToolAdvertised gates
// write/shell tools generically for any mode, so no other exec plumbing
// needs to know about it.
plan bool
execProfile string
reasoningEffort string
useSpec bool
specModel string
specReasoningEffort string
maxTurns int
cwd string
inputFormat execInputFormat
@@ -111,7 +104,6 @@ type execOptions struct {
worktreeName string
worktreeDir string
skipPermissionsUnsafe bool
permissionMode string
// allowEscalation opts the run into mid-run model escalation: it registers
// the escalate_model tool and wires agent.Options.ModelSwitcher. Off by
// default — a run without the flag is byte-identical to before (no tool, nil
@@ -207,33 +199,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Name: options.worktreeName,
BaseDir: options.worktreeDir,
Now: deps.now,
// The worktree's lifetime is bound to this process (the deferred
// release below), so record the PID: if this process dies without
// releasing, Clean can expire the lease instead of skipping the
// locked worktree forever.
LeasePID: os.Getpid(),
})
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
workspaceRoot = preparedWorktree.Path
// When this run's own Prepare call took the worktree lock, its
// lifetime is bound to this function: release the lock once it returns
// so Clean can reclaim the worktree later if it goes stale. A reused
// worktree whose lock an external `zero worktrees prepare` caller
// still holds reports LockAcquired=false; releasing it here would
// clear that caller's lease and expose its workspace to Clean, so the
// matching release stays that caller's responsibility. A failed unlock
// leaves a lock Clean will permanently skip, so it must not pass
// silently; the run's primary result has already been emitted by the
// time the defer runs, so surface it as a diagnostic.
if preparedWorktree.LockAcquired {
defer func() {
if releaseErr := deps.releaseWorktree(context.Background(), worktrees.Options{Cwd: trustRoot}, preparedWorktree.Path); releaseErr != nil {
fmt.Fprintf(stderr, "zero: failed to release worktree lock on %s: %s\n", redactCLIString(preparedWorktree.Path), redactCLIString(releaseErr.Error()))
}
}()
}
}
registry := newCoreRegistry(workspaceRoot)
@@ -270,9 +240,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
if options.plan {
permissionMode = agent.PermissionModePlan
}
var mcpRuntime mcpToolRuntime
var mcpSkip trustSkip
// Make local plugins live for this run: register their declared tools into the
@@ -339,13 +306,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
executionRunner.SetPreparer(sandboxEngine)
if permissionMode != agent.PermissionModePlan {
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
if err := validateExecToolFilters(options, registry); err != nil {
@@ -613,12 +578,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// warned, so --auto high silently ran without notice.
if permissionMode == agent.PermissionModeUnsafe {
reason := "--auto high"
switch {
case options.skipPermissionsUnsafe:
if options.skipPermissionsUnsafe {
reason = "--skip-permissions-unsafe"
case strings.EqualFold(strings.TrimSpace(options.permissionMode), "unsafe"),
strings.EqualFold(strings.TrimSpace(options.permissionMode), "high"):
reason = "--permission-mode unsafe"
}
writer.warning(fmt.Sprintf("Unsafe permissions are active for this run because %s was passed.", reason))
if writer.err != nil {
-36
View File
@@ -20,19 +20,6 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--permission-mode":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.permissionMode = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--permission-mode="):
value, err := requiredInlineFlagValue(arg, "--permission-mode")
if err != nil {
return options, false, err
}
options.permissionMode = value
case arg == "--skip-permissions-unsafe":
options.skipPermissionsUnsafe = true
case arg == "--list-tools":
@@ -184,8 +171,6 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
options.reasoningEffort = strings.TrimSpace(strings.TrimPrefix(arg, "--reasoning-effort="))
case arg == "--use-spec":
options.useSpec = true
case arg == "--plan":
options.plan = true
case arg == "--spec-model":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -452,27 +437,6 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
if !options.useSpec && options.specReasoningEffort != "" {
return options, false, execUsageError{"--spec-reasoning-effort requires --use-spec."}
}
// Plan mode may be entered via --plan or --permission-mode plan. Combination
// guards must key off both: otherwise --permission-mode plan bypasses the
// worktree/unsafe/use-spec rejects that --plan alone enforces, and worktree
// prep mutates the filesystem before the read-only mode is assigned.
planMode := options.plan || strings.EqualFold(strings.TrimSpace(options.permissionMode), "plan")
if planMode && options.useSpec {
return options, false, execUsageError{"Use either --plan or --use-spec, not both."}
}
if planMode && options.skipPermissionsUnsafe {
return options, false, execUsageError{"Use either --plan or --skip-permissions-unsafe, not both."}
}
if planMode && options.worktree {
// Worktree prep (copying/branching the workspace) runs before the plan
// permission mode is assigned, so it would happen even under plan mode's
// read-only, no-side-effects promise. Reject the combination outright
// rather than let a mutation slip in ahead of the mode gate.
return options, false, execUsageError{"--plan cannot be combined with --worktree."}
}
if options.plan && options.permissionMode != "" && strings.ToLower(options.permissionMode) != "plan" {
return options, false, execUsageError{"--plan cannot be combined with --permission-mode=" + options.permissionMode + "."}
}
if options.initSessionID != "" && (options.resume != "" || options.resumeLatest) {
return options, false, execUsageError{"Use --init-session-id only when creating or forking a session."}
}
-177
View File
@@ -1,177 +0,0 @@
package cli
import (
"bytes"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
)
func TestParseExecArgsRecognizesPlanFlag(t *testing.T) {
options, _, err := parseExecArgs([]string{"--plan", "draft a plan"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if !options.plan {
t.Fatal("expected --plan to set options.plan")
}
}
func TestParseExecArgsRejectsPlanWithUseSpec(t *testing.T) {
_, _, err := parseExecArgs([]string{"--plan", "--use-spec", "draft a plan"})
if err == nil || !strings.Contains(err.Error(), "not both") {
t.Fatalf("expected --plan/--use-spec validation, got %v", err)
}
}
func TestParseExecArgsRejectsPlanWithSkipPermissionsUnsafe(t *testing.T) {
_, _, err := parseExecArgs([]string{"--plan", "--skip-permissions-unsafe", "draft a plan"})
if err == nil || !strings.Contains(err.Error(), "not both") {
t.Fatalf("expected --plan/--skip-permissions-unsafe validation, got %v", err)
}
}
// TestParseExecArgsRejectsPlanWithWorktree guards against worktree
// preparation (a filesystem mutation) running ahead of the plan mode gate:
// options.worktree is processed in runExec before the plan permission mode is
// assigned, so the combination must be rejected during option validation,
// before any worktree prep can occur.
func TestParseExecArgsRejectsPlanWithWorktree(t *testing.T) {
_, _, err := parseExecArgs([]string{"--plan", "--worktree", "draft a plan"})
if err == nil || !strings.Contains(err.Error(), "--worktree") {
t.Fatalf("expected --plan/--worktree validation, got %v", err)
}
}
func TestParseExecArgsRejectsPlanWithNonPlanPermissionMode(t *testing.T) {
_, _, err := parseExecArgs([]string{"--plan", "--permission-mode=ask", "draft a plan"})
if err == nil || !strings.Contains(err.Error(), "--permission-mode") {
t.Fatalf("expected --plan/--permission-mode validation, got %v", err)
}
options, _, err := parseExecArgs([]string{"--plan", "--permission-mode=plan", "draft a plan"})
if err != nil {
t.Fatalf("expected --plan with --permission-mode=plan to succeed, got %v", err)
}
if !options.plan || options.permissionMode != "plan" {
t.Fatalf("expected options.plan=true and permissionMode=plan, got plan=%v mode=%q", options.plan, options.permissionMode)
}
}
// TestParseExecArgsPermissionModePlanSharesCombinationGuards ensures the
// combination rejects that key off --plan also fire for --permission-mode plan,
// which reaches PermissionModePlan without setting options.plan.
func TestParseExecArgsPermissionModePlanSharesCombinationGuards(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"worktree", []string{"--permission-mode", "plan", "--worktree", "draft a plan"}, "--worktree"},
{"use-spec", []string{"--permission-mode=plan", "--use-spec", "draft a plan"}, "not both"},
{"skip-permissions-unsafe", []string{"--permission-mode", "plan", "--skip-permissions-unsafe", "draft a plan"}, "not both"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := parseExecArgs(tc.args)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected --permission-mode plan / %s validation containing %q, got %v", tc.name, tc.want, err)
}
})
}
// Bare --permission-mode plan (no conflicting flags) still parses.
options, _, err := parseExecArgs([]string{"--permission-mode", "plan", "draft a plan"})
if err != nil {
t.Fatalf("expected bare --permission-mode plan to succeed, got %v", err)
}
if options.permissionMode != "plan" || options.plan {
t.Fatalf("expected permissionMode=plan and plan=false, got mode=%q plan=%v", options.permissionMode, options.plan)
}
}
// TestRunExecPlanHidesWriteAndShellToolsFromListing drives the real --plan
// flag through runExec (via --list-tools, so no provider is needed) and
// confirms write_file and bash — advertised under every other mode covered by
// TestRunExecListToolsAppliesModeBeforeListing-style tests — are hidden,
// mirroring the TUI /plan on gating end to end from the CLI entry point.
func TestRunExecPlanHidesWriteAndShellToolsFromListing(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--plan", "--list-tools"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
listing := stdout.String()
if !strings.Contains(listing, "Tools visible to model") {
t.Fatalf("expected --plan --list-tools to list tools, got %q", listing)
}
if !strings.Contains(listing, "read_file") {
t.Fatalf("expected plan mode to still list read_file, got %q", listing)
}
for _, hidden := range []string{"write_file", "edit_file", "apply_patch", "bash"} {
if strings.Contains(listing, hidden) {
t.Fatalf("expected --plan to hide %q from the tool listing, got %q", hidden, listing)
}
}
}
// TestRunExecPermissionModePlanHidesWriteAndShellToolsFromListing covers the
// --permission-mode plan entry path, which does not set options.plan and must
// still hide write and shell tools the same way --plan does.
func TestRunExecPermissionModePlanHidesWriteAndShellToolsFromListing(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--permission-mode", "plan", "--list-tools"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
listing := stdout.String()
if !strings.Contains(listing, "Tools visible to model") {
t.Fatalf("expected --permission-mode plan --list-tools to list tools, got %q", listing)
}
if !strings.Contains(listing, "read_file") {
t.Fatalf("expected plan mode to still list read_file, got %q", listing)
}
for _, hidden := range []string{"write_file", "edit_file", "apply_patch", "bash"} {
if strings.Contains(listing, hidden) {
t.Fatalf("expected --permission-mode plan to hide %q from the tool listing, got %q", hidden, listing)
}
}
}
func TestResolveExecPermissionModePlanOverride(t *testing.T) {
options := execOptions{autonomy: "low", plan: true}
mode, err := resolveExecPermissionMode(options)
if err != nil {
t.Fatalf("resolveExecPermissionMode: %v", err)
}
// resolveExecPermissionMode itself only resolves --auto; the --plan override
// is applied by the caller (runExec) afterward, same as --use-spec. This
// pins the precondition: --plan must not interfere with autonomy resolution.
if mode != agent.PermissionModeAuto {
t.Fatalf("resolveExecPermissionMode with --plan = %q, want auto (override applied by the caller)", mode)
}
}
-18
View File
@@ -73,24 +73,6 @@ func toolListContains(names []string, want string) bool {
}
func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error) {
if pm := strings.ToLower(strings.TrimSpace(options.permissionMode)); pm != "" {
switch pm {
case "plan":
return agent.PermissionModePlan, nil
case "spec-draft", "spec_draft":
return agent.PermissionModeSpecDraft, nil
case "auto":
return agent.PermissionModeAuto, nil
case "member", "member-auto", "member_auto":
return agent.PermissionModeMemberAuto, nil
case "ask":
return agent.PermissionModeAsk, nil
case "unsafe", "high":
return agent.PermissionModeUnsafe, nil
default:
return "", execUsageError{fmt.Sprintf("Invalid permission mode %q. Expected plan, spec-draft, auto, member, ask, or unsafe.", options.permissionMode)}
}
}
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
// invalid autonomy value is always rejected. (Previously the unsafe path
// short-circuited before validation, letting "--auto bogus" slip through
+7 -9
View File
@@ -142,15 +142,13 @@ func (writer *execEventWriter) checkpoint(event sessions.Event) {
}
func (writer *execEventWriter) toolResult(result agent.ToolResult) {
modelOutput := result.ModelOutput()
display := result.HumanDisplay()
if writer.format == execOutputJSON {
payload := map[string]any{
"type": "tool_result",
"tool_call_id": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": modelOutput,
"output": result.Output,
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
@@ -164,14 +162,14 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
if len(result.ChangedFiles) > 0 {
payload["changed_files"] = result.ChangedFiles
}
if display.Summary != "" || display.Kind != "" {
payload["display"] = map[string]string{"summary": display.Summary, "kind": display.Kind}
if result.Display.Summary != "" || result.Display.Kind != "" {
payload["display"] = map[string]string{"summary": result.Display.Summary, "kind": result.Display.Kind}
}
writer.writeJSON(payload)
return
}
if writer.format == execOutputStreamJSON {
output, surfaceTruncated := truncateForStreamJSONOutput(modelOutput)
output, surfaceTruncated := truncateForStreamJSONOutput(result.Output)
truncated := result.Truncated || surfaceTruncated
event := streamjson.Event{
Type: streamjson.EventToolResult,
@@ -188,13 +186,13 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
redacted := true
event.Redacted = &redacted
}
if display.Summary != "" || display.Kind != "" {
event.Display = &streamjson.Display{Summary: display.Summary, Kind: display.Kind}
if result.Display.Summary != "" || result.Display.Kind != "" {
event.Display = &streamjson.Display{Summary: result.Display.Summary, Kind: result.Display.Kind}
}
writer.writeStreamJSON(event)
return
}
writer.writeStderr("[result] " + truncateForStatus(modelOutput) + "\n")
writer.writeStderr("[result] " + truncateForStatus(result.Output) + "\n")
}
func (writer *execEventWriter) permission(event agent.PermissionEvent) {
+1 -1
View File
@@ -78,7 +78,7 @@ func TestRunPluginsListsJSONAndText(t *testing.T) {
}
func TestRunHooksListsRedactedJSONAndText(t *testing.T) {
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
secret := "sk-proj-" + strings.Repeat("a", 24)
result := hooks.LoadResult{
Config: hooks.Config{
Enabled: true,
+1 -1
View File
@@ -87,7 +87,7 @@ func TestRunHooksAddRejectsUnknownEvent(t *testing.T) {
func TestRunHooksAddJSONRedactsSecretArgs(t *testing.T) {
cwd := t.TempDir()
secret := "sk-proj-" + strings.Repeat("z", 23) + "0"
secret := "sk-proj-" + strings.Repeat("z", 24)
var stdout, stderr bytes.Buffer
code := runHooksAdd([]string{"h1", "--event", "beforeTool", "--command", "sh", "--arg", "-c", "--arg", "echo " + secret, "--json"}, &stdout, &stderr, hooksManageDeps(cwd))
if code != exitSuccess {
+1 -1
View File
@@ -516,7 +516,7 @@ func TestRunMCPRemovePreservesUnrelatedConfigFields(t *testing.T) {
func TestRunMCPListRedactsURLCredentialsAndSensitiveQueryParams(t *testing.T) {
cwd := t.TempDir()
serverURL := "https://user:[email protected]/mcp?access_token=secret-token&api_key=secret-key&safe=value#access_token=fragment-secret"
commandSecret := "sk-proj-" + strings.Repeat("a", 23) + "0"
commandSecret := "sk-proj-" + strings.Repeat("a", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
+48 -2
View File
@@ -1,8 +1,13 @@
package cli
import (
"context"
"errors"
"net/http"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
@@ -23,5 +28,46 @@ import (
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
// resolver, and the onboarding presence check never diverge.
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
return providers.OAuthLoginForProfile(profile)
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil, ""
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
// No login under any candidate (or unreadable/invalid keys) → API-key
// auth, no resolver.
return nil, ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// Refreshing a token the user logged into (possibly a preset provider like
// xAI) re-resolves that provider's OAuth config, which needs the preset.
AllowPresets: true,
})
if err != nil {
return nil, ""
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
token, rerr = manager.Handle401(ctx, key)
} else {
token, rerr = manager.GetFresh(ctx, key)
}
if errors.Is(rerr, oauth.ErrNoToken) {
// The login was removed since construction → fall back to the API key.
return "", "", false, nil
}
if rerr != nil {
return "", "", false, rerr
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
}
-12
View File
@@ -73,18 +73,6 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
if description := strings.TrimSpace(model.Description); description != "" {
entry["description"] = description
}
if len(model.ReasoningEfforts) > 0 {
entry["reasoning_efforts"] = append([]string{}, model.ReasoningEfforts...)
}
if effort := strings.TrimSpace(model.DefaultReasoningEffort); effort != "" {
entry["default_reasoning_effort"] = effort
}
if len(model.ServiceTiers) > 0 {
entry["service_tiers"] = append([]string{}, model.ServiceTiers...)
}
if tier := strings.TrimSpace(model.DefaultServiceTier); tier != "" {
entry["default_service_tier"] = tier
}
items = append(items, entry)
}
payload := map[string]any{
+21 -35
View File
@@ -18,8 +18,6 @@ type sandboxCommandOptions struct {
path string
}
const permissionProfileScopeNote = "permissionProfile is derived from this process's environment and working directory; a command run with its own environment can add further credential deny-read entries."
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
@@ -93,25 +91,16 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
return runSandboxPolicyEffective(options, workspaceRoot, policy, backend, plan, store.FilePath(), writeRoots, writeRootsErr, stdout)
}
if options.json {
// The profile here is built from Zero's own environment and working
// directory, because that is all this command has: no command has been
// chosen yet. A nested command carries its own cwd and env, and the
// credential baseline resolves relative token overrides against those, so
// an executed command can carry MORE denyReadIfExists entries than this
// view lists. Say so in the payload rather than leaving a consumer to
// assume it is the whole picture (see PermissionProfileFromPolicy).
payload := struct {
Policy zeroSandbox.Policy `json:"policy"`
Backend zeroSandbox.Backend `json:"backend"`
Plan zeroSandbox.BackendPlan `json:"plan"`
GrantsPath string `json:"grantsPath"`
PermissionProfileNote string `json:"permissionProfileNote"`
Policy zeroSandbox.Policy `json:"policy"`
Backend zeroSandbox.Backend `json:"backend"`
Plan zeroSandbox.BackendPlan `json:"plan"`
GrantsPath string `json:"grantsPath"`
}{
Policy: policy,
Backend: backend,
Plan: plan,
GrantsPath: store.FilePath(),
PermissionProfileNote: permissionProfileScopeNote,
Policy: policy,
Backend: backend,
Plan: plan,
GrantsPath: store.FilePath(),
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
@@ -258,22 +247,20 @@ func runSandboxPolicyEffective(options sandboxCommandOptions, workspaceRoot stri
// JSON consumers see the same signal as the text write_roots_error
// line: a stale sandbox.additionalWriteRoots entry means the real
// entrypoints would refuse to launch, not run workspace-only.
WriteRootsError string `json:"writeRootsError,omitempty"`
Policy zeroSandbox.Policy `json:"policy"`
Backend zeroSandbox.Backend `json:"backend"`
Plan zeroSandbox.BackendPlan `json:"plan"`
Guards sandboxGuards `json:"guards"`
GrantsPath string `json:"grantsPath"`
PermissionProfileNote string `json:"permissionProfileNote"`
WriteRootsError string `json:"writeRootsError,omitempty"`
Policy zeroSandbox.Policy `json:"policy"`
Backend zeroSandbox.Backend `json:"backend"`
Plan zeroSandbox.BackendPlan `json:"plan"`
Guards sandboxGuards `json:"guards"`
GrantsPath string `json:"grantsPath"`
}{
WorkspaceRoot: workspaceRoot,
WriteRoots: writeRoots,
Policy: policy,
Backend: backend,
Plan: plan,
Guards: guards,
GrantsPath: grantsPath,
PermissionProfileNote: permissionProfileScopeNote,
WorkspaceRoot: workspaceRoot,
WriteRoots: writeRoots,
Policy: policy,
Backend: backend,
Plan: plan,
Guards: guards,
GrantsPath: grantsPath,
}
if writeRootsErr != nil {
payload.WriteRootsError = writeRootsErr.Error()
@@ -297,7 +284,6 @@ func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Polic
"network: " + string(policy.Network),
"enforce_workspace: " + fmt.Sprintf("%t", policy.EnforceWorkspace),
"write_roots: " + strings.Join(writeRoots, ", "),
"permission_profile_note: " + permissionProfileScopeNote,
}
if writeRootsErr != nil {
// Fail soft, visibly: a stale sandbox.additionalWriteRoots entry must
+7 -91
View File
@@ -9,8 +9,6 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"testing"
@@ -485,23 +483,14 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// Point all user-config roots at an empty directory so the platform-specific
// credential deny baseline can be asserted without leaking host paths into
// the platform-neutral golden comparison.
// Point HOME at an empty directory so the default credential-store
// deny-read entries (which depend on what exists in the real home, e.g.
// ~/.aws on the macOS CI image) cannot leak host paths into the golden
// comparison.
emptyHome := t.TempDir()
t.Setenv("HOME", emptyHome)
t.Setenv("USERPROFILE", emptyHome)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(emptyHome, ".config"))
t.Setenv("CLOUDSDK_CONFIG", "")
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
t.Setenv("NPM_CONFIG_USERCONFIG", "")
t.Setenv("npm_config_userconfig", "")
t.Setenv("GH_CONFIG_DIR", "")
t.Setenv("NETRC", "")
t.Setenv("DOCKER_CONFIG", "")
t.Setenv("KUBECONFIG", "")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", "")
t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "")
store := newSandboxTestStore(t)
workspace := t.TempDir()
deps := appDeps{
@@ -529,7 +518,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace, emptyHome)
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace)
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
if err != nil {
t.Fatalf("read golden: %v", err)
@@ -539,7 +528,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
}
}
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte {
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte {
t.Helper()
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
@@ -560,56 +549,6 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
plan, _ := value.(map[string]any)["plan"].(map[string]any)
profile, _ := plan["permissionProfile"].(map[string]any)
fileSystem, _ := profile["fileSystem"].(map[string]any)
wantDenyRead := []string(nil)
credentialHome := emptyHome
if runtime.GOOS != "windows" {
if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil {
credentialHome = resolved
}
wantDenyRead = []string{
filepath.Join(credentialHome, ".aws"),
filepath.Join(credentialHome, ".azure"),
// git's cleartext credential stores, in both the home and XDG
// layouts (#816). Listed here so the exported policy JSON is what
// catches a regression: this baseline is the contract a user reads
// with `zero sandbox policy --json`.
filepath.Join(credentialHome, ".git-credentials"),
filepath.Join(credentialHome, ".config", "git", "credentials"),
filepath.Join(credentialHome, ".npmrc"),
filepath.Join(credentialHome, ".netrc"),
filepath.Join(credentialHome, ".kube", "config"),
filepath.Join(credentialHome, ".docker", "config.json"),
filepath.Join(credentialHome, ".config", "gh", "hosts.yml"),
filepath.Join(credentialHome, ".config", "gcloud"),
filepath.Join(credentialHome, ".config", "zero"),
}
}
gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"])
sort.Strings(gotDenyRead)
sort.Strings(wantDenyRead)
if !reflect.DeepEqual(gotDenyRead, wantDenyRead) {
t.Fatalf("manager credential deny baseline = %#v, want %#v", gotDenyRead, wantDenyRead)
}
wantCarveouts := []string(nil)
wantEnsureDirs := []string(nil)
if runtime.GOOS != "windows" {
zeroDir := filepath.Join(credentialHome, ".config", "zero")
wantCarveouts = []string{
filepath.Join(zeroDir, "plugins"),
filepath.Join(zeroDir, "specialists"),
filepath.Join(zeroDir, "commands"),
}
wantEnsureDirs = []string{zeroDir}
}
if gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"]); !reflect.DeepEqual(gotCarveouts, wantCarveouts) {
t.Fatalf("manager credential carveouts = %#v, want %#v", gotCarveouts, wantCarveouts)
}
if gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"]); !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) {
t.Fatalf("manager credential ensure dirs = %#v, want %#v", gotEnsureDirs, wantEnsureDirs)
}
delete(fileSystem, "denyReadIfExists")
delete(fileSystem, "denyReadCarveouts")
delete(fileSystem, "ensureDenyReadDirs")
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
normalized, err := json.MarshalIndent(value, "", " ")
@@ -619,22 +558,6 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
return append(normalized, '\n')
}
func jsonStringSlice(value any) []string {
values, ok := value.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
return nil
}
out = append(out, text)
}
return out
}
func filterJSONStringRoots(value any, excluded map[string]struct{}) any {
roots, ok := value.([]any)
if !ok {
@@ -797,8 +720,7 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
Network bool `json:"network"`
Workspace bool `json:"workspace"`
} `json:"guards"`
GrantsPath string `json:"grantsPath"`
PermissionProfileNote string `json:"permissionProfileNote"`
GrantsPath string `json:"grantsPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
@@ -815,9 +737,6 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
if payload.Plan.SupportLevel != string(sandbox.BackendSupportUnavailable) || payload.GrantsPath == "" {
t.Fatalf("unexpected effective plan/grants: %#v %q", payload.Plan, payload.GrantsPath)
}
if payload.PermissionProfileNote != permissionProfileScopeNote {
t.Fatalf("permissionProfileNote = %q, want %q", payload.PermissionProfileNote, permissionProfileScopeNote)
}
})
}
@@ -829,9 +748,6 @@ func TestEffectiveSandboxPolicyListsWriteRoots(t *testing.T) {
if !strings.Contains(output, "enforce_workspace: true\nwrite_roots: /ws, /extra") {
t.Fatalf("write_roots should directly follow enforce_workspace, got:\n%s", output)
}
if !strings.Contains(output, "permission_profile_note: "+permissionProfileScopeNote) {
t.Fatalf("effective text missing permission profile scope warning:\n%s", output)
}
if strings.Contains(output, "write_roots_error") {
t.Fatalf("unexpected write_roots_error line without an error:\n%s", output)
}
+1 -10
View File
@@ -286,11 +286,7 @@ func runSpecialistCreate(paths specialist.Paths, name string, options specialist
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
return writeSpecialistCreateResult(manifest, options.json, stdout, stderr)
}
func writeSpecialistCreateResult(manifest specialist.Manifest, jsonOutput bool, stdout io.Writer, stderr io.Writer) int {
if jsonOutput {
if options.json {
if err := writePrettyJSON(stdout, manifest); err != nil {
return exitCrash
}
@@ -299,11 +295,6 @@ func writeSpecialistCreateResult(manifest specialist.Manifest, jsonOutput bool,
if _, err := fmt.Fprintf(stdout, "Created specialist %s at %s\n", manifest.Metadata.Name, manifest.FilePath); err != nil {
return exitCrash
}
for _, warning := range manifest.Warnings {
if _, err := fmt.Fprintf(stderr, "warning: %s\n", warning); err != nil {
return exitCrash
}
}
return exitSuccess
}
-37
View File
@@ -10,7 +10,6 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/specialist"
)
func TestRunSpecialistListShowAndPath(t *testing.T) {
@@ -188,42 +187,6 @@ func TestRunSpecialistCreateDeleteAndEdit(t *testing.T) {
}
}
func TestWriteSpecialistCreateResultSurfacesWarningsOnlyForHumanOutput(t *testing.T) {
manifest := specialist.Manifest{
Metadata: specialist.Metadata{Name: "triage"},
FilePath: "/specialists/triage.md",
Warnings: []string{"replacement backup /specialists/.triage.bak retained: access denied"},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := writeSpecialistCreateResult(manifest, false, &stdout, &stderr); code != exitSuccess {
t.Fatalf("human output exit code = %d", code)
}
if got, want := stdout.String(), "Created specialist triage at /specialists/triage.md\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
if got, want := stderr.String(), "warning: replacement backup /specialists/.triage.bak retained: access denied\n"; got != want {
t.Fatalf("stderr = %q, want %q", got, want)
}
stdout.Reset()
stderr.Reset()
if code := writeSpecialistCreateResult(manifest, true, &stdout, &stderr); code != exitSuccess {
t.Fatalf("JSON output exit code = %d", code)
}
var payload specialist.Manifest
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if len(payload.Warnings) != 1 || payload.Warnings[0] != manifest.Warnings[0] {
t.Fatalf("JSON warnings = %#v", payload.Warnings)
}
if stderr.Len() != 0 {
t.Fatalf("JSON warning duplicated to stderr: %q", stderr.String())
}
}
func TestRunSpecialistEditRejectsSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows")
@@ -100,6 +100,5 @@
"shell commands are not wrapped by a native platform sandbox"
]
},
"grantsPath": "$GRANTS",
"permissionProfileNote": "permissionProfile is derived from this process's environment and working directory; a command run with its own environment can add further credential deny-read entries."
"grantsPath": "$GRANTS"
}
+2 -4
View File
@@ -44,8 +44,7 @@ func runTrust(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
func trustCurrentDir(stdout io.Writer, stderr io.Writer, deps appDeps) int {
cwd, err := deps.getwd()
if err != nil {
// Flatten with %s: ErrorMessage stringifies inline, so %w would be a no-op.
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %s", err), redaction.Options{}), exitCrash)
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
}
if err := workspacetrust.Trust(cwd); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
@@ -87,8 +86,7 @@ func trustRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
case 0:
cwd, err := deps.getwd()
if err != nil {
// Flatten with %s: ErrorMessage stringifies inline, so %w would be a no-op.
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %s", err), redaction.Options{}), exitCrash)
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
}
target = cwd
case 1:
+2 -2
View File
@@ -202,8 +202,8 @@ Flags:
--check Check the latest GitHub release without installing
--apply Download, verify, and install the latest release
--json Print the update result as JSON
--repo <owner/repo> Repository to use when no endpoint is provided (--check and --apply; ignored for the install source by an npm-managed apply)
--endpoint <url|repo> Release API URL or owner/repo slug to use (--check and --apply; ignored for the install source by an npm-managed apply)
--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 with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
+4 -344
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -93,7 +92,7 @@ func TestRunWorktreesPrepareReportsErrors(t *testing.T) {
}
func TestRunWorktreesPrepareRedactsPathsInOutput(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0"
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
cwd := filepath.Join(t.TempDir(), secret, "repo")
if err := os.MkdirAll(cwd, 0o700); err != nil {
t.Fatal(err)
@@ -154,194 +153,6 @@ func TestRunWorktreesPrepareRejectsDuplicateNames(t *testing.T) {
}
}
func TestRunWorktreesRelease(t *testing.T) {
worktreeDir := t.TempDir()
var releasedPath string
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", worktreeDir}, &stdout, &stderr, appDeps{
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
releasedPath = path
return nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if releasedPath != worktreeDir {
t.Fatalf("released path = %q, want %q", releasedPath, worktreeDir)
}
if !strings.Contains(stdout.String(), worktreeDir) {
t.Fatalf("expected confirmation output to mention path, got %q", stdout.String())
}
}
func TestRunWorktreesReleaseNormalizesRelativePath(t *testing.T) {
// git worktree unlock matches against the path git recorded when the
// worktree was created, so a relative argument (resolved against
// whatever directory the caller happens to be running `zero` from) can
// fail to match. Chdir into a known directory and pass a relative
// argument to confirm it reaches releaseWorktree as an absolute path.
origWd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
parent := t.TempDir()
worktreeDir := filepath.Join(parent, "task-a")
if err := os.Mkdir(worktreeDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Chdir(parent); err != nil {
t.Fatal(err)
}
defer func() {
if err := os.Chdir(origWd); err != nil {
t.Fatal(err)
}
}()
// filepath.Abs resolves against os.Getwd, whose spelling of the temp dir
// can differ from t.TempDir()'s (macOS reports /private/var for /var), so
// derive the expected value through the same resolution instead of
// joining onto the lexical parent path.
expectedPath, err := filepath.Abs("task-a")
if err != nil {
t.Fatal(err)
}
var releasedPath string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", "task-a"}, &stdout, &stderr, appDeps{
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
releasedPath = path
return nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if releasedPath != expectedPath {
t.Fatalf("released path = %q, want absolute %q", releasedPath, expectedPath)
}
}
func TestRunWorktreesReleaseRequiresPath(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release"}, &stdout, &stderr, appDeps{
releaseWorktree: func(context.Context, worktrees.Options, string) error {
t.Fatal("releaseWorktree should not be called without a path")
return nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if !strings.Contains(stderr.String(), "requires a worktree path") {
t.Fatalf("expected missing-path error, got %q", stderr.String())
}
}
func TestRunWorktreesReleaseWiresWorkspaceCwd(t *testing.T) {
// Release only consults Options.Cwd when the worktree directory itself is
// already gone (deleted by hand instead of released); git then has to run
// from the source repository to clear the orphaned lock. The CLI must
// wire the resolved workspace root through, or that advertised recovery
// path runs git from a possibly non-repository directory and the lock is
// never cleared (Clean skips locked entries).
root := t.TempDir()
var gotCwd string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", filepath.Join(root, "already-deleted")}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
gotCwd = options.Cwd
return nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if gotCwd != root {
t.Fatalf("release Options.Cwd = %q, want workspace root %q", gotCwd, root)
}
}
func TestRunWorktreesReleaseHonorsExplicitCwd(t *testing.T) {
// The deleted-path recovery cannot derive the source repository from the
// worktree path (the directory is gone and its name is a one-way hash),
// so -C names it explicitly; the resolved root must reach Release as
// Options.Cwd regardless of where the command was launched.
launchDir := t.TempDir()
repoDir := t.TempDir()
var gotCwd string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", "-C", repoDir, filepath.Join(repoDir, "already-deleted")}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return launchDir, nil },
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
gotCwd = options.Cwd
return nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if gotCwd != repoDir {
t.Fatalf("release Options.Cwd = %q, want explicit -C root %q", gotCwd, repoDir)
}
}
func TestRunWorktreesReleaseReportsErrors(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", "/no/such/worktree"}, &stdout, &stderr, appDeps{
releaseWorktree: func(context.Context, worktrees.Options, string) error {
return errors.New("unlock git worktree: not a valid worktree")
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if !strings.Contains(stderr.String(), "not a valid worktree") {
t.Fatalf("expected underlying error, got %q", stderr.String())
}
}
func TestRunWorktreesReleaseRedactsErrorText(t *testing.T) {
// Release errors interpolate the caller-supplied path (see
// verifyZeroOwnedWorktree's "refusing to release %s" messages); unlike the
// success path, which redacts before printing, the error was previously
// forwarded to stderr verbatim, so a rejected path containing a key-shaped
// segment would reach terminal/model-visible output unredacted.
secret := "sk-proj-abcDEF123_ghiJKL456-mnoPQR789stu"
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", "/tmp/" + secret + "/task"}, &stdout, &stderr, appDeps{
releaseWorktree: func(context.Context, worktrees.Options, string) error {
return fmt.Errorf("refusing to release /tmp/%s/task: not a registered worktree of this repository", secret)
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("release error leaked unredacted key-shaped path: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("expected redaction placeholder in error output, got %q", stderr.String())
}
}
func TestRunVerifyTextAndJSON(t *testing.T) {
cwd := t.TempDir()
plan := verify.Plan{Root: cwd, Checks: []verify.Check{{ID: "go.test", Name: "Go tests", Command: []string{"go", "test", "./..."}}}}
@@ -427,7 +238,7 @@ func TestRunVerifyTextAndJSON(t *testing.T) {
}
func TestRunVerifyRedactsWorkspacePathsInOutput(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0"
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
cwd := filepath.Join(t.TempDir(), secret, "workspace")
if err := os.MkdirAll(cwd, 0o700); err != nil {
t.Fatal(err)
@@ -810,157 +621,6 @@ func TestRunExecWorktreeUsesPreparedWorkspace(t *testing.T) {
}
}
func TestRunExecWorktreeReleasesLockAfterRun(t *testing.T) {
root := t.TempDir()
worktreeDir := t.TempDir()
var releasedPath string
releaseCalls := 0
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) {
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil
},
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
releaseCalls++
releasedPath = path
return nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
// A `zero exec --worktree` run is the only user of the worktree it prepares:
// its own process created it and is now exiting, so it must release the
// Prepare lock itself rather than leaving it locked forever (unlike `zero
// worktrees prepare`, which hands the path to a longer-lived external
// caller and has no such end-of-life signal to act on).
if releaseCalls != 1 {
t.Fatalf("releaseWorktree call count = %d, want 1", releaseCalls)
}
if releasedPath != worktreeDir {
t.Fatalf("released path = %q, want %q", releasedPath, worktreeDir)
}
}
func TestRunExecWorktreeKeepsLockItDidNotAcquire(t *testing.T) {
// Defense in depth: Prepare now rejects an in-use lease outright, so a
// successful result always reports LockAcquired=true. Should that ever
// change, exec must still only release the ownership its own invocation
// established; releasing a lock it did not acquire would clear another
// caller's lease and let a later Clean force-delete a live workspace.
root := t.TempDir()
worktreeDir := t.TempDir()
releaseCalls := 0
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) {
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", Reused: true, LockAcquired: false}, nil
},
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
releaseCalls++
return nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if releaseCalls != 0 {
t.Fatalf("releaseWorktree call count = %d, want 0 for a lock this run did not acquire", releaseCalls)
}
}
func TestRunExecWorktreeSurfacesReleaseFailure(t *testing.T) {
// A failed unlock leaves a lock Clean permanently skips, recreating the
// disk leak silently; the failure must reach the user with the affected
// path so the leaked lock can be cleared by hand.
root := t.TempDir()
worktreeDir := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) {
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil
},
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
return errors.New("unlock git worktree: boom")
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), worktreeDir) || !strings.Contains(stderr.String(), "boom") {
t.Fatalf("expected release failure with path on stderr, got %q", stderr.String())
}
}
func TestRunExecWorktreeRedactsReleaseFailureText(t *testing.T) {
// The release path argument was already redacted before this diagnostic
// was added, but the error's own text was forwarded verbatim; Release's
// ownership errors interpolate the caller-supplied path (see
// verifyZeroOwnedWorktree), so a key-shaped path reaching this message
// leaked unredacted onto stderr.
root := t.TempDir()
worktreeDir := t.TempDir()
secret := "sk-proj-abcDEF123_ghiJKL456-mnoPQR789stu"
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) {
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil
},
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
return fmt.Errorf("refusing to release /tmp/%s/task: not a registered worktree of this repository", secret)
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("deferred release diagnostic leaked unredacted key-shaped path: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("expected redaction placeholder in deferred release diagnostic, got %q", stderr.String())
}
}
func TestRunExecRejectsForkWithWorktree(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1060,7 +720,7 @@ func TestRunChangesCommitAuto(t *testing.T) {
Root: cwd,
Branch: "main",
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
Diff: "some diff content with ghp_1234567890abcdefghijklmnopqrstuvwxyz",
Diff: "some diff content with ghp_SECRETKEYHERE",
}
mockProv := &mockCommitMsgProvider{
@@ -1108,7 +768,7 @@ func TestRunChangesCommitAuto(t *testing.T) {
}
// Verify that secret in the diff was redacted
promptContent := mockProv.req.Messages[0].Content
if strings.Contains(promptContent, "ghp_1234567890abcdefghijklmnopqrstuvwxyz") {
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") {
+1 -97
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"time"
@@ -64,9 +63,6 @@ func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDep
}
return exitSuccess
}
if command == "release" {
return runWorktreesRelease(args, stdout, stderr, deps)
}
if command != "prepare" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees command %q", command))
}
@@ -106,85 +102,6 @@ func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return exitSuccess
}
// runWorktreesRelease unlocks a worktree `zero worktrees prepare` created, so
// Zero's automatic Clean pass can reclaim it once it goes stale. Prepare locks
// every worktree it creates and nothing else in that command's own lifetime
// unlocks it (its process exits right after printing the path, long before
// whatever external caller actually finishes using the worktree), so that
// caller is responsible for running this once it is done.
func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
var path string
var cwdFlag string
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
if err := writeWorktreesHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
case arg == "-C" || arg == "--cwd":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
cwdFlag = value
index = next
case strings.HasPrefix(arg, "--cwd="):
cwdFlag = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
case strings.HasPrefix(arg, "-"):
return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees release flag %q", arg))
default:
if path != "" {
return writeExecUsageError(stderr, "worktree path was provided more than once")
}
path = arg
}
}
path = strings.TrimSpace(path)
if path == "" {
return writeExecUsageError(stderr, "worktrees release requires a worktree path")
}
// git worktree unlock matches against the path git recorded when the
// worktree was created, so a relative argument here (resolved against
// whatever directory the caller happens to be running in, not
// necessarily the worktree's own) can fail to match. Resolve to absolute
// up front so the unlock target is unambiguous regardless of cwd.
absPath, err := filepath.Abs(path)
if err != nil {
return writeExecUsageError(stderr, redactCLIString(err.Error()))
}
// Release falls back to Options.Cwd as git's working directory when the
// worktree directory itself was deleted by hand instead of released; with
// no Cwd wired in, that recovery path runs `git worktree unlock` from
// wherever the caller happens to be, which outside the source repository
// leaves the orphaned lock behind forever (Clean skips locked entries).
// -C/--cwd names the source repository explicitly for exactly that case,
// since the deleted worktree path itself carries no way back to the repo;
// without the flag, resolve the launch directory best-effort (the
// ordinary existing-path case does not need it, so a resolution failure
// is not an error here).
releaseOptions := worktrees.Options{}
if cwdFlag != "" {
workspaceRoot, err := resolveWorkspaceRoot(cwdFlag, deps)
if err != nil {
return writeExecUsageError(stderr, redactCLIString(err.Error()))
}
releaseOptions.Cwd = workspaceRoot
} else if workspaceRoot, rootErr := resolveWorkspaceRoot("", deps); rootErr == nil {
releaseOptions.Cwd = workspaceRoot
}
if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil {
return writeExecUsageError(stderr, redactCLIString(err.Error()))
}
// Print the absolute path that was released so relative arguments still
// show an unambiguous target in the confirmation line.
if _, err := fmt.Fprintf(stdout, "released %s\n", redactCLIString(absPath)); err != nil {
return exitCrash
}
return exitSuccess
}
func runVerifyCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseVerifyCommandArgs(args)
if err != nil {
@@ -869,28 +786,15 @@ func formatCommitResult(result zerogit.CommitResult) string {
func writeWorktreesHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero worktrees prepare [flags] [name]
zero worktrees release [flags] <path>
Prepares an isolated git worktree for a Zero task.
prepare locks the worktree it creates so Zero's automatic cleanup never
removes it out from under you. release unlocks a worktree prepare created,
once you are done with it, so cleanup can reclaim it later if it goes stale.
If the worktree directory was deleted by hand, run release with -C pointing
at the source repository so the orphaned lock can still be cleared.
prepare flags:
Flags:
--name <name> Worktree name; defaults to a timestamped task name
--dir <path> Base directory for Zero worktrees
-C, --cwd <path> Source repository directory
--json Print JSON output
-h, --help Show this help
release flags:
-C, --cwd <path> Source repository directory; needed when the
worktree directory was already deleted and you
are not running inside the source repository
-h, --help Show this help
`)
return err
}
-2
View File
@@ -197,10 +197,8 @@ func shellCommand(command string) *exec.Cmd {
if strings.HasPrefix(strings.TrimSpace(command), `"`) {
command = "call " + command
}
/* #nosec G204 -- Provider commands are intentionally evaluated by a shell to support features like pipes and env expansions. The command string originates from the user's trusted local config. */
return exec.Command("cmd", "/C", command)
}
/* #nosec G204 -- Provider commands are intentionally evaluated by a shell to support features like pipes and env expansions. The command string originates from the user's trusted local config. */
return exec.Command("sh", "-c", command)
}
-294
View File
@@ -1,294 +0,0 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
)
type jsonObjectSpan struct {
start int
end int
members []jsonMemberSpan
}
type jsonMemberSpan struct {
key string
leadingStart int
keyStart int
valueStart int
valueEnd int
commaAfter int
}
// setPetPreferenceJSON changes only preferences.pet. It keeps the original
// bytes for every unrelated member so choosing a companion does not reorder or
// reformat the user's configuration file.
func setPetPreferenceJSON(data []byte, pet string) ([]byte, error) {
rootStart := skipJSONSpace(data, 0)
root, err := parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
preferencesIndex := lastJSONMember(root.members, "preferences")
if preferencesIndex < 0 {
if pet == "" {
return data, nil
}
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
return insertJSONMember(data, root, "preferences", petJSONObject(encodedPet)), nil
}
preferenceMember := root.members[preferencesIndex]
preferenceValue := bytes.TrimSpace(data[preferenceMember.valueStart:preferenceMember.valueEnd])
if bytes.Equal(preferenceValue, []byte("null")) {
if pet == "" {
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
return data, nil
}
return removeJSONMember(data, root, preferencesIndex), nil
}
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
return replaceJSONRange(data, preferenceMember.valueStart, preferenceMember.valueEnd, petJSONObject(encodedPet)), nil
}
preferences, err := parseJSONObject(data, preferenceMember.valueStart)
if err != nil {
return nil, fmt.Errorf("preferences must be a JSON object: %w", err)
}
petIndex := lastJSONMember(preferences.members, "pet")
if pet != "" {
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
if petIndex >= 0 {
member := preferences.members[petIndex]
return replaceJSONRange(data, member.valueStart, member.valueEnd, encodedPet), nil
}
return insertJSONMember(data, preferences, "pet", encodedPet), nil
}
for petIndex >= 0 {
data = removeJSONMember(data, preferences, petIndex)
root, err = parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
preferencesIndex = lastJSONMember(root.members, "preferences")
if preferencesIndex < 0 {
return data, nil
}
preferenceMember = root.members[preferencesIndex]
preferences, err = parseJSONObject(data, preferenceMember.valueStart)
if err != nil {
return nil, err
}
petIndex = lastJSONMember(preferences.members, "pet")
}
if len(preferences.members) == 0 {
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
return data, nil
}
return removeJSONMember(data, root, preferencesIndex), nil
}
return data, nil
}
func parseJSONObject(data []byte, start int) (jsonObjectSpan, error) {
if start < 0 || start >= len(data) || data[start] != '{' {
return jsonObjectSpan{}, fmt.Errorf("expected JSON object")
}
object := jsonObjectSpan{start: start}
leadingStart := start + 1
for {
i := skipJSONSpace(data, leadingStart)
if i >= len(data) {
return jsonObjectSpan{}, fmt.Errorf("unterminated JSON object")
}
if data[i] == '}' {
object.end = i
return object, nil
}
if data[i] != '"' {
return jsonObjectSpan{}, fmt.Errorf("expected JSON object key")
}
keyStart := i
keyEnd, err := scanJSONString(data, i)
if err != nil {
return jsonObjectSpan{}, err
}
var key string
if err := json.Unmarshal(data[keyStart:keyEnd], &key); err != nil {
return jsonObjectSpan{}, err
}
i = skipJSONSpace(data, keyEnd)
if i >= len(data) || data[i] != ':' {
return jsonObjectSpan{}, fmt.Errorf("expected colon after JSON object key")
}
valueStart := skipJSONSpace(data, i+1)
valueEnd, err := scanJSONValue(data, valueStart)
if err != nil {
return jsonObjectSpan{}, err
}
i = skipJSONSpace(data, valueEnd)
member := jsonMemberSpan{
key: key,
leadingStart: leadingStart,
keyStart: keyStart,
valueStart: valueStart,
valueEnd: valueEnd,
commaAfter: -1,
}
if i < len(data) && data[i] == ',' {
member.commaAfter = i
object.members = append(object.members, member)
leadingStart = i + 1
continue
}
if i >= len(data) || data[i] != '}' {
return jsonObjectSpan{}, fmt.Errorf("expected comma or end of JSON object")
}
object.members = append(object.members, member)
object.end = i
return object, nil
}
}
func scanJSONString(data []byte, start int) (int, error) {
for i := start + 1; i < len(data); i++ {
switch data[i] {
case '\\':
i++
case '"':
return i + 1, nil
}
}
return 0, fmt.Errorf("unterminated JSON string")
}
func scanJSONValue(data []byte, start int) (int, error) {
if start >= len(data) {
return 0, fmt.Errorf("missing JSON value")
}
if data[start] == '"' {
return scanJSONString(data, start)
}
if data[start] == '{' || data[start] == '[' {
stack := []byte{data[start]}
for i := start + 1; i < len(data); i++ {
if data[i] == '"' {
end, err := scanJSONString(data, i)
if err != nil {
return 0, err
}
i = end - 1
continue
}
switch data[i] {
case '{', '[':
stack = append(stack, data[i])
case '}', ']':
open := stack[len(stack)-1]
if (open == '{' && data[i] != '}') || (open == '[' && data[i] != ']') {
return 0, fmt.Errorf("mismatched JSON delimiter")
}
stack = stack[:len(stack)-1]
if len(stack) == 0 {
return i + 1, nil
}
}
}
return 0, fmt.Errorf("unterminated JSON value")
}
for i := start; i < len(data); i++ {
switch data[i] {
case ',', '}', ']', ' ', '\t', '\r', '\n':
if i == start {
return 0, fmt.Errorf("missing JSON value")
}
return i, nil
}
}
return len(data), nil
}
func skipJSONSpace(data []byte, start int) int {
for start < len(data) {
switch data[start] {
case ' ', '\t', '\r', '\n':
start++
default:
return start
}
}
return start
}
func lastJSONMember(members []jsonMemberSpan, key string) int {
for i := len(members) - 1; i >= 0; i-- {
if members[i].key == key {
return i
}
}
return -1
}
func petJSONObject(encodedPet []byte) []byte {
result := append([]byte(`{"pet":`), encodedPet...)
return append(result, '}')
}
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
result = append(result, data[:start]...)
result = append(result, replacement...)
return append(result, data[end:]...)
}
func removeJSONMember(data []byte, object jsonObjectSpan, index int) []byte {
member := object.members[index]
start, end := member.leadingStart, member.valueEnd
if member.commaAfter >= 0 {
end = member.commaAfter + 1
} else if index > 0 {
start = object.members[index-1].commaAfter
}
return replaceJSONRange(data, start, end, nil)
}
func insertJSONMember(data []byte, object jsonObjectSpan, key string, value []byte) []byte {
encodedKey, _ := json.Marshal(key)
entry := append(append(encodedKey, ':', ' '), value...)
if len(object.members) == 0 {
interior := data[object.start+1 : object.end]
if newline := bytes.LastIndexByte(interior, '\n'); newline >= 0 {
closingIndent := interior[newline+1:]
indentUnit := []byte(" ")
if bytes.Contains(closingIndent, []byte("\t")) {
indentUnit = []byte("\t")
}
lineBreak := []byte("\n")
if newline > 0 && interior[newline-1] == '\r' {
lineBreak = []byte("\r\n")
}
insert := append(append(append(lineBreak, closingIndent...), indentUnit...), entry...)
return replaceJSONRange(data, object.start+1, object.start+1, insert)
}
return replaceJSONRange(data, object.end, object.end, entry)
}
last := object.members[len(object.members)-1]
separator := []byte(", ")
if bytes.Contains(data[last.valueEnd:object.end], []byte("\n")) {
lineStart := bytes.LastIndexByte(data[:last.keyStart], '\n') + 1
indent := data[lineStart:last.keyStart]
separator = append([]byte(",\n"), indent...)
}
insert := append(separator, entry...)
return replaceJSONRange(data, last.valueEnd, last.valueEnd, insert)
}
-252
View File
@@ -1,252 +0,0 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSetPetPreservesUnrelatedConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := FileConfig{
ActiveProvider: "test",
Providers: []ProviderProfile{{Name: "test", Model: "example"}},
Preferences: PreferencesConfig{Theme: "dracula"},
}
data, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
updated, err := SetPet(path, "boba")
if err != nil {
t.Fatal(err)
}
if updated.Preferences.Pet != "boba" || updated.Preferences.Theme != "dracula" || updated.ActiveProvider != "test" || len(updated.Providers) != 1 {
t.Fatalf("SetPet lost config fields: %#v", updated)
}
reloadedData, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var reloaded FileConfig
if err := json.Unmarshal(reloadedData, &reloaded); err != nil {
t.Fatal(err)
}
if reloaded.Preferences.Pet != "boba" || reloaded.Preferences.Theme != "dracula" || reloaded.ActiveProvider != "test" || len(reloaded.Providers) != 1 {
t.Fatalf("persisted config lost fields: %#v", reloaded)
}
}
func TestSetPetRejectsInvalidInputs(t *testing.T) {
if _, err := SetPet(" ", "boba"); err == nil {
t.Fatal("SetPet accepted a blank config path")
}
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"preferences":`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err == nil || !strings.Contains(err.Error(), "invalid config JSON") {
t.Fatalf("SetPet malformed JSON error = %v", err)
}
}
func TestSetPetCreatesNestedConfigAndClearsEmptyPreferences(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "zero", "config.json")
if _, err := SetPet(path, " boba "); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
var preferences struct {
Pet string `json:"pet"`
}
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
t.Fatal(err)
}
if preferences.Pet != "boba" {
t.Fatalf("persisted pet = %q, want %q", preferences.Pet, "boba")
}
root["future"] = json.RawMessage(`{"enabled":true}`)
encoded, err := json.Marshal(root)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, " "); err != nil {
t.Fatal(err)
}
data, err = os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
root = nil
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["preferences"]; ok {
t.Fatalf("empty preferences were retained: %s", data)
}
var future struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(root["future"], &future); err != nil {
t.Fatal(err)
}
if !future.Enabled {
t.Fatalf("unrelated future member was lost: %s", data)
}
}
func TestSetPetPreservesUnknownJSONMembers(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := []byte(`{
"activeProvider": "test",
"futureTopLevel": {"enabled": true},
"preferences": {
"theme": "dracula",
"futurePreference": {"mode": "extended"}
}
}`)
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
var futureTopLevel struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(root["futureTopLevel"], &futureTopLevel); err != nil {
t.Fatal(err)
}
if !futureTopLevel.Enabled {
t.Fatalf("unknown top-level member = %s", root["futureTopLevel"])
}
var preferences map[string]json.RawMessage
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
t.Fatal(err)
}
var futurePreference struct {
Mode string `json:"mode"`
}
if err := json.Unmarshal(preferences["futurePreference"], &futurePreference); err != nil {
t.Fatal(err)
}
if futurePreference.Mode != "extended" {
t.Fatalf("unknown preference member = %s", preferences["futurePreference"])
}
if got := string(preferences["pet"]); got != `"boba"` {
t.Fatalf("pet preference = %s, want boba", got)
}
}
func TestSetPetPreservesExistingJSONLayoutAndMemberOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := []byte("{\n\t\"zFuture\": {\"enabled\":true},\n\t\"preferences\": {\n\t\t\"theme\" : \"dracula\",\n\t\t\"pet\" : \"old\",\n\t\t\"futurePreference\": {\"mode\":\"extended\"}\n\t},\n\t\"activeProvider\": \"test\"\n}\n")
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(string(original), `"old"`, `"boba"`, 1)
if string(got) != want {
t.Fatalf("SetPet reformatted or reordered config:\n--- got ---\n%s--- want ---\n%s", got, want)
}
}
func TestSetPetMinimallyEditsExistingJSON(t *testing.T) {
tests := []struct {
name string
original string
pet string
want string
}{
{
name: "add pet to existing preferences",
original: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\"\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
},
{
name: "add preferences without reordering root",
original: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\",\n \"preferences\": {\"pet\":\"boba\"}\n}\n",
},
{
name: "remove pet but retain preferences",
original: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
pet: "",
want: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
},
{
name: "remove empty preferences member",
original: "{\n \"zFuture\": true,\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "",
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
},
{
name: "do not expose an older duplicate preference",
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{\"pet\":\"dog\"}}\n",
pet: "",
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{}}\n",
},
{
name: "do not expose an older duplicate behind null",
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
pet: "",
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
},
{
name: "preserve multiline empty preferences layout",
original: "{\n \"preferences\": {\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(test.original), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, test.pet); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != test.want {
t.Fatalf("SetPet output:\n--- got ---\n%s--- want ---\n%s", got, test.want)
}
})
}
}
-3
View File
@@ -273,9 +273,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
if strings.TrimSpace(src.Preferences.Pet) != "" {
dst.Preferences.Pet = strings.TrimSpace(src.Preferences.Pet)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
mergeSTTConfig(&dst.STT, src.STT)
-16
View File
@@ -168,22 +168,6 @@ func TestResolveLoadsRecentModelsFromUserConfigOnly(t *testing.T) {
}
}
func TestResolveLoadsPetFromUserConfigOnly(t *testing.T) {
userPath := writeConfig(t, `{"preferences":{"pet":" boba "}}`)
projectPath := writeConfig(t, `{"preferences":{"pet":"project-pet"}}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{},
})
if err != nil {
t.Fatal(err)
}
if resolved.Preferences.Pet != "boba" {
t.Fatalf("Preferences.Pet = %q, want user preference boba", resolved.Preferences.Pet)
}
}
// recentModels must never be merged from project config (same posture as
// favoriteModels): a project setting it with no corresponding user setting
// should leave the resolved value empty, not pick up the project's list.
-3
View File
@@ -112,9 +112,6 @@ type PreferencesConfig struct {
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Pet is the terminal companion selected through /pets. Empty leaves pets
// off until the user chooses one; "disabled" is the explicit off state.
Pet string `json:"pet,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
+4 -39
View File
@@ -591,35 +591,6 @@ func SetTheme(path string, theme string) (FileConfig, error) {
return cfg, nil
}
// SetPet persists only the terminal-pet preference while preserving every
// unrelated user setting through the config writer's atomic replace path.
func SetPet(path string, pet string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
data := []byte("{}")
if existing, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(existing, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
data = existing
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
pet = strings.TrimSpace(pet)
cfg.Preferences.Pet = pet
data, err := setPetPreferenceJSON(data, pet)
if err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
if err := writeConfigData(path, data); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
// SetSTTModel persists the dictation model and its provider, mirroring
// SetTheme (read-modify-atomic-write). provider must be one of the known STT
// provider kinds; a local provider stores the model as stt.localModelPath,
@@ -765,23 +736,17 @@ func NormalizeRecentModels(entries []RecentModelEntry) []RecentModelEntry {
}
func writeConfigFile(path string, cfg FileConfig) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config JSON: %w", err)
}
return writeConfigData(path, data)
}
func writeConfigData(path string, data []byte) error {
dir := filepath.Dir(path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory %s: %w", dir, err)
}
}
if len(data) == 0 || data[len(data)-1] != '\n' {
data = append(data, '\n')
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config JSON: %w", err)
}
data = append(data, '\n')
// Write-to-temp + rename: an in-place write interrupted mid-way (crash,
// disk full) would leave the user's only config truncated or corrupt.
tmp, err := os.CreateTemp(dir, ".zero-config-*.tmp")
-41
View File
@@ -3,47 +3,12 @@ package fsutil
import (
"errors"
"fmt"
"os"
"runtime"
"syscall"
"time"
)
// CommittedReplacementCleanupError reports that a replacement was committed,
// but the old destination retained at BackupPath could not be removed. Callers
// must treat the replacement itself as successful and surface the cleanup
// problem separately.
type CommittedReplacementCleanupError struct {
BackupPath string
Cause error
}
func (err *CommittedReplacementCleanupError) Error() string {
return fmt.Sprintf("replacement committed, but backup %s could not be removed: %v", err.BackupPath, err.Cause)
}
func (err *CommittedReplacementCleanupError) Unwrap() error {
return err.Cause
}
// ReplaceWithRetry publishes src over dst using the platform's replacement
// primitive, retrying on the same transient Windows lock errors RenameWithRetry
// handles. On Windows it uses ReplaceFileW so an existing destination keeps its
// DACL and selected metadata instead of receiving the temporary file's inherited
// DACL. ReplaceFileW is not observer-atomic and may briefly leave dst absent;
// callers that cannot tolerate that must synchronize their own readers. On Unix
// replacement uses os.Rename.
//
// replace overrides the platform primitive so tests can exercise the retry path;
// pass nil for the default.
func ReplaceWithRetry(src, dst string, replace func(src, dst string) error) error {
if replace == nil {
replace = replaceExisting
}
return RenameWithRetry(src, dst, replace)
}
// RenameWithRetry renames src to dst, retrying briefly on Windows when the
// destination is transiently locked (antivirus scanners, search indexers, or
// a concurrent reader holding the file open). rename overrides os.Rename so
@@ -58,12 +23,6 @@ func RenameWithRetry(src, dst string, rename func(src, dst string) error) error
if err == nil {
return nil
}
var committed *CommittedReplacementCleanupError
if errors.As(err, &committed) {
// The source has already been consumed. In particular, never retry if
// the cleanup cause itself is a transient Windows sharing violation.
break
}
if runtime.GOOS == "windows" {
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
time.Sleep(10 * time.Millisecond)
-12
View File
@@ -1,12 +0,0 @@
//go:build !windows
package fsutil
import "os"
// replaceExisting publishes src over dst. rename(2) already replaces the
// destination atomically within one filesystem on Unix, and it neither creates
// nor consults an ACL, so there is nothing extra to preserve here.
func replaceExisting(src, dst string) error {
return os.Rename(src, dst)
}
-50
View File
@@ -1,50 +0,0 @@
//go:build !windows
package fsutil
import (
"os"
"path/filepath"
"testing"
)
// On Unix the replacement primitive is rename(2), which already publishes
// atomically within one filesystem and neither creates nor consults an ACL. These
// cover both shapes so the shared helper is exercised on every platform.
func TestReplaceWithRetryPublishesOverExistingAndMissingDestinations(t *testing.T) {
for _, tc := range []struct {
name string
existing bool
}{
{name: "existing destination", existing: true},
{name: "missing destination"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if tc.existing {
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
}
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile dst: %v", err)
}
if string(data) != "new" {
t.Fatalf("destination content = %q, want the replacement bytes", data)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("the replacement file should be consumed by the replace: %v", err)
}
})
}
}
-237
View File
@@ -1,237 +0,0 @@
//go:build windows
package fsutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"time"
"unsafe"
)
// replaceFileFlags is deliberately ZERO. Every REPLACEFILE_* flag ReplaceFileW
// accepts either defeats the reason this function exists or does nothing:
//
// - REPLACEFILE_IGNORE_MERGE_ERRORS (0x2) and REPLACEFILE_IGNORE_ACL_ERRORS
// (0x4). Microsoft documents BOTH with the same consequence: "if you specify
// this flag and do not have WRITE_DAC access, the function succeeds but the
// ACLs are not preserved." A silent success that publishes the temporary
// file's inherited directory DACL over an explicitly restricted specialist —
// exposing its system prompt — is precisely the failure this function exists
// to prevent, so a merge failure MUST surface as an error and leave the
// destination untouched. Passing 0x2 while omitting only 0x4 buys nothing:
// ACL merging is part of the metadata merge 0x2 covers.
// - REPLACEFILE_WRITE_THROUGH (0x1) is documented as "This value is not
// supported", so it cannot be relied on to flush anything.
const replaceFileFlags = 0
const replaceBackupPattern = ".zero-replace-*.backup"
const (
// Returned when the volume or redirector cannot provide ReplaceFileW's
// DACL-preserving semantics. Existing destinations must fail closed.
errorInvalidFunction = syscall.Errno(1)
errorNotSupported = syscall.Errno(50)
// Partial-failure codes: ReplaceFileW got far enough to move or delete
// something, so the on-disk state needs repair rather than a bare error. See
// recoverPartialReplace.
errorUnableToRemoveReplaced = syscall.Errno(1175)
errorUnableToMoveReplacement = syscall.Errno(1176)
errorUnableToMoveReplacement2 = syscall.Errno(1177)
)
var (
replaceKernel32 = syscall.NewLazyDLL("kernel32.dll")
replaceProcReplaceFil = replaceKernel32.NewProc("ReplaceFileW")
)
// replaceExisting publishes src over dst with ReplaceFileW rather than
// MoveFileEx (what os.Rename uses) to preserve destination metadata. The
// replacement is a freshly created temporary file, so it carries the directory's
// inherited DACL. Renaming it over the destination would therefore replace the
// destination's ACL - silently widening access to a file that had been restricted
// explicitly (os.File.Chmod cannot express that on Windows; Go only maps the
// owner-write bit). ReplaceFileW carries the replaced file's DACL and selected
// metadata over to the replacement instead.
//
// ReplaceFileW combines multiple filesystem steps and is not observer-atomic: an
// external reader can briefly see dst absent even when replacement succeeds.
// Callers that cannot tolerate that window must synchronize their own readers.
//
// No REPLACEFILE_* flag is passed at all — see replaceFileFlags for why each one
// would either silently lose the descriptor this function exists to preserve or
// do nothing. A merge failure therefore surfaces as an error, leaving the
// destination untouched and the caller free to clean up its temporary file,
// except in the partial-failure states recoverPartialReplace repairs.
func replaceExisting(src, dst string) error {
return replaceExistingWith(src, dst, callReplaceFile, nil)
}
func replaceExistingWith(src, dst string, replace func(string, string, string) error, restore func(string, string) error) error {
return replaceExistingWithCleanup(src, dst, replace, restore, removeReplaceBackup)
}
func replaceExistingWithCleanup(src, dst string, replace func(string, string, string) error, restore func(string, string) error, removeBackup func(string) error) error {
info, err := os.Lstat(dst)
if err != nil {
if os.IsNotExist(err) {
// Nothing to replace and no descriptor to preserve.
return os.Rename(src, dst)
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("refusing to replace symlink destination: %s", dst)
}
backup, err := prepareReplaceBackup(dst)
if err != nil {
return fmt.Errorf("prepare replacement backup: %w", err)
}
callErr := replace(dst, src, backup)
if callErr == nil {
if err := removeBackup(backup); err != nil {
return &CommittedReplacementCleanupError{BackupPath: backup, Cause: err}
}
return nil
}
if errors.Is(callErr, errorInvalidFunction) || errors.Is(callErr, errorNotSupported) {
callErr = fmt.Errorf("DACL-preserving replacement is not supported for %s: %w", dst, callErr)
}
return recoverPartialReplace(callErr, dst, backup, restore)
}
func callReplaceFile(replacedPath, replacementPath, backupPath string) error {
replaced, err := syscall.UTF16PtrFromString(replacedPath)
if err != nil {
return err
}
replacement, err := syscall.UTF16PtrFromString(replacementPath)
if err != nil {
return err
}
backup, err := syscall.UTF16PtrFromString(backupPath)
if err != nil {
return err
}
result, _, callErr := replaceProcReplaceFil.Call(
uintptr(unsafe.Pointer(replaced)),
uintptr(unsafe.Pointer(replacement)),
uintptr(unsafe.Pointer(backup)),
uintptr(replaceFileFlags),
0,
0,
)
if result != 0 {
return nil
}
if callErr == nil || errors.Is(callErr, syscall.Errno(0)) {
return fmt.Errorf("replace %s: ReplaceFileW failed", replacedPath)
}
return callErr
}
func prepareReplaceBackup(dst string) (string, error) {
file, err := os.CreateTemp(filepath.Dir(dst), replaceBackupPattern)
if err != nil {
return "", err
}
path := file.Name()
if err := file.Close(); err != nil {
cleanupErr := removeReplaceBackup(path)
return "", fmt.Errorf("close backup placeholder %s: %w (cleanup error: %v)", path, err, cleanupErr)
}
if err := removeReplaceBackup(path); err != nil {
return "", fmt.Errorf("release backup path %s: %w", path, err)
}
return path, nil
}
func removeReplaceBackup(path string) error {
var err error
for i := 0; i < 10; i++ {
err = os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
if os.IsPermission(err) {
// ReplaceFileW can leave the original's read-only attribute on the
// backup. Chmod clears that bit on Windows without changing its DACL.
_ = os.Chmod(path, 0o600)
}
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
time.Sleep(10 * time.Millisecond)
continue
}
break
}
return err
}
func cleanupReplaceBackup(callErr error, backup string) error {
if err := removeReplaceBackup(backup); err != nil {
return fmt.Errorf("%w (backup %s could not be removed: %v)", callErr, backup, err)
}
return callErr
}
// recoverPartialReplace restores the original after ReplaceFileW reports a
// partial failure, then returns the original error. Supplying a managed backup
// changes the dangerous failure states documented by Microsoft:
//
// - ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176) leaves both files under their
// original names, so the failed write can discard src normally.
// - ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177) leaves src in place and moves the
// original destination to backup. Moving backup back to dst rolls the failed
// overwrite back without losing the original content or its DACL.
//
// The replacement is left alone either way — nothing here deletes what it was
// handed — but the errors below deliberately say nothing about where it ended up,
// which is also why its path is not a parameter. Whether it still exists when the
// error surfaces is the caller's business: the specialist writer, the only caller
// today, removes its temporary file in a deferred cleanup, so an error promising
// a replacement "remains at" that path would send an operator looking for a file
// that was already gone. What these errors do describe is what this function
// itself left on disk — the state of dst, and of the managed backup holding the
// original.
func recoverPartialReplace(callErr error, dst, backup string, restore func(string, string) error) error {
if !errors.Is(callErr, errorUnableToMoveReplacement2) {
// For 1175, 1176, unsupported volumes, and ordinary failures, Windows
// documents that dst remains at its original name. Remove any redundant
// backup only after confirming that the destination still exists.
if _, err := os.Lstat(dst); err == nil {
return cleanupReplaceBackup(callErr, backup)
} else if !os.IsNotExist(err) {
return fmt.Errorf("replace %s: %w (inspect destination during recovery: %v)", dst, callErr, err)
}
}
if _, err := os.Lstat(backup); err != nil {
return fmt.Errorf("replace %s: %w (original backup expected at %s is unavailable: %v)", dst, callErr, backup, err)
}
if _, err := os.Lstat(dst); err == nil {
return fmt.Errorf("replace %s: %w (destination unexpectedly exists; the original also remains at backup %s)", dst, callErr, backup)
} else if !os.IsNotExist(err) {
return fmt.Errorf("replace %s: %w (inspect destination before restoring backup %s: %v)", dst, callErr, backup, err)
}
if err := RenameWithRetry(backup, dst, restore); err != nil {
// Only callErr is wrapped. Exposing a sharing violation from the exhausted
// rollback would make the outer ReplaceWithRetry call retry after the
// filesystem was already mutated.
//
// This is the one terminal state that needs a human: nothing is at dst, and
// the original's only copy is under a name no tool looks for (the specialist
// loader reads *.md and skips everything else), so it is invisible until
// somebody moves it back. Say that outright rather than leaving an operator
// to infer it from a bare error code.
return fmt.Errorf(
"replace %s: %w (rolling the original back failed: %v; %s no longer exists and the original survives only as %s, which must be moved back by hand to restore it)",
dst, callErr, err, dst, backup,
)
}
return fmt.Errorf("replace %s: %w (the original was restored)", dst, callErr)
}
-559
View File
@@ -1,559 +0,0 @@
//go:build windows
package fsutil
import (
"errors"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"golang.org/x/sys/windows"
)
// TestReplaceWithRetryPreservesDestinationCreationTime proves the publish goes
// through ReplaceFileW and not os.Rename: ReplaceFileW carries selected metadata,
// including creation time, from the destination to the replacement. A rename
// would carry the temporary file's creation time over instead.
func TestReplaceWithRetryPreservesDestinationCreationTime(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
created := creationTime(t, dst)
src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
// Push the replacement's own creation time clearly past the destination's so a
// rename would be visible in the comparison below.
future := windows.NsecToFiletime(created.Nanoseconds() + int64(10*1e9))
setCreationTime(t, src, future)
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile dst: %v", err)
}
if string(data) != "new" {
t.Fatalf("destination content = %q, want the replacement bytes", data)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("the replacement file should be consumed by the replace: %v", err)
}
assertNoReplaceBackups(t, dir)
if got := creationTime(t, dst); got != created {
t.Fatalf("creation time = %v, want the replaced file's %v (a rename would not preserve it)", got, created)
}
}
// TestReplaceWithRetryPreservesDestinationDACL is the regression test for the
// second half of the finding: the replacement is a freshly created temporary file
// carrying the directory's inherited DACL, so publishing it with a rename would
// REPLACE the restrictive descriptor an explicitly locked-down file had.
func TestReplaceWithRetryPreservesDestinationDACL(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
// A protected DACL granting only the owner: distinct from whatever the temp
// file inherits from the directory.
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
if err != nil {
t.Skipf("cannot build a test security descriptor: %v", err)
}
dacl, _, err := restricted.DACL()
if err != nil {
t.Skipf("cannot read the test DACL: %v", err)
}
if err := windows.SetNamedSecurityInfo(
dst,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, nil, dacl, nil,
); err != nil {
t.Skipf("cannot apply a restrictive DACL on this filesystem: %v", err)
}
want := describeDACL(t, dst)
src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if inherited := describeDACL(t, src); inherited == want {
t.Skip("the temporary file already carries the same DACL; this filesystem cannot show the difference")
}
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if got := describeDACL(t, dst); got != want {
t.Fatalf("DACL after replace = %q, want the destination's own %q", got, want)
}
assertNoReplaceBackups(t, dir)
}
// TestReplaceWithRetryPublishesWhenDestinationIsMissing covers the no-destination
// case: ReplaceFileW requires an existing file to replace, so there is a rename
// fallback (and nothing to preserve).
func TestReplaceWithRetryPublishesWhenDestinationIsMissing(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if data, err := os.ReadFile(dst); err != nil || string(data) != "new" {
t.Fatalf("destination = %q err=%v, want the replacement bytes", data, err)
}
assertNoReplaceBackups(t, dir)
}
// TestReplaceWithRetryRetriesTransientLockViolation keeps the retry behavior that
// RenameWithRetry provides for antivirus/indexer holds.
func TestReplaceWithRetryRetriesTransientLockViolation(t *testing.T) {
attempts := 0
err := ReplaceWithRetry("src", "dst", func(src, dst string) error {
attempts++
if attempts < 3 {
return &os.PathError{Op: "replace", Path: dst, Err: syscall.Errno(32)} // ERROR_SHARING_VIOLATION
}
return nil
})
if err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want the transient violations retried", attempts)
}
}
// TestReplaceFileFlagsDoNotIgnoreMergeErrors is the regression test for jatmn's
// #757 P1 finding on flags: the call used to pass REPLACEFILE_IGNORE_MERGE_ERRORS
// (0x2) while the comment claimed ACL failures were fail-closed. Microsoft
// documents 0x2 and REPLACEFILE_IGNORE_ACL_ERRORS (0x4) identically — with either
// one set, a call lacking WRITE_DAC "succeeds but the ACLs are not preserved" —
// so passing 0x2 let a --force overwrite silently publish the temporary file's
// inherited directory DACL over a restricted specialist and expose its system
// prompt.
//
// This asserts the flag word directly rather than a live denied merge because a
// denied merge is not constructible for a file this process owns: Windows grants
// an object's owner READ_CONTROL and WRITE_DAC implicitly, so no DACL a test can
// apply to its own temp file can withhold WRITE_DAC from it. Pinning the flags is
// what actually prevents the regression — re-adding either bit fails here.
func TestReplaceFileFlagsDoNotIgnoreMergeErrors(t *testing.T) {
const (
ignoreMergeErrors = 0x00000002
ignoreACLErrors = 0x00000004
)
if replaceFileFlags&ignoreMergeErrors != 0 {
t.Error("REPLACEFILE_IGNORE_MERGE_ERRORS must not be set: it makes ReplaceFileW succeed WITHOUT preserving ACLs when it cannot obtain WRITE_DAC")
}
if replaceFileFlags&ignoreACLErrors != 0 {
t.Error("REPLACEFILE_IGNORE_ACL_ERRORS must not be set: it makes ReplaceFileW succeed WITHOUT preserving ACLs when it cannot obtain WRITE_DAC")
}
}
func TestReplaceExistingRejectsUnsupportedDACLReplacement(t *testing.T) {
for _, tc := range []struct {
name string
code syscall.Errno
}{
{name: "ERROR_INVALID_FUNCTION", code: errorInvalidFunction},
{name: "ERROR_NOT_SUPPORTED", code: errorNotSupported},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
err := replaceExistingWith(src, dst, func(replaced, replacement, backup string) error {
if replaced != dst || replacement != src {
t.Fatalf("ReplaceFileW paths = (%q, %q), want (%q, %q)", replaced, replacement, dst, src)
}
if filepath.Dir(backup) != dir {
t.Fatalf("backup directory = %q, want sibling directory %q", filepath.Dir(backup), dir)
}
return tc.code
}, nil)
if !errors.Is(err, tc.code) {
t.Fatalf("error = %v, want unsupported error %v", err, tc.code)
}
if !strings.Contains(err.Error(), "DACL-preserving replacement is not supported") {
t.Fatalf("error = %v, want a DACL-preserving-replacement explanation", err)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
assertNoReplaceBackups(t, dir)
})
}
}
func TestReplaceExistingCleansManagedBackup(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
err := replaceExistingWith(src, dst, func(replaced, replacement, backup string) error {
backupPath = backup
if matched, matchErr := filepath.Match(replaceBackupPattern, filepath.Base(backup)); matchErr != nil || !matched {
t.Fatalf("backup path = %q, want pattern %q (match error: %v)", backup, replaceBackupPattern, matchErr)
}
if _, statErr := os.Lstat(backup); !os.IsNotExist(statErr) {
t.Fatalf("backup path must be vacant before ReplaceFileW: %v", statErr)
}
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage backup: %v", renameErr)
}
// Windows refuses to remove a read-only file. The cleanup path must clear
// that attribute without changing the backup's DACL, then remove it.
if chmodErr := os.Chmod(backup, 0o400); chmodErr != nil {
t.Fatalf("make backup read-only: %v", chmodErr)
}
return os.Rename(replacement, replaced)
}, nil)
if err != nil {
t.Fatalf("replaceExistingWith: %v", err)
}
assertFileContent(t, dst, "new")
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("replacement source should be consumed: %v", err)
}
if _, err := os.Lstat(backupPath); !os.IsNotExist(err) {
t.Fatalf("managed backup should be removed: %v", err)
}
assertNoReplaceBackups(t, dir)
}
func TestReplaceWithRetryDoesNotRetryCommittedReplacementWhenBackupCleanupFails(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
attempts := 0
cleanupErr := &os.PathError{Op: "remove", Path: "managed backup", Err: syscall.Errno(32)}
var backupPath string
err := ReplaceWithRetry(src, dst, func(src, dst string) error {
attempts++
return replaceExistingWithCleanup(src, dst, func(replaced, replacement, backup string) error {
backupPath = backup
if err := os.Rename(replaced, backup); err != nil {
return err
}
return os.Rename(replacement, replaced)
}, nil, func(got string) error {
if got != backupPath {
t.Fatalf("cleanup backup path = %q, want %q", got, backupPath)
}
return cleanupErr
})
})
var outcome *CommittedReplacementCleanupError
if !errors.As(err, &outcome) {
t.Fatalf("error = %v, want committed cleanup outcome", err)
}
if outcome.BackupPath != backupPath || !errors.Is(outcome, syscall.Errno(32)) {
t.Fatalf("outcome = %#v, want backup %q and sharing violation", outcome, backupPath)
}
if attempts != 1 {
t.Fatalf("replacement attempts = %d, want 1", attempts)
}
assertFileContent(t, dst, "new")
assertFileContent(t, backupPath, "old")
}
func TestReplaceExistingKeeps1176FilesAtOriginalNames(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
err := replaceExistingWith(src, dst, func(_, _, _ string) error {
// With a backup name, Microsoft documents that 1176 leaves the replaced
// and replacement files under their original names.
return errorUnableToMoveReplacement
}, nil)
if !errors.Is(err, errorUnableToMoveReplacement) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
assertNoReplaceBackups(t, dir)
}
func TestReplaceExistingRollsBack1177AndRetriesTransientRestore(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
restoreAttempts := 0
err := replaceExistingWith(src, dst, func(replaced, _, backup string) error {
backupPath = backup
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage documented 1177 backup: %v", renameErr)
}
return errorUnableToMoveReplacement2
}, func(backup, destination string) error {
restoreAttempts++
if restoreAttempts < 3 {
return &os.PathError{Op: "rename", Path: backup, Err: syscall.Errno(32)}
}
return os.Rename(backup, destination)
})
if !errors.Is(err, errorUnableToMoveReplacement2) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement2)
}
if restoreAttempts != 3 {
t.Fatalf("restore attempts = %d, want transient lock failures retried", restoreAttempts)
}
assertErrorDoesNotAdvertiseReplacement(t, err, src)
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
if _, err := os.Lstat(backupPath); !os.IsNotExist(err) {
t.Fatalf("backup should be consumed by rollback: %v", err)
}
assertNoReplaceBackups(t, dir)
}
func TestReplaceExistingPreserves1177BackupWhenRollbackFails(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
replaceAttempts := 0
err := ReplaceWithRetry(src, dst, func(src, dst string) error {
replaceAttempts++
return replaceExistingWith(src, dst, func(replaced, _, backup string) error {
backupPath = backup
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage documented 1177 backup: %v", renameErr)
}
return errorUnableToMoveReplacement2
}, func(_, _ string) error {
return &os.PathError{Op: "rename", Path: backupPath, Err: syscall.Errno(32)}
})
})
if !errors.Is(err, errorUnableToMoveReplacement2) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement2)
}
if replaceAttempts != 1 {
t.Fatalf("replace attempts = %d, want no retry after a partial failure", replaceAttempts)
}
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
t.Fatalf("partial-failure error exposes the rollback lock error to the outer retry loop: %v", err)
}
if !strings.Contains(err.Error(), backupPath) {
t.Fatalf("error = %v, want retained backup path %q", err, backupPath)
}
// The terminal state an operator has to fix by hand: the original exists only
// under the backup name and nothing is at dst, so the error has to name both
// and say what to do, not just report the Windows code.
if !strings.Contains(err.Error(), dst) {
t.Fatalf("error = %v, want the destination %q an operator must restore to", err, dst)
}
if !strings.Contains(err.Error(), "moved back by hand") {
t.Fatalf("error = %v, want an explicit instruction to move the backup back", err)
}
assertErrorDoesNotAdvertiseReplacement(t, err, src)
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("destination should remain absent after failed rollback: %v", err)
}
assertFileContent(t, backupPath, "old")
assertFileContent(t, src, "new")
}
func TestReplaceExistingRejectsSymlinkDestination(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.md")
dst := filepath.Join(dir, "manifest.md")
src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(target, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.Symlink(target, dst); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
called := false
err := replaceExistingWith(src, dst, func(_, _, _ string) error {
called = true
return nil
}, nil)
if err == nil || !strings.Contains(err.Error(), "refusing to replace symlink") {
t.Fatalf("symlink replacement error = %v", err)
}
if called {
t.Fatal("ReplaceFileW callback was called for a symlink destination")
}
assertFileContent(t, target, "old")
assertFileContent(t, src, "new")
if info, err := os.Lstat(dst); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("destination symlink was changed: info=%v err=%v", info, err)
}
assertNoReplaceBackups(t, dir)
}
func TestRecoverPartialReplaceLeavesIntactStatesAlone(t *testing.T) {
for _, tc := range []struct {
name string
code syscall.Errno
}{
{name: "ERROR_UNABLE_TO_REMOVE_REPLACED", code: errorUnableToRemoveReplaced},
{name: "ERROR_ACCESS_DENIED", code: syscall.Errno(5)},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
backup := filepath.Join(dir, ".zero-replace-test.backup")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
if err := recoverPartialReplace(tc.code, dst, backup, nil); !errors.Is(err, tc.code) {
t.Fatalf("error = %v, want the original %v unchanged", err, tc.code)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
if _, err := os.Lstat(backup); !os.IsNotExist(err) {
t.Fatalf("unexpected backup residue: %v", err)
}
})
}
}
// assertErrorDoesNotAdvertiseReplacement guards the messaging fixed for jatmn's
// #757 P3 finding: recovery errors used to say the replacement "remains at" the
// caller's temporary path, but the only caller removes that file in a deferred
// cleanup before the error ever surfaces. Recovery is always about the original,
// so an operator must never be sent looking for the replacement.
func assertErrorDoesNotAdvertiseReplacement(t *testing.T, err error, replacement string) {
t.Helper()
if err == nil {
t.Fatal("expected a partial-replacement error")
}
if strings.Contains(err.Error(), replacement) {
t.Fatalf("error = %v, must not point an operator at the replacement %q, which its caller deletes", err, replacement)
}
}
func assertFileContent(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if string(data) != want {
t.Fatalf("content of %s = %q, want %q", path, data, want)
}
}
func assertNoReplaceBackups(t *testing.T, dir string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(dir, replaceBackupPattern))
if err != nil {
t.Fatalf("Glob replacement backups: %v", err)
}
if len(matches) != 0 {
t.Fatalf("replacement backups remain: %v", matches)
}
}
func creationTime(t *testing.T, path string) syscall.Filetime {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat %s: %v", path, err)
}
data, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok {
t.Skipf("no Windows file attributes for %s", path)
}
return data.CreationTime
}
func setCreationTime(t *testing.T, path string, created windows.Filetime) {
t.Helper()
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
t.Fatalf("UTF16PtrFromString: %v", err)
}
handle, err := windows.CreateFile(pathPtr, windows.FILE_WRITE_ATTRIBUTES, 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
t.Fatalf("CreateFile %s: %v", path, err)
}
defer func() {
_ = windows.CloseHandle(handle)
}()
if err := windows.SetFileTime(handle, &created, nil, nil); err != nil {
t.Fatalf("SetFileTime %s: %v", path, err)
}
}
func describeDACL(t *testing.T, path string) string {
t.Helper()
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Skipf("cannot read the security descriptor of %s: %v", path, err)
}
text := sd.String()
if index := strings.Index(text, "D:"); index >= 0 {
return text[index:]
}
return text
}
+11 -263
View File
@@ -13,14 +13,8 @@ import (
)
// NotificationHandler receives server->client notifications (e.g.
// textDocument/publishDiagnostics). params is the raw JSON payload. seq is the
// notification's receipt sequence (see Client.ReceiptSeq): the position of the
// frame it arrived in, stamped the moment that frame came off the wire — NOT
// when this handler happens to run, which can lag receipt when the queue is
// backed up. A caller that needs to know whether something newer than a given
// point has arrived must compare against seq, not against when its own handling
// code runs.
type NotificationHandler func(method string, params json.RawMessage, seq int64)
// textDocument/publishDiagnostics). params is the raw JSON payload.
type NotificationHandler func(method string, params json.RawMessage)
// Client speaks JSON-RPC 2.0 with LSP framing (Content-Length headers) over a
// reader/writer pair. It is transport-agnostic: server.go wires it to a process's
@@ -38,60 +32,8 @@ type Client struct {
closeOnce sync.Once
closed chan struct{}
readErr error
notifyMu sync.Mutex
notifyQueue []notification
notifyBytes int
notifyReady chan struct{}
notifyClosed bool
// notificationDrained is closed by notificationLoop, and only after
// closeNotifications has stopped admission and every accepted notification
// (including an in-flight handler) has finished.
notificationDrained chan struct{}
receiptSeq int64 // frames read off the wire so far (see ReceiptSeq)
// acceptedSeq is the receipt seq of the last notification queued for the
// worker, handledSeq the seq of the last one whose handler has returned, and
// handledWait a channel closed (and replaced) each time handledSeq advances.
// Together they let a waiter tell whether the worker has caught up with what
// the reader accepted. Both track notifications only; receiptSeq counts every
// frame, so the two are compared against each other, never against it.
acceptedSeq int64
handledSeq int64
handledWait chan struct{}
}
type notification struct {
method string
params json.RawMessage
seq int64
}
// The notification backlog is bounded by both message count and retained
// payload bytes. A well-behaved handler drains far faster than any single burst
// fills either limit; sustained overload — a language server emitting faster
// than the single handler can consume, or a handler stuck waiting on a
// re-entrant Call — is a fatal condition for this client, not something to
// paper over by growing the queue without bound. Hitting either limit fails the
// client (see enqueueNotification): IsClosed becomes true, and the manager
// evicts and restarts the session on next use, exactly as it does for any other
// dead client.
const (
notifyQueueLimit = 4096
notifyQueueByteLimit = 16 << 20 // 16 MiB
)
// maxFrameBytes is the largest single frame this transport will read. Without
// it the byte budget above would not be authoritative: readMessage allocates the
// whole body before anything can classify it, so one notification with a huge
// Content-Length would be materialized in full and only then rejected by
// enqueueNotification. Capping at the same value keeps that impossible — a
// notification can never allocate past the budget it is measured against — and
// bounds what a malformed or hostile header can make this client allocate. A
// legitimate LSP frame is orders of magnitude smaller; one that is not is a
// protocol error, and failing the read closes the client so the manager restarts
// the session.
const maxFrameBytes = notifyQueueByteLimit
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -139,14 +81,10 @@ type incomingMessage struct {
// server process exits); call Close to stop using the client.
func NewClient(r io.Reader, w io.Writer) *Client {
client := &Client{
writer: w,
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
notifyReady: make(chan struct{}, 1),
notificationDrained: make(chan struct{}),
handledWait: make(chan struct{}),
writer: w,
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
go client.notificationLoop()
go client.readLoop(bufio.NewReader(r))
return client
}
@@ -217,15 +155,6 @@ func (c *Client) readLoop(reader *bufio.Reader) {
c.failPending(err)
return
}
// Stamp the receipt boundary here — while the frame is nothing but bytes
// that have left the wire — rather than at enqueue. Everything below
// (json.Unmarshal of a peer-sized payload, then enqueue) runs on this
// goroutine while another can be capturing a baseline from ReceiptSeq, so
// stamping later would let a publish that was already read be numbered
// above a baseline taken after it arrived, and a superseded publish would
// then look fresh. Frames that turn out not to be notifications consume a
// number too: the sequence is a receipt clock, and gaps in it are fine.
seq := c.stampReceipt()
var msg incomingMessage
if err := json.Unmarshal(body, &msg); err != nil {
continue // skip a malformed frame rather than tearing down the session
@@ -237,7 +166,12 @@ func (c *Client) readLoop(reader *bufio.Reader) {
// required or the server can block waiting on it (e.g. registerCapability).
_ = c.write(outgoingReply{JSONRPC: "2.0", ID: msg.ID, Result: nil})
case msg.Method != "":
c.enqueueNotification(notification{method: msg.Method, params: msg.Params, seq: seq})
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler != nil {
handler(msg.Method, msg.Params)
}
case hasID:
var id int64
if err := json.Unmarshal(msg.ID, &id); err == nil {
@@ -247,171 +181,6 @@ func (c *Client) readLoop(reader *bufio.Reader) {
}
}
func (c *Client) notificationLoop() {
defer close(c.notificationDrained)
for {
<-c.notifyReady
for {
notification, ok, closed := c.dequeueNotification()
if !ok {
if closed {
return
}
break
}
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler != nil {
handler(notification.method, notification.params, notification.seq)
}
c.markHandled(notification.seq)
}
}
}
// markHandled records that everything received through seq has now been handled
// and wakes anyone waiting for the worker to catch up.
func (c *Client) markHandled(seq int64) {
c.notifyMu.Lock()
if seq > c.handledSeq {
c.handledSeq = seq
}
wait := c.handledWait
c.handledWait = make(chan struct{})
c.notifyMu.Unlock()
if wait != nil {
close(wait)
}
}
// handledThrough returns the receipt seq the worker has finished handling, plus
// a channel closed the next time that advances. The channel is nil for a Client
// built without NewClient (some unit tests), which has no worker to advance it.
func (c *Client) handledThrough() (int64, <-chan struct{}) {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
return c.handledSeq, c.handledWait
}
// acceptedNotificationSeq returns the receipt seq of the most recently queued
// notification (0 if none). It is the point the worker must reach to have
// handled everything the reader had accepted at the time of the call.
func (c *Client) acceptedNotificationSeq() int64 {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
return c.acceptedSeq
}
// notificationsDone returns the worker completion signal. A nil result means
// this Client was constructed without NewClient (as some unit-test clients are)
// and has no notification worker to wait for.
func (c *Client) notificationsDone() <-chan struct{} {
return c.notificationDrained
}
// enqueueNotification hands a server notification to the worker loop. item.seq
// must already carry the receipt stamp from stampReceipt: the receipt boundary
// belongs to the moment the frame left the wire, not to this call, which happens
// a full json.Unmarshal later. It never blocks and never silently discards a
// message the handler could still act on: the queue grows instead, up to its
// message and byte limits.
//
// The alternatives to growing are worse. Blocking the read loop when a buffer
// fills is the deadlock this dispatch exists to avoid — a handler that calls
// Client.Call waits for a response frame the blocked reader can no longer
// deliver. Dropping the oldest queued item instead loses protocol state
// permanently: a textDocument/publishDiagnostics for one URI is the server's
// only report for that URI, so discarding it makes session.waitForDiagnostics
// time out and Manager.Check return nothing even though the server published
// findings.
//
// Growth is bounded in practice by how much the server emits while a handler
// runs, and the queue is released as soon as it drains. But "in practice" is
// not a limit: a handler that never returns, or a server that sustains a
// higher rate than the single handler can drain, would otherwise grow this
// queue's full json.RawMessage payloads without bound until the heap gives
// out. The count limit alone is insufficient because notification payloads are
// peer-controlled and can be large; the byte limit prevents a few large
// diagnostics publishes from exhausting the heap before the count limit is
// reached. Either limit turns overload into an explicit, observable failure —
// the client is failed and closed — rather than unbounded protocol retention.
func (c *Client) enqueueNotification(item notification) {
c.notifyMu.Lock()
if c.notifyClosed {
// The worker loop has already stopped, so anything queued now would never
// be handled. Retaining it would grow the queue for as long as the
// transport stays readable after Close — Server.Shutdown closes the client
// before closing stdin, so a server emitting notifications while it handles
// shutdown/exit keeps the read loop feeding a queue nobody drains.
c.notifyMu.Unlock()
return
}
itemBytes := len(item.method) + len(item.params)
if len(c.notifyQueue) >= notifyQueueLimit || itemBytes > notifyQueueByteLimit-c.notifyBytes {
c.notifyMu.Unlock()
c.failPending(fmt.Errorf(
"lsp client: notification backlog exceeded %d messages or %d bytes",
notifyQueueLimit,
notifyQueueByteLimit,
))
return
}
c.notifyQueue = append(c.notifyQueue, item)
c.notifyBytes += itemBytes
c.acceptedSeq = item.seq
c.notifyMu.Unlock()
select {
case c.notifyReady <- struct{}{}:
default:
// A wake-up is already pending; the worker drains the whole queue per wake,
// so this item is covered by it.
}
}
// stampReceipt claims the next receipt number for a frame that has just been
// read off the wire. The read loop calls it for every frame before parsing one,
// so a notification's seq reflects when it arrived rather than when this client
// got around to decoding and queueing it.
func (c *Client) stampReceipt() int64 {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
c.receiptSeq++
return c.receiptSeq
}
// ReceiptSeq returns how many frames have been read off the wire so far,
// including any notification still waiting to be dispatched to the handler. A
// caller that wants to know whether a notification newer than "now" has arrived
// should snapshot this before triggering whatever produces it, then require a
// subsequently-observed seq to be strictly greater: a notification whose frame
// had already been read at snapshot time has seq <= the snapshot, even if it is
// still queued and its handler doesn't run until afterward.
func (c *Client) ReceiptSeq() int64 {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
return c.receiptSeq
}
func (c *Client) dequeueNotification() (notification, bool, bool) {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
if len(c.notifyQueue) == 0 {
return notification{}, false, c.notifyClosed
}
item := c.notifyQueue[0]
c.notifyBytes -= len(item.method) + len(item.params)
c.notifyQueue[0] = notification{}
c.notifyQueue = c.notifyQueue[1:]
if len(c.notifyQueue) == 0 {
// Release the backing array once drained; re-slicing alone would keep a
// burst's worth of capacity (and its already-consumed items) alive.
c.notifyQueue = nil
}
return item, true, false
}
func (c *Client) deliver(id int64, resp rpcResponse) {
c.mu.Lock()
ch, ok := c.pending[id]
@@ -435,27 +204,9 @@ func (c *Client) failPending(err error) {
ch <- rpcResponse{Err: &rpcError{Code: -1, Message: err.Error()}}
}
close(c.closed)
c.closeNotifications()
})
}
// closeNotifications atomically stops accepting notifications, then wakes the
// worker so everything accepted before that boundary is delivered in FIFO order.
// It deliberately does not wait: a user handler may be blocked, and transport
// failure, overload, and concurrent Close must never deadlock teardown. The
// worker exits itself after the accepted queue (including any in-flight handler)
// has drained. An enqueue racing with shutdown is therefore either accepted and
// delivered before worker exit, or observes notifyClosed and is rejected.
func (c *Client) closeNotifications() {
c.notifyMu.Lock()
c.notifyClosed = true
c.notifyMu.Unlock()
select {
case c.notifyReady <- struct{}{}:
default:
}
}
func (c *Client) readError() error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -532,9 +283,6 @@ func readMessage(reader *bufio.Reader) ([]byte, error) {
if contentLength < 0 {
return nil, errors.New("message missing Content-Length header")
}
if contentLength > maxFrameBytes {
return nil, fmt.Errorf("lsp frame of %d bytes exceeds the %d byte limit", contentLength, maxFrameBytes)
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(reader, body); err != nil {
return nil, err
+1 -511
View File
@@ -7,9 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"runtime"
"strings"
"sync"
"testing"
"time"
)
@@ -210,7 +208,7 @@ func TestClientNotificationHandler(t *testing.T) {
_ = serverReader
received := make(chan string, 1)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
client.SetNotificationHandler(func(method string, _ json.RawMessage) {
received <- method
})
_ = writeMessage(serverWriter, map[string]any{
@@ -229,438 +227,6 @@ func TestClientNotificationHandler(t *testing.T) {
}
}
func TestClientNotificationHandlerCanCallClient(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
client := NewClient(clientReader, clientWriter)
defer client.Close()
defer serverWriter.Close()
defer clientWriter.Close()
serverDone := make(chan error, 1)
go func() {
body, err := readMessage(bufio.NewReader(serverReader))
if err != nil {
serverDone <- err
return
}
var request incomingMessage
if err := json.Unmarshal(body, &request); err != nil {
serverDone <- err
return
}
serverDone <- writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]bool{"applied": true},
})
}()
handlerDone := make(chan error, 1)
client.SetNotificationHandler(func(_ string, _ json.RawMessage, _ int64) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := client.Call(ctx, "workspace/applyEdit", nil)
handlerDone <- err
})
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": "workspace/requestEdit",
}); err != nil {
t.Fatal(err)
}
select {
case err := <-handlerDone:
if err != nil {
t.Fatalf("notification handler call failed: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("notification handler deadlocked waiting for its response")
}
if err := <-serverDone; err != nil {
t.Fatalf("server failed: %v", err)
}
}
func TestClientNotificationHandlersPreserveOrder(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
defer client.Close()
defer serverWriter.Close()
received := make(chan string, 2)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
received <- method
})
for _, method := range []string{"first", "second"} {
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": method,
}); err != nil {
t.Fatal(err)
}
}
for _, want := range []string{"first", "second"} {
select {
case got := <-received:
if got != want {
t.Fatalf("notification = %q, want %q", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for notification %q", want)
}
}
}
func TestClientNotificationBurstDoesNotBlockResponse(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
client := NewClient(clientReader, clientWriter)
defer client.Close()
defer serverWriter.Close()
defer clientWriter.Close()
handlerStarted := make(chan struct{})
handlerDone := make(chan error, 1)
var mu sync.Mutex
var queued []string
allQueued := make(chan struct{})
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
if method != "blocking" {
mu.Lock()
queued = append(queued, method)
complete := len(queued) == notificationBurstSize
mu.Unlock()
if complete {
close(allQueued)
}
return
}
close(handlerStarted)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := client.Call(ctx, "workspace/applyEdit", nil)
handlerDone <- err
})
serverDone := make(chan error, 1)
go func() {
reader := bufio.NewReader(serverReader)
body, err := readMessage(reader)
if err != nil {
serverDone <- err
return
}
var request incomingMessage
if err := json.Unmarshal(body, &request); err != nil {
serverDone <- err
return
}
for i := 0; i < notificationBurstSize; i++ {
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": fmt.Sprintf("queued-%03d", i),
}); err != nil {
serverDone <- err
return
}
}
serverDone <- writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": nil,
})
}()
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": "blocking",
}); err != nil {
t.Fatal(err)
}
select {
case <-handlerStarted:
case <-time.After(time.Second):
t.Fatal("blocking notification handler did not start")
}
select {
case err := <-handlerDone:
if err != nil {
t.Fatalf("notification handler call failed under burst: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("notification burst blocked the response read loop")
}
if err := <-serverDone; err != nil {
t.Fatalf("server failed: %v", err)
}
// Not blocking the reader is only half the requirement: every notification the
// server sent during the burst must still reach the handler, in order. A
// bounded queue that drops the oldest would silently lose the head of this
// sequence — and with it a publishDiagnostics the checker is waiting for.
select {
case <-allQueued:
case <-time.After(5 * time.Second):
mu.Lock()
received := len(queued)
mu.Unlock()
t.Fatalf("received %d of %d burst notifications; the queue lost messages", received, notificationBurstSize)
}
mu.Lock()
defer mu.Unlock()
for i, method := range queued {
if want := fmt.Sprintf("queued-%03d", i); method != want {
t.Fatalf("burst notification %d = %q, want %q (order must survive the burst)", i, method, want)
}
}
}
// notificationBurstSize is far past any buffer the dispatch used to have, so a
// bounded queue would be visible as either a blocked read loop or a lost message.
const notificationBurstSize = 512
// TestClientNotificationQueueIsLossless is the regression test for the overflow
// policy: a queued notification must never be discarded. A dropped
// textDocument/publishDiagnostics is the server's only report for that URI, so
// losing it makes session.waitForDiagnostics time out and Manager.Check return
// nothing even though the server published findings.
func TestClientNotificationQueueIsLossless(t *testing.T) {
client := &Client{notifyReady: make(chan struct{}, 1)}
for i := 0; i < notificationBurstSize; i++ {
client.enqueueNotification(notification{method: fmt.Sprintf("notification-%03d", i)})
}
for i := 0; i < notificationBurstSize; i++ {
item, ok, _ := client.dequeueNotification()
if !ok {
t.Fatalf("notification %d was discarded by the queue", i)
}
want := fmt.Sprintf("notification-%03d", i)
if item.method != want {
t.Fatalf("notification = %q, want %q (queue must stay FIFO)", item.method, want)
}
}
if _, ok, _ := client.dequeueNotification(); ok {
t.Fatal("queue returned more notifications than were enqueued")
}
if client.notifyQueue != nil {
t.Fatalf("drained queue retained %d slots of capacity", cap(client.notifyQueue))
}
if client.notifyBytes != 0 {
t.Fatalf("drained queue retained byte accounting: %d", client.notifyBytes)
}
}
func TestClientDrainsAcceptedNotificationsAfterTransportEOF(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
blockStarted := make(chan struct{})
releaseBlock := make(chan struct{})
handled := make(chan string, 2)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
if method == "block" {
close(blockStarted)
<-releaseBlock
}
handled <- method
})
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "block"}); err != nil {
t.Fatal(err)
}
<-blockStarted
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
}); err != nil {
t.Fatal(err)
}
// Wait until the reader has accepted the publish, then fail it while the
// sole worker is still in the preceding handler and cannot dequeue it.
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() != 2 {
if time.Now().After(deadline) {
t.Fatal("publish was not accepted by the read loop")
}
runtime.Gosched()
}
if err := serverWriter.Close(); err != nil {
t.Fatal(err)
}
// Fresh budget: the wait above may have consumed most of the previous one,
// which would make this loop fail on nothing worse than slow scheduling.
deadline = time.Now().Add(time.Second)
for !client.IsClosed() {
if time.Now().After(deadline) {
t.Fatal("transport EOF did not close client")
}
runtime.Gosched()
}
close(releaseBlock)
for _, want := range []string{"block", "textDocument/publishDiagnostics"} {
select {
case got := <-handled:
if got != want {
t.Fatalf("handled %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("accepted notification %q was not delivered after EOF", want)
}
}
select {
case <-client.notificationDrained:
case <-time.After(time.Second):
t.Fatal("notification worker did not report the accepted queue drained")
}
}
// TestReceiptStampedAtReadNotEnqueue is the regression test for the review gap
// on #759 (pullrequestreview-4834747507): TestPublishBaselineRejectsFrameReadBeforeBaseline
// calls stampReceipt itself and never drives readLoop, so it pins the seq
// comparison but not WHERE the stamp is taken — a refactor that moved stamping
// back to the enqueue call site (reverting the read-time fix) left that test
// green.
//
// This test distinguishes the two call sites directly: a response frame is
// read off the wire but never reaches enqueueNotification (see the `hasID`
// branch in readLoop). Under read-time stamping every frame consumes a receipt
// number — "gaps in it are fine", per stampReceipt's doc comment — so
// ReceiptSeq must advance even though nothing was queued. Under enqueue-time
// stamping it would not, since stampReceipt would never run for this frame.
func TestReceiptStampedAtReadNotEnqueue(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
defer serverWriter.Close()
// A plain response frame: has an id, no method, so it is delivered via
// c.deliver and never passed to enqueueNotification.
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "id": 1, "result": nil,
}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() == 0 {
if time.Now().After(deadline) {
t.Fatal("ReceiptSeq did not advance for a frame that was read but never enqueued as a notification")
}
runtime.Gosched()
}
}
// TestReadMessageRejectsOversizedFrame covers jatmn's P3 finding on #759: the
// notification byte budget was enforced only at enqueue, but readMessage
// allocates the whole body before anything can tell a notification from a
// response. A single frame declaring a huge Content-Length was therefore
// materialized in full and only then measured against a budget it had already
// blown past. The header is now rejected before the allocation.
func TestReadMessageRejectsOversizedFrame(t *testing.T) {
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", maxFrameBytes+1)
_, err := readMessage(bufio.NewReader(strings.NewReader(header)))
if err == nil {
t.Fatal("an oversized frame must be rejected, not allocated and read")
}
if !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("error = %v, want the frame-size limit to reject it before reading the body", err)
}
// A frame at the limit is still legal: the cap rejects what is over it, not
// what merely approaches it.
body := strings.Repeat("x", 16)
atLimit := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body)
if _, err := readMessage(bufio.NewReader(strings.NewReader(atLimit))); err != nil {
t.Fatalf("readMessage of an ordinary frame: %v", err)
}
}
// TestClientFailsOnNotificationBacklogOverload is the regression test for
// jatmn's #759 P2 finding: the lossless queue above had no failure policy — a
// language server sustaining a higher notification rate than the single
// handler can drain (no permanently-stuck handler required, just a sustained
// producer faster than the consumer) would retain every full json.RawMessage
// payload on Zero's heap without bound. Hitting notifyQueueLimit must fail
// (and close) the client observably instead of continuing to grow.
func TestClientFailsOnNotificationBacklogOverload(t *testing.T) {
client := &Client{
notifyReady: make(chan struct{}, 1),
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
for i := 0; i < notifyQueueLimit; i++ {
client.enqueueNotification(notification{method: "spam"})
}
if client.IsClosed() {
t.Fatal("client closed before the backlog limit was reached")
}
client.notifyMu.Lock()
queued := len(client.notifyQueue)
client.notifyMu.Unlock()
if queued != notifyQueueLimit {
t.Fatalf("queued = %d, want %d before the limit is exceeded", queued, notifyQueueLimit)
}
// One push past the limit must fail the client rather than growing the
// queue further.
client.enqueueNotification(notification{method: "spam"})
if !client.IsClosed() {
t.Fatal("client must be closed once the notification backlog exceeds notifyQueueLimit")
}
client.notifyMu.Lock()
queuedAfter := len(client.notifyQueue)
bytesAfter := client.notifyBytes
client.notifyMu.Unlock()
if queuedAfter != notifyQueueLimit {
t.Fatalf("closed client retained %d accepted notifications, want %d pending worker drain", queuedAfter, notifyQueueLimit)
}
if bytesAfter == 0 {
t.Fatal("accepted notifications were discarded instead of left for worker drain")
}
}
func TestClientFailsOnNotificationBacklogByteOverload(t *testing.T) {
client := &Client{
notifyReady: make(chan struct{}, 1),
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
const messageCount = 16
const method = "spam"
for i := 0; i < messageCount; i++ {
client.enqueueNotification(notification{
method: method,
params: make(json.RawMessage, notifyQueueByteLimit/messageCount-len(method)),
})
}
if client.IsClosed() {
t.Fatal("client closed before the notification byte limit was exceeded")
}
client.notifyMu.Lock()
queued := len(client.notifyQueue)
queuedBytes := client.notifyBytes
client.notifyMu.Unlock()
if queued != messageCount {
t.Fatalf("queued = %d, want %d at the byte limit", queued, messageCount)
}
if queuedBytes != notifyQueueByteLimit {
t.Fatalf("queued bytes = %d, want limit %d", queuedBytes, notifyQueueByteLimit)
}
client.enqueueNotification(notification{method: "x"})
if !client.IsClosed() {
t.Fatal("client must close before retaining notification bytes past the limit")
}
client.notifyMu.Lock()
queuedAfter := len(client.notifyQueue)
bytesAfter := client.notifyBytes
client.notifyMu.Unlock()
if queuedAfter != messageCount || bytesAfter != notifyQueueByteLimit {
t.Fatalf("accepted backlog changed during close: %d notifications and %d accounted bytes", queuedAfter, bytesAfter)
}
}
func TestClientRejectsCallsAfterClose(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
@@ -677,79 +243,3 @@ func TestClientRejectsCallsAfterClose(t *testing.T) {
t.Fatal("Notify after Close must return an error")
}
}
// TestClientDropsNotificationsAfterClose covers the shutdown path: Close stops
// the worker loop, but the read loop keeps reading until the transport ends —
// Server.Shutdown closes the client BEFORE closing stdin, so a server emitting
// notifications while it handles shutdown/exit would otherwise pile them into a
// queue nobody drains. Nothing may be handled or retained after Close.
func TestClientDropsNotificationsAfterClose(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
client := NewClient(clientReader, clientWriter)
defer serverWriter.Close()
defer clientWriter.Close()
go func() {
// Drain anything the client writes so no write can block the test.
_, _ = io.Copy(io.Discard, serverReader)
}()
handled := make(chan string, 4)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
handled <- method
})
// A notification before Close still reaches the handler.
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "before-close"}); err != nil {
t.Fatal(err)
}
select {
case method := <-handled:
if method != "before-close" {
t.Fatalf("handled %q, want before-close", method)
}
case <-time.After(2 * time.Second):
t.Fatal("notification before Close was not handled")
}
if err := client.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// The transport stays writable, exactly as it does between Client.Close and
// stdin.Close in Server.Shutdown.
for i := 0; i < 64; i++ {
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": fmt.Sprintf("after-close-%03d", i),
}); err != nil {
t.Fatalf("write notification after Close: %v", err)
}
}
select {
case method := <-handled:
t.Fatalf("handler ran for %q after Close", method)
case <-time.After(200 * time.Millisecond):
}
// Give the read loop time to consume every frame it was handed, then require
// the queue to hold nothing: dropping on enqueue is what keeps a closed client
// from growing for as long as its reader stays open.
deadline := time.Now().Add(2 * time.Second)
for {
client.notifyMu.Lock()
queued := len(client.notifyQueue)
closed := client.notifyClosed
client.notifyMu.Unlock()
if !closed {
t.Fatal("Close did not mark the notification queue closed")
}
if queued != 0 {
t.Fatalf("closed client retained %d queued notifications", queued)
}
if time.Now().After(deadline) {
return
}
time.Sleep(10 * time.Millisecond)
}
}
+34 -135
View File
@@ -20,39 +20,34 @@ type lspServer interface {
type session struct {
server lspServer
client *Client
// catchUpGrace bounds catchUpNotifications on a live client. Zero means "do
// not wait", which is what the hand-built sessions in tests get by default.
catchUpGrace time.Duration
mu sync.Mutex
open map[string]bool // uri -> didOpen sent
versions map[string]int // uri -> current (committed) version
diagnostics map[string][]Diagnostic // uri -> latest published diagnostics
lastPublish map[string]time.Time // uri -> time of last publish
publishSeq map[string]int64 // uri -> receipt seq of latest publish (see Client.ReceiptSeq)
waiters map[string][]chan struct{} // uri -> goroutines awaiting the next publish
fileLocks map[string]*sync.Mutex // uri -> per-document sync serializer
mu sync.Mutex
open map[string]bool // uri -> didOpen sent
versions map[string]int // uri -> current (committed) version
diagnostics map[string][]Diagnostic // uri -> latest published diagnostics
lastPublish map[string]time.Time // uri -> time of last publish
publishCount map[string]int // uri -> monotonic publish count
waiters map[string][]chan struct{} // uri -> goroutines awaiting the next publish
fileLocks map[string]*sync.Mutex // uri -> per-document sync serializer
}
func newSession(server lspServer) *session {
s := &session{
server: server,
client: server.Client(),
catchUpGrace: notificationCatchUpGrace,
open: map[string]bool{},
versions: map[string]int{},
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
fileLocks: map[string]*sync.Mutex{},
open: map[string]bool{},
versions: map[string]int{},
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishCount: map[string]int{},
waiters: map[string][]chan struct{}{},
fileLocks: map[string]*sync.Mutex{},
}
s.client.SetNotificationHandler(s.handleNotification)
return s
}
func (s *session) handleNotification(method string, params json.RawMessage, seq int64) {
func (s *session) handleNotification(method string, params json.RawMessage) {
if method != "textDocument/publishDiagnostics" {
return
}
@@ -71,7 +66,7 @@ func (s *session) handleNotification(method string, params json.RawMessage, seq
}
s.diagnostics[payload.URI] = payload.Diagnostics
s.lastPublish[payload.URI] = time.Now()
s.publishSeq[payload.URI] = seq
s.publishCount[payload.URI]++
waiters := s.waiters[payload.URI]
delete(s.waiters, payload.URI)
s.mu.Unlock()
@@ -138,65 +133,36 @@ func (s *session) diagnosticsFor(uri string) []Diagnostic {
return append([]Diagnostic(nil), s.diagnostics[uri]...)
}
// publishBaseline snapshots the client's current notification receipt
// sequence, captured before a sync so waitForDiagnostics can require a publish
// that was RECEIVED after this point. This must baseline against receipt, not
// against session.publishSeq (which only advances once handleNotification
// actually runs): dispatch happens off a queue, so a publish for a since-
// superseded version can already be sitting in that queue, still unprocessed,
// at the moment a later Check captures its baseline. If baseline were instead
// "how many publishes has this session recorded so far", that stale publish
// would land afterward, still satisfy "more than baseline", and hand the new
// check diagnostics for the wrong text — silently, since many servers omit the
// version field the staleness check in handleNotification relies on.
// Baselining against receipt sequence closes that: a notification whose frame
// had already come off the wire before this call has seq <= baseline no matter
// when it is decoded, queued, or handled.
func (s *session) publishBaseline() int64 {
return s.client.ReceiptSeq()
// publishBaseline snapshots the current publish count for a URI, captured before
// a sync so waitForDiagnostics can wait specifically for the publish that sync
// triggers (not be satisfied by a stale earlier publish).
func (s *session) publishBaseline(uri string) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.publishCount[uri]
}
// notificationCatchUpGrace is the live-client catch-up budget a real session
// runs with. It exists for the case where the caller's own deadline has already
// passed: dropping a publish that is sitting accepted-but-unhandled in the queue
// would report "no diagnostics" for text the server had in fact already answered
// for, so a brief overrun is preferable to discarding it. It stays short because
// the caller is by then out of budget, and it costs nothing in the ordinary case
// — the session's own handler only parses and records, so the worker is
// virtually always caught up already and no wait happens at all. A closed client
// is deliberately not bounded by this; see catchUpNotifications.
const notificationCatchUpGrace = 50 * time.Millisecond
// waitForDiagnostics blocks until a publish newer than baseline arrives for the
// URI and the server then goes quiet for the debounce window, or until ctx is
// done or the client closes. It returns true when a fresh publish was observed
// and false when the wait ended before any did — so a caller can avoid returning
// a stale prior result for newer text. Servers don't signal "analysis complete",
// so the debounce approximates it: once a fresh publish lands, wait debounce for
// a follow-up, resetting on each new publish.
func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce time.Duration, baseline int64) bool {
// done. It returns true when a fresh publish was observed and false when ctx
// expired before any did — so a caller can avoid returning a stale prior result
// for newer text. Servers don't signal "analysis complete", so the debounce
// approximates it: once a fresh publish lands, wait debounce for a follow-up,
// resetting on each new publish.
func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce time.Duration, baseline int) bool {
for {
s.mu.Lock()
ch := make(chan struct{})
s.waiters[uri] = append(s.waiters[uri], ch)
seq := s.publishSeq[uri]
count := s.publishCount[uri]
last := s.lastPublish[uri]
s.mu.Unlock()
if seq <= baseline {
if count <= baseline {
select {
case <-ctx.Done():
s.cancelWaiter(uri, ch)
// Cancellation, like closure below, ends the wait on a signal that
// says nothing about what the server sent, and either can win a race
// against a publish this session has already received. Deciding
// "nothing arrived" straight out of the select would discard it, so
// both settle identically: let the worker finish what it accepted,
// then re-read what got recorded.
return s.freshAfterCatchUp(uri, baseline)
case <-s.client.closed:
s.cancelWaiter(uri, ch)
return s.freshAfterCatchUp(uri, baseline)
return false // no fresh publish for this sync arrived in time
case <-ch:
continue // a fresh publish arrived; loop into the debounce check
}
@@ -205,7 +171,6 @@ func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce t
remaining := debounce - time.Since(last)
if remaining <= 0 {
s.cancelWaiter(uri, ch)
s.catchUpNotifications()
return true
}
timer := time.NewTimer(remaining)
@@ -213,83 +178,17 @@ func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce t
case <-ctx.Done():
timer.Stop()
s.cancelWaiter(uri, ch)
// A fresh publish did arrive; ctx merely cut the debounce short. Catch
// the worker up regardless: the caller reads diagnosticsFor the moment
// this returns, and a newer publish for this URI may be sitting accepted
// but unhandled — it should get the newest received, not the older one
// that happened to wake this wait.
s.catchUpNotifications()
return true
case <-s.client.closed:
timer.Stop()
s.cancelWaiter(uri, ch)
// Same, for whatever the server managed to publish before it died.
s.catchUpNotifications()
return true // preserve the fresh publish already received before failure
return true // a fresh publish did arrive; ctx merely cut the debounce short
case <-ch:
timer.Stop()
continue // a newer publish arrived; re-arm the debounce
case <-timer.C:
s.cancelWaiter(uri, ch)
s.catchUpNotifications()
return true // quiet for the full window
}
}
}
// freshAfterCatchUp reports whether a publish received after baseline has been
// recorded for the URI, having first let the notification worker finish frames
// it already accepted. Receipt is what the answer turns on: a publish that was
// read off the wire but is still queued would otherwise read as "never arrived".
func (s *session) freshAfterCatchUp(uri string, baseline int64) bool {
s.catchUpNotifications()
s.mu.Lock()
defer s.mu.Unlock()
return s.publishSeq[uri] > baseline
}
// catchUpNotifications waits for the client's notification worker to handle
// every notification it had already accepted, so a decision made right after
// cannot miss a publish that was on the wire before it. It returns immediately
// in the ordinary case, where the worker is already caught up.
func (s *session) catchUpNotifications() {
drained := s.client.notificationsDone()
if drained == nil {
return // hand-built client (some unit tests): no worker to catch up on
}
if s.client.IsClosed() {
// Wait the worker out instead of time-boxing it. Admission is already
// closed so the backlog cannot grow, and a handler's re-entrant Call now
// fails immediately rather than blocking on a response that will never
// come — the drain is bounded, and it is the last chance these frames get.
<-drained
return
}
timer := time.NewTimer(s.catchUpGrace)
defer timer.Stop()
for {
// Re-read the target every pass rather than snapshotting it once. The
// reader keeps running while this waits, so a snapshot goes stale: the
// worker finishing the item that was last when we started satisfies
// `handled >= target` while a newer publish for the same URI sits queued,
// and the caller then answers from diagnosticsFor with the older one —
// the exact failure the closed-client drain exists to prevent, on the
// live path. Growth is not unbounded: the grace below caps the wait no
// matter how fast the server publishes.
handled, advanced := s.client.handledThrough()
if handled >= s.client.acceptedNotificationSeq() {
return
}
select {
case <-advanced:
case <-drained:
return
case <-timer.C:
return
}
}
}
// cancelWaiter removes a still-open waiter (one a publish has not already closed
// and cleared) so it can't leak or be closed twice.
func (s *session) cancelWaiter(uri string, target chan struct{}) {
+1 -1
View File
@@ -73,7 +73,7 @@ func (m *Manager) Check(ctx context.Context, path, text string) ([]Diagnostic, e
}
return nil, err
}
baseline := sess.publishBaseline()
baseline := sess.publishBaseline(uri)
if err := sess.sync(ctx, abs, languageID, text); err != nil {
return nil, err
}
+2 -733
View File
@@ -7,7 +7,6 @@ import (
"errors"
"io"
"os/exec"
"runtime"
"strings"
"sync"
"testing"
@@ -229,748 +228,18 @@ func TestSessionDropsStaleVersionPublish(t *testing.T) {
sess.mu.Unlock()
stale, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Version: 2, Diagnostics: []Diagnostic{{Message: "stale"}}})
sess.handleNotification("textDocument/publishDiagnostics", stale, 1)
sess.handleNotification("textDocument/publishDiagnostics", stale)
if len(sess.diagnosticsFor(uri)) != 0 {
t.Fatal("a publish for an older version must be ignored")
}
fresh, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Version: 3, Diagnostics: []Diagnostic{{Message: "fresh"}}})
sess.handleNotification("textDocument/publishDiagnostics", fresh, 2)
sess.handleNotification("textDocument/publishDiagnostics", fresh)
if d := sess.diagnosticsFor(uri); len(d) != 1 || d[0].Message != "fresh" {
t.Fatalf("a current-version publish must apply, got %#v", d)
}
}
// TestPublishBaselineRejectsAlreadyQueuedPublish is the regression test for
// jatmn's #759 P1 finding: publishBaseline used to snapshot how many publishes
// session.handleNotification had already RUN for a URI. Dispatch happens off a
// queue, though, so a publish for a version Check is about to supersede can
// already be sitting RECEIVED but undispatched at the moment a later Check
// captures its baseline — then run only afterward, incrementing the old
// count-based baseline and wrongly looking "new". Since many servers omit the
// version field, handleNotification's own staleness check (which only fires
// for a positive version) can't catch this either, so the stale result would
// reach the caller for the new text, and the debounce could finish before the
// real response even arrives.
//
// This drives the exact interleaving through the real production path: the
// stale publish is enqueued first (fixing its receipt seq), THEN a baseline is
// captured, THEN the stale item is dispatched — reproducing "received before
// baseline, handled after" without depending on goroutine scheduling luck. A
// bare Client (no background loops, like TestClientNotificationQueueIsLossless
// uses) makes the enqueue/dequeue ordering fully explicit.
func TestPublishBaselineRejectsAlreadyQueuedPublish(t *testing.T) {
client := &Client{notifyReady: make(chan struct{}, 1)}
sess := &session{
client: client,
versions: map[string]int{},
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
uri := PathToURI("/repo/main.go")
// The stale (version-less — the common case) publish is RECEIVED first: the
// read loop stamps a frame's receipt before it decodes or queues it, so the
// stamp here comes first too.
stale, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "stale"}}})
client.enqueueNotification(notification{
method: "textDocument/publishDiagnostics", params: stale, seq: client.stampReceipt(),
})
// A later Check captures its baseline only now — after the stale publish's
// receipt, exactly as publishBaseline does between two Checks whose
// notification queue has backed up.
baseline := sess.publishBaseline()
// Dispatch the stale item exactly as notificationLoop would: dequeue and
// call the handler with the receipt seq it was actually stamped with.
item, ok, _ := client.dequeueNotification()
if !ok {
t.Fatal("stale notification was not queued")
}
sess.handleNotification(item.method, item.params, item.seq)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if sess.waitForDiagnostics(ctx, uri, 10*time.Millisecond, baseline) {
t.Fatal("a publish received before baseline must not satisfy waitForDiagnostics merely because it was handled afterward")
}
// The real response — received (and handled) after baseline — must satisfy it.
fresh, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "fresh"}}})
client.enqueueNotification(notification{
method: "textDocument/publishDiagnostics", params: fresh, seq: client.stampReceipt(),
})
item2, ok, _ := client.dequeueNotification()
if !ok {
t.Fatal("fresh notification was not queued")
}
sess.handleNotification(item2.method, item2.params, item2.seq)
ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second)
defer cancel2()
if !sess.waitForDiagnostics(ctx2, uri, 10*time.Millisecond, baseline) {
t.Fatal("a publish received after baseline must satisfy waitForDiagnostics")
}
if d := sess.diagnosticsFor(uri); len(d) != 1 || d[0].Message != "fresh" {
t.Fatalf("diagnostics = %#v, want the fresh publish", d)
}
}
// TestPublishBaselineRejectsFrameReadBeforeBaseline is the regression test for
// jatmn's follow-up P1 finding on #759: stamping receipt at ENQUEUE left a
// window async dispatch had opened. The read loop consumes a frame, then decodes
// it (a full json.Unmarshal of a peer-sized payload) and only then enqueues it,
// all while another goroutine can capture a baseline. A publishDiagnostics for
// text the new Check is about to supersede could therefore be numbered above a
// baseline taken after it had already come off the wire, and — since most
// servers omit the version field that handleNotification's staleness check needs
// — reach the caller as if it answered the new text.
//
// The interleaving is driven at the seam itself: stamp (the frame leaves the
// wire), baseline (a concurrent Check), then enqueue and dispatch (the read loop
// finishes decoding). Stamping at enqueue put this publish above the baseline;
// stamping at read puts it at or below.
func TestPublishBaselineRejectsFrameReadBeforeBaseline(t *testing.T) {
client := &Client{notifyReady: make(chan struct{}, 1)}
sess := &session{
client: client,
versions: map[string]int{},
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
uri := PathToURI("/repo/main.go")
// The read loop pulls the stale frame off the wire and stamps it...
seq := client.stampReceipt()
// ...a concurrent Check baselines while that frame is still being decoded...
baseline := sess.publishBaseline()
// ...and only now does the frame reach the queue and the handler.
stale, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "stale"}}})
client.enqueueNotification(notification{
method: "textDocument/publishDiagnostics", params: stale, seq: seq,
})
item, ok, _ := client.dequeueNotification()
if !ok {
t.Fatal("stale notification was not queued")
}
sess.handleNotification(item.method, item.params, item.seq)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if sess.waitForDiagnostics(ctx, uri, 10*time.Millisecond, baseline) {
t.Fatal("a publish whose frame was read before baseline must not satisfy waitForDiagnostics merely because it was decoded and queued afterward")
}
if d := sess.diagnosticsFor(uri); len(d) != 1 || d[0].Message != "stale" {
t.Fatalf("diagnostics = %#v, want the stale publish still recorded (rejected as an answer, not dropped)", d)
}
}
func TestWaitForDiagnosticsReturnsWhenClientCloses(t *testing.T) {
client := &Client{
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
waitDone := make(chan bool, 1)
go func() {
waitDone <- sess.waitForDiagnostics(context.Background(), uri, time.Second, 0)
}()
deadline := time.Now().Add(time.Second)
for {
sess.mu.Lock()
waiting := len(sess.waiters[uri]) == 1
sess.mu.Unlock()
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("diagnostic wait was not registered")
}
time.Sleep(time.Millisecond)
}
client.failPending(errors.New("notification backlog exceeded"))
select {
case fresh := <-waitDone:
if fresh {
t.Fatal("client failure without a fresh publish must not report fresh diagnostics")
}
case <-time.After(time.Second):
t.Fatal("diagnostic wait did not wake when the client failed")
}
sess.mu.Lock()
waiters := len(sess.waiters[uri])
sess.mu.Unlock()
if waiters != 0 {
t.Fatalf("client failure left %d diagnostic waiters registered", waiters)
}
}
func TestWaitForDiagnosticsPreservesPublishHandledBeforeClientCloses(t *testing.T) {
// Keep the test goroutine running while it closes both signals so the waiter
// observes the publish and client closure as simultaneously ready. Without
// re-checking publishSeq, select could randomly choose closure and discard the
// fresh diagnostics that handleNotification had already recorded.
previousProcs := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(previousProcs)
params, err := json.Marshal(PublishDiagnosticsParams{
URI: PathToURI("/repo/main.go"),
Diagnostics: []Diagnostic{{Message: "fresh"}},
})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
client := &Client{
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
waitDone := make(chan bool, 1)
go func() {
waitDone <- sess.waitForDiagnostics(context.Background(), uri, time.Second, 0)
}()
deadline := time.Now().Add(time.Second)
for {
sess.mu.Lock()
waiting := len(sess.waiters[uri]) == 1
sess.mu.Unlock()
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("diagnostic wait was not registered")
}
time.Sleep(time.Millisecond)
}
sess.handleNotification("textDocument/publishDiagnostics", params, 1)
client.failPending(errors.New("server exited after publishing"))
select {
case fresh := <-waitDone:
if !fresh {
t.Fatalf("iteration %d discarded diagnostics handled before client closure", i)
}
case <-time.After(time.Second):
t.Fatal("diagnostic wait did not wake when the client failed")
}
}
}
func TestWaitForDiagnosticsDrainsAcceptedPublishAfterTransportEOF(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
blocked := make(chan struct{})
release := make(chan struct{})
client.SetNotificationHandler(func(method string, params json.RawMessage, seq int64) {
if method == "test/block" {
close(blocked)
<-release
return
}
sess.handleNotification(method, params, seq)
})
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
params := PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "fresh"}}}
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params,
}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() != 2 {
if time.Now().After(deadline) {
t.Fatal("publish was not accepted before EOF")
}
runtime.Gosched()
}
waitDone := make(chan bool, 1)
go func() { waitDone <- sess.waitForDiagnostics(context.Background(), uri, 0, 0) }()
if err := serverWriter.Close(); err != nil {
t.Fatal(err)
}
// Fresh budget: the wait above may have consumed most of the previous one,
// which would make this loop fail on nothing worse than slow scheduling.
deadline = time.Now().Add(time.Second)
for !client.IsClosed() {
if time.Now().After(deadline) {
t.Fatal("transport EOF did not close client")
}
runtime.Gosched()
}
select {
case fresh := <-waitDone:
t.Fatalf("wait returned %v before accepted notifications drained", fresh)
case <-time.After(25 * time.Millisecond):
}
close(release)
select {
case fresh := <-waitDone:
if !fresh {
t.Fatal("wait missed publish accepted before transport EOF")
}
case <-time.After(time.Second):
t.Fatal("wait did not finish after notification drain")
}
if got := sess.diagnosticsFor(uri); len(got) != 1 || got[0].Message != "fresh" {
t.Fatalf("diagnostics = %#v, want fresh publish", got)
}
}
// TestWaitForDiagnosticsCatchesUpOnContextCancellation covers jatmn's P2 finding
// (1): the cancellation arm used to re-check only what the handler had already
// RUN. A publishDiagnostics accepted off the wire but still queued behind a busy
// worker therefore read as "never arrived", and Manager.Check returned (nil,
// nil) for text the server had in fact answered. Cancellation now settles like
// closure does — let the worker finish what it accepted, then re-read.
// TestCatchUpNotificationsFollowsPublishesAcceptedWhileWaiting covers jatmn's
// #759 finding: catch-up snapshotted the accepted sequence once, so a publish
// the reader accepted DURING the wait was not waited for. The worker finishing
// the stale target satisfied the check, and the caller then read diagnosticsFor
// while a newer publish for the same URI was still queued — the same
// "newest received, not the one that woke the wait" failure the closed-client
// drain regression was written for, on the live path.
func TestCatchUpNotificationsFollowsPublishesAcceptedWhileWaiting(t *testing.T) {
clientReader, serverWriter := io.Pipe()
defer serverWriter.Close()
client := NewClient(clientReader, io.Discard)
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
catchUpGrace: 5 * time.Second,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
publish := func(message string) {
t.Helper()
params := PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: message}}}
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params,
}); err != nil {
t.Error(err)
}
}
blocked := make(chan struct{})
release := make(chan struct{})
secondEntered := make(chan struct{})
releaseSecond := make(chan struct{})
client.SetNotificationHandler(func(method string, params json.RawMessage, seq int64) {
switch {
case method == "test/block":
close(blocked)
<-release
return
case strings.Contains(string(params), `"first"`):
// Handling "first" is what makes "second" arrive mid-catch-up: it goes
// on the wire and is confirmed accepted before this handler returns, so
// the worker's handled count only reaches "first" once a newer item is
// already queued behind it.
publish("second")
deadline := time.Now().Add(time.Second)
for client.acceptedNotificationSeq() < 3 {
if time.Now().After(deadline) {
t.Error("follow-up publish was not accepted while the worker was busy")
break
}
runtime.Gosched()
}
case strings.Contains(string(params), `"second"`):
// Hold the worker inside this handler so the assertion below observes
// the state the bug produced: caught up by the stale target, with the
// newer publish still unrecorded.
close(secondEntered)
<-releaseSecond
}
sess.handleNotification(method, params, seq)
})
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
publish("first")
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() != 2 {
if time.Now().After(deadline) {
t.Fatal("first publish was not accepted")
}
runtime.Gosched()
}
done := make(chan struct{})
go func() {
sess.catchUpNotifications()
close(done)
}()
// Catch-up must be parked in its wait before the worker is released, so the
// sequence it started from is the one that goes stale.
select {
case <-done:
t.Fatal("catch-up returned while the worker was still behind")
case <-time.After(25 * time.Millisecond):
}
close(release)
// The worker is now inside the handler for "second", which means "first" is
// recorded and its sequence — the one catch-up started from — is satisfied.
// A snapshot-based catch-up returns here, with the newer publish unrecorded.
select {
case <-secondEntered:
case <-time.After(3 * time.Second):
t.Fatal("worker never reached the follow-up publish")
}
select {
case <-done:
t.Fatalf("catch-up returned on a stale target; diagnostics = %#v", sess.diagnosticsFor(uri))
case <-time.After(25 * time.Millisecond):
}
close(releaseSecond)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("catch-up did not finish")
}
if got := sess.diagnosticsFor(uri); len(got) != 1 || got[0].Message != "second" {
t.Fatalf("diagnostics = %#v, want the publish accepted during catch-up", got)
}
}
// TestCatchUpNotificationsGivesUpAfterTheLiveGrace spells out the tradeoff the
// live-client timeout represents: an unbounded wait would hang Check past a
// deadline the caller has already blown, so a worker that never catches up
// costs the grace and no more. Production sessions use a 50ms grace; this uses
// the same order of magnitude rather than the generous one the other tests pin.
func TestCatchUpNotificationsGivesUpAfterTheLiveGrace(t *testing.T) {
clientReader, serverWriter := io.Pipe()
defer serverWriter.Close()
client := NewClient(clientReader, io.Discard)
sess := &session{
client: client,
catchUpGrace: 25 * time.Millisecond,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
blocked := make(chan struct{})
release := make(chan struct{})
defer close(release)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
if method == "test/block" {
close(blocked)
<-release
}
})
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
done := make(chan struct{})
start := time.Now()
go func() {
sess.catchUpNotifications()
close(done)
}()
select {
case <-done:
if elapsed := time.Since(start); elapsed < sess.catchUpGrace {
t.Fatalf("catch-up returned after %v, before its %v grace", elapsed, sess.catchUpGrace)
}
case <-time.After(3 * time.Second):
t.Fatal("catch-up on a permanently-behind worker must give up after the grace, not hang")
}
}
func TestWaitForDiagnosticsCatchesUpOnContextCancellation(t *testing.T) {
clientReader, serverWriter := io.Pipe()
defer serverWriter.Close()
client := NewClient(clientReader, io.Discard)
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
// Generous, so the catch-up is decided by the worker rather than by how
// fast this test's goroutines happen to be scheduled.
catchUpGrace: 5 * time.Second,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
blocked := make(chan struct{})
release := make(chan struct{})
client.SetNotificationHandler(func(method string, params json.RawMessage, seq int64) {
if method == "test/block" {
close(blocked)
<-release
return
}
sess.handleNotification(method, params, seq)
})
// Occupy the sole worker, then let a publish queue up behind it.
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
params := PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "fresh"}}}
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params,
}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() != 2 {
if time.Now().After(deadline) {
t.Fatal("publish was not accepted before cancellation")
}
runtime.Gosched()
}
ctx, cancel := context.WithCancel(context.Background())
waitDone := make(chan bool, 1)
go func() { waitDone <- sess.waitForDiagnostics(ctx, uri, 0, 0) }()
deadline = time.Now().Add(time.Second)
for {
sess.mu.Lock()
waiting := len(sess.waiters[uri]) == 1
sess.mu.Unlock()
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("diagnostic wait was not registered")
}
runtime.Gosched()
}
cancel()
select {
case fresh := <-waitDone:
t.Fatalf("wait returned %v while an accepted publish was still queued", fresh)
case <-time.After(25 * time.Millisecond):
}
close(release)
select {
case fresh := <-waitDone:
if !fresh {
t.Fatal("cancellation discarded a publish that was already off the wire")
}
case <-time.After(5 * time.Second):
t.Fatal("wait did not finish after the worker caught up")
}
if got := sess.diagnosticsFor(uri); len(got) != 1 || got[0].Message != "fresh" {
t.Fatalf("diagnostics = %#v, want the publish accepted before cancellation", got)
}
}
// TestWaitForDiagnosticsDrainsFollowUpPublishOnCloseDuringDebounce covers
// jatmn's P2 finding (2): the debounce-phase closure arm returned true without
// draining, so Manager.Check read diagnosticsFor while a NEWER publish was still
// queued and handed back the older one. It also pins the drain as
// ctx-independent — cancelling mid-drain (finding (1)'s second clause) must not
// cut it short, since after closure the backlog can no longer grow.
func TestWaitForDiagnosticsDrainsFollowUpPublishOnCloseDuringDebounce(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
// Zero on purpose: the post-closure drain must not be bounded by the
// live-client grace.
catchUpGrace: 0,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
blocked := make(chan struct{})
release := make(chan struct{})
client.SetNotificationHandler(func(method string, params json.RawMessage, seq int64) {
if method == "test/block" {
close(blocked)
<-release
return
}
sess.handleNotification(method, params, seq)
})
publish := func(message string) {
t.Helper()
params := PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: message}}}
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params,
}); err != nil {
t.Fatal(err)
}
}
// A first publish lands and is handled, so the wait below starts out in its
// debounce phase rather than waiting for a first result.
publish("first")
deadline := time.Now().Add(time.Second)
for {
if got := sess.diagnosticsFor(uri); len(got) == 1 && got[0].Message == "first" {
break
}
if time.Now().After(deadline) {
t.Fatal("first publish was never handled")
}
runtime.Gosched()
}
// Now occupy the worker and queue a newer publish behind it.
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
publish("second")
deadline = time.Now().Add(time.Second)
for client.ReceiptSeq() != 3 {
if time.Now().After(deadline) {
t.Fatal("follow-up publish was not accepted before closure")
}
runtime.Gosched()
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
waitDone := make(chan bool, 1)
go func() { waitDone <- sess.waitForDiagnostics(ctx, uri, 5*time.Second, 0) }()
select {
case fresh := <-waitDone:
t.Fatalf("wait returned %v instead of debouncing", fresh)
case <-time.After(25 * time.Millisecond):
}
if err := serverWriter.Close(); err != nil {
t.Fatal(err)
}
deadline = time.Now().Add(time.Second)
for !client.IsClosed() {
if time.Now().After(deadline) {
t.Fatal("transport EOF did not close client")
}
runtime.Gosched()
}
// Cancelling now must not cut the drain short.
cancel()
select {
case fresh := <-waitDone:
t.Fatalf("wait returned %v before the accepted follow-up publish drained", fresh)
case <-time.After(25 * time.Millisecond):
}
close(release)
select {
case fresh := <-waitDone:
if !fresh {
t.Fatal("wait dropped the publish it had already observed")
}
case <-time.After(time.Second):
t.Fatal("wait did not finish after the notification drain")
}
if got := sess.diagnosticsFor(uri); len(got) != 1 || got[0].Message != "second" {
t.Fatalf("diagnostics = %#v, want the follow-up publish accepted before closure", got)
}
}
func TestWaitForDiagnosticsPreservesPublishHandledAtDeadline(t *testing.T) {
previousProcs := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(previousProcs)
params, err := json.Marshal(PublishDiagnosticsParams{
URI: PathToURI("/repo/main.go"), Diagnostics: []Diagnostic{{Message: "fresh"}},
})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
client := &Client{closed: make(chan struct{})}
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
ctx, cancel := context.WithCancel(context.Background())
waitDone := make(chan bool, 1)
go func() {
waitDone <- sess.waitForDiagnostics(ctx, uri, time.Second, 0)
}()
deadline := time.Now().Add(time.Second)
for {
sess.mu.Lock()
waiting := len(sess.waiters[uri]) == 1
sess.mu.Unlock()
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("diagnostic wait was not registered")
}
runtime.Gosched()
}
// handleNotification records the publish and closes the waiter. Cancel
// before yielding under GOMAXPROCS(1), making both cases ready together.
sess.handleNotification("textDocument/publishDiagnostics", params, 1)
cancel()
select {
case fresh := <-waitDone:
if !fresh {
t.Fatalf("iteration %d: deadline discarded a publish handled before cancellation", i)
}
case <-time.After(time.Second):
t.Fatalf("iteration %d: wait did not return", i)
}
}
}
func TestManagerCheckDegradesWhenServerBinaryMissing(t *testing.T) {
// A configured extension whose binary isn't on PATH (exec.ErrNotFound) must
// degrade to no diagnostics, exactly like an unsupported extension.
-53
View File
@@ -26,10 +26,6 @@ type RemoteTool struct {
type Content struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
// MimeType names what a non-text block holds. Decoded but not yet forwarded:
// it is what lets a dropped block be described to the model instead of
// vanishing (#823). Servers that omit it still decode fine.
MimeType string `json:"mimeType,omitempty"`
}
type CallToolResult struct {
@@ -499,52 +495,3 @@ func TextContent(content []Content) string {
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
// DroppedContentSummary describes the blocks TextContent discards, e.g.
// "1 image/png block" or "2 resource blocks, 1 audio/wav block". It returns ""
// when a result is entirely text, so a caller adds nothing to the ordinary case.
//
// This exists because dropping silently is the worst available behaviour. A
// screenshot server returns a valid image, TextContent keeps nothing, and the
// call is reported as "(empty MCP tool result)" — so the model concludes the
// tool produced nothing and usually retries, burning another call on the same
// empty answer. Naming what came back costs nothing and ends that loop even
// though the payload still cannot be forwarded.
//
// Counts are grouped by mime type and ordered by first appearance, so the same
// result always produces the same sentence.
func DroppedContentSummary(content []Content) string {
labels := make([]string, 0, len(content))
counts := make(map[string]int, len(content))
for _, item := range content {
if item.Type == "text" {
continue
}
// Prefer the mime type: "image/png" tells the reader more than "image".
// A server may omit it, so fall back to the block type rather than
// printing an empty label.
label := strings.TrimSpace(item.MimeType)
if label == "" {
label = strings.TrimSpace(item.Type)
}
if label == "" {
label = "unknown"
}
if _, seen := counts[label]; !seen {
labels = append(labels, label)
}
counts[label]++
}
if len(labels) == 0 {
return ""
}
parts := make([]string, 0, len(labels))
for _, label := range labels {
part := fmt.Sprintf("%d %s block", counts[label], label)
if counts[label] != 1 {
part += "s"
}
parts = append(parts, part)
}
return strings.Join(parts, ", ")
}
-158
View File
@@ -1,158 +0,0 @@
package mcp
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// A server that returns only an image currently reports "(empty MCP tool
// result)": TextContent keeps text blocks and drops the rest, so a successful
// call looks like it produced nothing. The model then usually retries, which is
// the worst outcome, and the user is never told an image existed (#823).
//
// Carrying the payload is a separate change. Naming what was dropped is what
// stops the retry loop, and it has to be true of the DELIVERED result, so this
// drives registryTool.Run rather than the helper alone.
func TestAnImageOnlyResultSaysWhatItReturned(t *testing.T) {
tool := registryTool{
client: &nonTextClient{content: []Content{
{Type: "image", MimeType: "image/png"},
}},
server: Server{Name: "shots"},
remote: RemoteTool{Name: "screenshot"},
}
result := tool.Run(context.Background(), map[string]any{})
if strings.Contains(result.Output, "(empty MCP tool result)") {
t.Fatalf("an image-only result still reports empty, so the model will retry:\n%s", result.Output)
}
if !strings.Contains(result.Output, "image/png") {
t.Errorf("the output does not name what the server returned:\n%s", result.Output)
}
// Naming the block is only half of it. Without the guidance the model still
// retries, which is the expensive symptom, so the wording is pinned too.
//
// "cannot recover this payload" and not "will return the same thing": every
// retry is a fresh call and the server may answer differently. What cannot
// change is that Zero has nowhere to put a non-text block.
if !strings.Contains(result.Output, "Retrying cannot recover this payload.") {
t.Errorf("the output does not tell the model retrying is pointless:\n%s", result.Output)
}
if strings.Contains(result.Output, "will return the same thing") {
t.Errorf("the output promises an identical response, which a fresh call cannot guarantee:\n%s", result.Output)
}
if result.Status != tools.StatusOK {
t.Errorf("status = %v, want OK: the call succeeded, we just cannot forward the payload", result.Status)
}
}
// The quieter half of the same bug: when a result carries text AND an image,
// the text arrives and the image vanishes with no mention at all.
func TestTextAlongsideAnImageStillReportsTheImage(t *testing.T) {
tool := registryTool{
client: &nonTextClient{content: []Content{
{Type: "text", Text: "captured the page"},
{Type: "image", MimeType: "image/png"},
}},
server: Server{Name: "shots"},
remote: RemoteTool{Name: "screenshot"},
}
result := tool.Run(context.Background(), map[string]any{})
if !strings.Contains(result.Output, "captured the page") {
t.Errorf("the text block was lost:\n%s", result.Output)
}
if !strings.Contains(result.Output, "image/png") {
t.Errorf("the dropped image was not mentioned:\n%s", result.Output)
}
}
// A text-only result must be byte-for-byte what it was before: this change adds
// a line only when something was actually dropped.
func TestATextOnlyResultIsUnchanged(t *testing.T) {
tool := registryTool{
client: &nonTextClient{content: []Content{{Type: "text", Text: "plain answer"}}},
server: Server{Name: "shots"},
remote: RemoteTool{Name: "lookup"},
}
if got := tool.Run(context.Background(), map[string]any{}).Output; got != "plain answer" {
t.Fatalf("output = %q, want exactly %q", got, "plain answer")
}
}
func TestDroppedContentSummaryNamesTheBlocks(t *testing.T) {
tests := []struct {
name string
content []Content
want string
}{
{
name: "nothing dropped",
content: []Content{{Type: "text", Text: "hi"}},
want: "",
},
{
name: "no content at all",
content: nil,
want: "",
},
{
name: "one image with a mime type",
content: []Content{{Type: "image", MimeType: "image/png"}},
want: "1 image/png block",
},
{
// A server may omit mimeType. Fall back to the block type rather than
// inventing one or printing an empty pair of slashes.
name: "one image without a mime type",
content: []Content{{Type: "image"}},
want: "1 image block",
},
{
name: "several of the same kind are counted, not repeated",
content: []Content{
{Type: "image", MimeType: "image/png"},
{Type: "image", MimeType: "image/png"},
},
want: "2 image/png blocks",
},
{
// Order follows first appearance so the message is stable to read and
// to assert on.
name: "mixed kinds",
content: []Content{
{Type: "text", Text: "ignored here"},
{Type: "resource"},
{Type: "audio", MimeType: "audio/wav"},
{Type: "resource"},
},
want: "2 resource blocks, 1 audio/wav block",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := DroppedContentSummary(test.content); got != test.want {
t.Fatalf("DroppedContentSummary() = %q, want %q", got, test.want)
}
})
}
}
type nonTextClient struct {
content []Content
}
func (client *nonTextClient) ListTools(context.Context) ([]RemoteTool, error) { return nil, nil }
func (client *nonTextClient) CallTool(context.Context, string, map[string]any) (CallToolResult, error) {
return CallToolResult{Content: client.content}, nil
}
func (client *nonTextClient) Close() error { return nil }
-18
View File
@@ -325,24 +325,6 @@ func (tool registryTool) Run(ctx context.Context, args map[string]any) tools.Res
status = tools.StatusError
}
output := TextContent(result.Content)
// Say what was thrown away. Without this an image-only result reads as
// "(empty MCP tool result)", the model concludes the call produced nothing
// and retries, and the user never learns an image came back (#823). The note
// is appended only when something was actually dropped, so a text-only
// result is byte-for-byte what it was before.
//
// It says retrying cannot RECOVER the payload rather than that a retry
// returns the same thing. Each retry is a fresh call, so the server may well
// answer differently; what cannot change is that Zero still has nowhere to
// put a non-text block. Claiming the response would be identical would be a
// promise this code is in no position to make.
if dropped := DroppedContentSummary(result.Content); dropped != "" {
note := "[zero] this server also returned " + dropped + ", which Zero cannot forward yet. Retrying cannot recover this payload."
if output == "" {
note = "[zero] this server returned " + dropped + ", which Zero cannot forward yet. Retrying cannot recover this payload."
}
output = strings.TrimSpace(output + "\n\n" + note)
}
if output == "" {
output = "(empty MCP tool result)"
}
+1 -4
View File
@@ -29,9 +29,6 @@ const (
// newer Anthropic models). Defined so a capability catalog that lists "max"
// has a constant to map to; no curated model lists it yet.
ReasoningEffortMax ReasoningEffort = "max"
// ReasoningEffortUltra is advertised by models whose provider exposes an
// additional tier above max.
ReasoningEffortUltra ReasoningEffort = "ultra"
)
type ModelCapability string
@@ -413,7 +410,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
func ValidReasoningEffort(effort ReasoningEffort) bool {
switch effort {
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax, ReasoningEffortUltra:
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
return true
default:
return false
+7 -4
View File
@@ -140,14 +140,17 @@ func writeNewSecretFile(path string) ([]byte, error) {
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
}
// The encryption key is published exactly like the token blob: through the
// protected per-store directory, under a random name a sandboxed process
// cannot guess, wait for, or rename away.
tmp, tmpPath, err := createPublicationFile(path)
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return nil, fmt.Errorf("oauth: create token secret temp file: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return nil, fmt.Errorf("oauth: chmod token secret temp file: %w", err)
}
if _, werr := tmp.Write(secret); werr != nil {
_ = tmp.Close()
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
+5 -43
View File
@@ -409,53 +409,15 @@ func (b fileBlob) write(data []byte) error {
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
return err
}
temp, tempPath, err := createPublicationFile(b.path)
if err != nil {
tempPath := fmt.Sprintf("%s.tmp-%d-%d", b.path, os.Getpid(), time.Now().UnixNano())
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
return err
}
defer os.Remove(tempPath)
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
if err := os.Rename(tempPath, b.path); err != nil {
_ = os.Remove(tempPath)
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(tempPath, b.path)
}
// PublicationDirSuffix names the per-store directory a token store publishes new
// contents through. Sandbox profiles deny it by name (see
// internal/sandbox.credentialPublicationDir), which is why the directory name is
// derived from the store path while the file inside it is randomly named: the
// deterministic part is what a deny rule can reference, and the random part is
// what stops a same-user process from waiting for the plaintext to appear at a
// path it can open or rename away.
const PublicationDirSuffix = ".publish"
// PublicationDir returns the publication directory for a store path.
func PublicationDir(path string) string { return path + PublicationDirSuffix }
// createPublicationFile creates the randomly-named 0600 file that path's next
// contents are written to before being renamed into place. It lives in
// PublicationDir(path) — same filesystem as path, so the rename stays atomic —
// and the directory is created 0700 if it does not exist yet.
func createPublicationFile(path string) (*os.File, string, error) {
dir := PublicationDir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, "", err
}
temp, err := os.CreateTemp(dir, "publish-*")
if err != nil {
return nil, "", err
}
if err := temp.Chmod(0o600); err != nil {
name := temp.Name()
_ = temp.Close()
_ = os.Remove(name)
return nil, "", err
}
return temp, temp.Name(), nil
return nil
}
func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
-54
View File
@@ -91,60 +91,6 @@ func TestStoreFileMode0600(t *testing.T) {
}
}
// TestStorePublishesThroughProtectedDirectory pins the publication contract the
// sandbox profile relies on: the plaintext blob is never written to a path a
// same-user process can predict (a fixed `.tmp` sibling), only to a random name
// inside the directory the profile denies by name, and nothing is left behind.
func TestStorePublishesThroughProtectedDirectory(t *testing.T) {
s, path := newTestStore(t)
if err := os.WriteFile(path+".tmp", []byte("stale"), 0o600); err != nil {
t.Fatal(err)
}
if err := s.Save(ProviderKey("x"), Token{AccessToken: "secret"}); err != nil {
t.Fatalf("Save: %v", err)
}
// A predictable sibling is never used, so a pre-existing one is untouched
// rather than becoming the file the plaintext passes through.
if data, err := os.ReadFile(path + ".tmp"); err != nil || string(data) != "stale" {
t.Fatalf("fixed sibling data = %q, err = %v, want the untouched placeholder", data, err)
}
assertEmptyPublicationDir(t, path)
}
func TestEncryptedStorePublishesSecretThroughProtectedDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.json")
if err := os.WriteFile(path+".secret.tmp", []byte("stale"), 0o600); err != nil {
t.Fatal(err)
}
s, err := NewStore(StoreOptions{FilePath: path, Storage: "encrypted-file"})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := s.Save(ProviderKey("x"), Token{AccessToken: "secret"}); err != nil {
t.Fatalf("Save: %v", err)
}
if data, err := os.ReadFile(path + ".secret.tmp"); err != nil || string(data) != "stale" {
t.Fatalf("fixed secret sibling data = %q, err = %v, want the untouched placeholder", data, err)
}
assertEmptyPublicationDir(t, path)
assertEmptyPublicationDir(t, path+".secret")
}
// assertEmptyPublicationDir asserts the store's publication directory exists
// (so the sandbox has a mount target to mask on Linux) and holds no leftover
// copy of the secret it published.
func assertEmptyPublicationDir(t *testing.T, storePath string) {
t.Helper()
dir := PublicationDir(storePath)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read publication dir %s: %v", dir, err)
}
if len(entries) != 0 {
t.Fatalf("publication dir %s = %v, want empty after publish", dir, entries)
}
}
func TestStoreMalformedFailsClosed(t *testing.T) {
s, path := newTestStore(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
+11 -17
View File
@@ -8,21 +8,17 @@ import (
)
type Model struct {
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
ReasoningEfforts []string
DefaultReasoningEffort string
ServiceTiers []string
DefaultServiceTier string
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
}
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
@@ -274,8 +270,6 @@ func dedupeModels(defaultModel string, models []Model) []Model {
}
model.InputModalities = append([]string{}, model.InputModalities...)
model.OutputModalities = append([]string{}, model.OutputModalities...)
model.ReasoningEfforts = append([]string{}, model.ReasoningEfforts...)
model.ServiceTiers = append([]string{}, model.ServiceTiers...)
model.Tags = append([]string{}, model.Tags...)
seen[model.ID] = true
result = append(result, model)
@@ -122,25 +122,6 @@ func TestModelsDoNotAliasMutableCatalogState(t *testing.T) {
}
}
func TestDedupeModelsDoesNotAliasCapabilitySlices(t *testing.T) {
models := []Model{{
ID: "reasoner",
ReasoningEfforts: []string{"low", "high"},
ServiceTiers: []string{"priority"},
}}
first := dedupeModels("reasoner", models)
first[0].ReasoningEfforts[0] = "mutated"
first[0].ServiceTiers[0] = "mutated"
second := dedupeModels("reasoner", models)
if got, want := second[0].ReasoningEfforts[0], "low"; got != want {
t.Fatalf("reasoning efforts alias source state: got %q, want %q", got, want)
}
if got, want := second[0].ServiceTiers[0], "priority"; got != want {
t.Fatalf("service tiers alias source state: got %q, want %q", got, want)
}
}
func modelIDs(models []Model) []string {
ids := make([]string, 0, len(models))
for _, model := range models {
+3 -122
View File
@@ -48,28 +48,13 @@ func TestModelMatchesProvider(t *testing.T) {
}
func TestDefaultedOpenGatewayURL(t *testing.T) {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{}, " https://x/v1/models "); got != "https://x/v1/models" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{}, " https://x/models.json "); got != "https://x/models.json" {
t.Fatalf("explicit override = %q, want trimmed override", got)
}
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/v1/models" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/zero/models.json" {
t.Fatalf("derived = %q", got)
}
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/v1/models" {
t.Fatalf("fallback = %q", got)
}
}
func TestDefaultedOpenRouterURL(t *testing.T) {
if got := defaultedOpenRouterURL(providercatalog.Descriptor{}, " https://or.example/api/v1/models "); got != "https://or.example/api/v1/models" {
t.Fatalf("explicit override = %q, want trimmed override", got)
}
if got := defaultedOpenRouterURL(providercatalog.Descriptor{DefaultBaseURL: "https://openrouter.ai/api/v1"}, ""); got != "https://openrouter.ai/api/v1/models" {
t.Fatalf("derived = %q", got)
}
if got := defaultedOpenRouterURL(providercatalog.Descriptor{DefaultBaseURL: "https://openrouter.ai/api/v1?token=x#frag"}, ""); got != "https://openrouter.ai/api/v1/models" {
t.Fatalf("query/fragment stripped = %q, want clean /models URL", got)
}
if got := defaultedOpenRouterURL(providercatalog.Descriptor{DefaultBaseURL: "bad"}, ""); got != "https://openrouter.ai/api/v1/models" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/zero/models.json" {
t.Fatalf("fallback = %q", got)
}
}
@@ -147,110 +132,6 @@ func TestFetchModelsDevAndOpenGatewayOverHTTP(t *testing.T) {
if !containsModelID(routed, "claude-coder") {
t.Fatalf("FetchRemote models = %#v, want claude-coder", routed)
}
openrouter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"id":"openai/gpt-4o","name":"GPT-4o","context_length":128000,"supported_parameters":["tools"]}]}`))
}))
defer openrouter.Close()
orModels, err := FetchOpenRouter(context.Background(), openrouter.URL, FetchOptions{HTTPClient: openrouter.Client()})
if err != nil {
t.Fatalf("FetchOpenRouter error: %v", err)
}
if !containsModelID(orModels, "openai/gpt-4o") {
t.Fatalf("FetchOpenRouter models = %#v, want openai/gpt-4o", orModels)
}
routedOR, err := FetchRemote(context.Background(), providercatalog.Descriptor{ID: "openrouter"}, FetchOptions{HTTPClient: openrouter.Client(), OpenRouterURL: openrouter.URL})
if err != nil {
t.Fatalf("FetchRemote openrouter error: %v", err)
}
if !containsModelID(routedOR, "openai/gpt-4o") {
t.Fatalf("FetchRemote openrouter models = %#v, want openai/gpt-4o", routedOR)
}
}
// TestFetchOpenRouterFallsBackToModelsDev pins independent resilience: when
// openrouter.ai is down, models.dev still supplies a coding list so the picker
// does not error empty.
func TestFetchOpenRouterFallsBackToModelsDev(t *testing.T) {
openrouter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream unavailable", http.StatusBadGateway)
}))
defer openrouter.Close()
modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{
"openrouter": {
"models": {
"openai/gpt-4.1": {
"id": "openai/gpt-4.1",
"name": "GPT-4.1",
"tool_call": true,
"limit": {"context": 1048576},
"cost": {"input": 2, "output": 8},
"modalities": {"input": ["text"], "output": ["text"]}
}
}
}
}`))
}))
defer modelsDev.Close()
models, err := FetchOpenRouter(context.Background(), openrouter.URL, FetchOptions{
HTTPClient: openrouter.Client(),
ModelsDevURL: modelsDev.URL,
})
if err != nil {
t.Fatalf("FetchOpenRouter with models.dev fallback: %v", err)
}
if !containsModelID(models, "openai/gpt-4.1") {
t.Fatalf("models = %#v, want models.dev openrouter fallback entry", models)
}
if models[0].Source != "models.dev" {
t.Fatalf("source = %q, want models.dev fallback source", models[0].Source)
}
}
// TestFetchOpenRouterFallsBackOnMalformedJSON covers the HTTP-200-but-unparseable
// path: a broken openrouter.ai body must still degrade to models.dev instead of
// failing the catalog fetch entirely.
func TestFetchOpenRouterFallsBackOnMalformedJSON(t *testing.T) {
openrouter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"data": not-json`))
}))
defer openrouter.Close()
modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{
"openrouter": {
"models": {
"openai/gpt-4.1": {
"id": "openai/gpt-4.1",
"name": "GPT-4.1",
"tool_call": true,
"limit": {"context": 1048576},
"cost": {"input": 2, "output": 8},
"modalities": {"input": ["text"], "output": ["text"]}
}
}
}
}`))
}))
defer modelsDev.Close()
models, err := FetchOpenRouter(context.Background(), openrouter.URL, FetchOptions{
HTTPClient: openrouter.Client(),
ModelsDevURL: modelsDev.URL,
})
if err != nil {
t.Fatalf("FetchOpenRouter malformed JSON fallback: %v", err)
}
if !containsModelID(models, "openai/gpt-4.1") {
t.Fatalf("models = %#v, want models.dev openrouter fallback entry", models)
}
if models[0].Source != "models.dev" {
t.Fatalf("source = %q, want models.dev fallback source", models[0].Source)
}
}
func containsModelID(models []Model, id string) bool {
+51 -293
View File
@@ -1,16 +1,13 @@
package providermodelcatalog
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
@@ -18,27 +15,24 @@ import (
)
const (
DefaultModelsDevURL = "https://models.dev/api.json"
defaultOpenGatewayURL = "https://opengateway.gitlawb.com/v1/models"
defaultOpenRouterURL = "https://openrouter.ai/api/v1/models"
modelsDevSource = "models.dev"
openGatewaySource = "opengateway"
openRouterSource = "openrouter"
DefaultModelsDevURL = "https://models.dev/api.json"
modelsDevSource = "models.dev"
openGatewaySource = "opengateway"
)
type FetchOptions struct {
HTTPClient *http.Client
ModelsDevURL string
OpenGatewayURL string
OpenRouterURL string
}
func FetchRemote(ctx context.Context, provider providercatalog.Descriptor, options FetchOptions) ([]Model, error) {
switch providercatalog.NormalizeID(provider.ID) {
case "gitlawb-opengateway":
return FetchOpenGateway(ctx, defaultedOpenGatewayURL(provider, options.OpenGatewayURL), options)
case "openrouter":
return FetchOpenRouter(ctx, defaultedOpenRouterURL(provider, options.OpenRouterURL), options)
if provider.ID == "gitlawb-opengateway" {
models, err := FetchOpenGateway(ctx, defaultedOpenGatewayURL(provider, options.OpenGatewayURL), options)
if err != nil {
return nil, err
}
return models, nil
}
providerID := ModelsDevProviderID(provider)
@@ -68,28 +62,6 @@ func FetchOpenGateway(ctx context.Context, endpoint string, options FetchOptions
return ParseOpenGatewayCatalog(body)
}
// FetchOpenRouter loads OpenRouter's public live model list (GET /api/v1/models).
// Auth is optional for listing; callers may still attach a key for account-scoped
// probes via the live discovery path. When the live host is unreachable or
// returns an unparseable body, fall back to models.dev so the picker still
// degrades instead of failing entirely (both catalog and live probe hit
// openrouter.ai otherwise).
func FetchOpenRouter(ctx context.Context, endpoint string, options FetchOptions) ([]Model, error) {
body, err := fetchJSON(ctx, endpoint, options.HTTPClient)
if err == nil {
models, parseErr := ParseOpenRouterCatalog(body)
if parseErr == nil {
return models, nil
}
err = parseErr
}
fallback, fallbackErr := FetchModelsDev(ctx, "openrouter", options)
if fallbackErr == nil {
return fallback, nil
}
return nil, fmt.Errorf("%w (models.dev fallback: %v)", err, fallbackErr)
}
func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) {
var payload map[string]struct {
Models map[string]remoteModel `json:"models"`
@@ -117,25 +89,24 @@ func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) {
return models, nil
}
// ParseOpenGatewayCatalog parses OpenGateway's live GET /v1/models payload (or the
// older {models:[...]} shape). The gateway already curates what it exposes, so
// every non-empty id is accepted rather than applying the coding-model heuristic.
func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
items, err := parseOpenAIStyleModelList(body)
if err != nil {
var payload struct {
Models []remoteModel `json:"models"`
Data []remoteModel `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("decode OpenGateway catalog: %w", err)
}
items := payload.Models
if len(items) == 0 {
items = payload.Data
}
models := make([]Model, 0, len(items))
for _, item := range items {
model := item.toModel("", openGatewaySource)
if model.ID == "" {
if model.ID == "" || !IsCodingModel(model) {
continue
}
// Drop only clearly non-coding ids if a gateway ever lists them.
if IsKnownNonCodingModelID(model.ID) {
continue
}
model = annotateFreeModel(model, item)
models = append(models, model)
}
sortModels(models)
@@ -145,43 +116,6 @@ func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
return models, nil
}
// ParseOpenRouterCatalog parses OpenRouter's public GET /api/v1/models list and
// keeps coding-capable entries (tools, reasoning, or coding-like ids).
func ParseOpenRouterCatalog(body []byte) ([]Model, error) {
items, err := parseOpenAIStyleModelList(body)
if err != nil {
return nil, fmt.Errorf("decode OpenRouter catalog: %w", err)
}
models := make([]Model, 0, len(items))
for _, item := range items {
model := item.toModel("", openRouterSource)
if model.ID == "" || !IsCodingModel(model) {
continue
}
model = annotateFreeModel(model, item)
models = append(models, model)
}
sortModels(models)
if len(models) == 0 {
return nil, fmt.Errorf("OpenRouter catalog returned no models")
}
return models, nil
}
func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
var payload struct {
Models []remoteModel `json:"models"`
Data []remoteModel `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
if len(payload.Models) > 0 {
return payload.Models, nil
}
return payload.Data, nil
}
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
switch strings.TrimSpace(provider.ID) {
case "dashscope":
@@ -205,52 +139,22 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
}
}
// PublicLiveCatalog reports whether the provider publishes a public live model
// list that Zero should prefer over third-party catalogs (models.dev) and that
// can be fetched without credentials.
func PublicLiveCatalog(providerID string) bool {
switch providercatalog.NormalizeID(providerID) {
case "openrouter", "gitlawb-opengateway":
return true
default:
return false
}
}
type remoteModel struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ContextWindow int `json:"context_window"`
ContextWindowCamel int `json:"contextWindow"`
ContextLength int `json:"context_length"`
MaxContextLength int `json:"max_context_length"`
ToolCall bool `json:"tool_call"`
ToolCallCamel bool `json:"toolCall"`
Tools bool `json:"tools"`
// ReasoningRaw accepts both a boolean (models.dev) and OpenRouter's object form.
ReasoningRaw json.RawMessage `json:"reasoning"`
ReasoningEfforts []string `json:"reasoning_efforts"`
DefaultReasoning string `json:"default_reasoning_effort"`
ServiceTiers []string `json:"service_tiers"`
AdditionalSpeed []string `json:"additional_speed_tiers"`
DefaultServiceTier string `json:"default_service_tier"`
Free bool `json:"free"`
IsFree bool `json:"is_free"`
InputCost float64 `json:"input_cost"`
OutputCost float64 `json:"output_cost"`
Tags []string `json:"tags"`
SupportedParameters []string `json:"supported_parameters"`
Limit remoteLimit `json:"limit"`
Cost remoteCost `json:"cost"`
Pricing remotePricing `json:"pricing"`
Modalities remoteModalities `json:"modalities"`
Architecture remoteArchitecture `json:"architecture"`
}
type remotePricing struct {
Prompt string `json:"prompt"`
Completion string `json:"completion"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ContextWindow int `json:"context_window"`
ContextWindowCamel int `json:"contextWindow"`
ToolCall bool `json:"tool_call"`
ToolCallCamel bool `json:"toolCall"`
Tools bool `json:"tools"`
Reasoning bool `json:"reasoning"`
InputCost float64 `json:"input_cost"`
OutputCost float64 `json:"output_cost"`
Tags []string `json:"tags"`
Limit remoteLimit `json:"limit"`
Cost remoteCost `json:"cost"`
Modalities remoteModalities `json:"modalities"`
}
type remoteLimit struct {
@@ -270,141 +174,26 @@ type remoteModalities struct {
Output []string `json:"output"`
}
// OpenRouter nests modalities under architecture; OpenGateway uses flat fields.
type remoteArchitecture struct {
InputModalities []string `json:"input_modalities"`
OutputModalities []string `json:"output_modalities"`
}
type remoteReasoningInfo struct {
Mandatory bool `json:"mandatory"`
DefaultEnabled bool `json:"default_enabled"`
Supported []string `json:"supported_efforts"`
}
func (model remoteModel) toModel(key string, source string) Model {
id := firstNonEmpty(model.ID, key)
contextWindow := firstPositive(
model.ContextWindow,
model.ContextWindowCamel,
model.ContextLength,
model.MaxContextLength,
model.Limit.Context,
)
inputCost := firstPositiveFloat(
model.InputCost,
parsePricingString(model.Pricing.Prompt),
model.Cost.Input,
)
outputCost := firstPositiveFloat(
model.OutputCost,
parsePricingString(model.Pricing.Completion),
model.Cost.Output,
)
inputModalities := cleanStrings(model.Modalities.Input)
if len(inputModalities) == 0 {
inputModalities = cleanStrings(model.Architecture.InputModalities)
}
outputModalities := cleanStrings(model.Modalities.Output)
if len(outputModalities) == 0 {
outputModalities = cleanStrings(model.Architecture.OutputModalities)
}
toolCall := model.ToolCall || model.ToolCallCamel || model.Tools || containsFold(model.SupportedParameters, "tools")
reasoning := model.supportsReasoning() ||
containsFold(model.SupportedParameters, "reasoning") ||
containsFold(model.SupportedParameters, "reasoning_effort") ||
containsFold(model.SupportedParameters, "include_reasoning")
reasoningEfforts := model.supportedReasoningEfforts()
serviceTiers := normalizedServiceTiers(model.ServiceTiers, model.AdditionalSpeed)
// Prefer the short display name for aggregator catalogs (OpenRouter's
// description field is multi-sentence marketing copy). models.dev and
// OpenGateway still prefer an explicit description when present.
description := firstNonEmpty(model.Description, model.Name)
if source == openRouterSource {
description = firstNonEmpty(model.Name, model.Description)
}
contextWindow := firstPositive(model.ContextWindow, model.ContextWindowCamel, model.Limit.Context)
inputCost := firstPositiveFloat(model.InputCost, model.Cost.Input)
outputCost := firstPositiveFloat(model.OutputCost, model.Cost.Output)
return Model{
ID: strings.TrimSpace(id),
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning,
ReasoningEfforts: reasoningEfforts,
DefaultReasoningEffort: strings.TrimSpace(model.DefaultReasoning),
ServiceTiers: serviceTiers,
DefaultServiceTier: normalizeServiceTier(model.DefaultServiceTier),
InputModalities: inputModalities,
OutputModalities: outputModalities,
InputCost: inputCost,
OutputCost: outputCost,
Tags: cleanStrings(model.Tags),
Source: source,
ID: strings.TrimSpace(id),
Description: firstNonEmpty(model.Description, model.Name),
ContextWindow: contextWindow,
ToolCall: model.ToolCall || model.ToolCallCamel || model.Tools,
Reasoning: model.Reasoning,
InputModalities: cleanStrings(model.Modalities.Input),
OutputModalities: cleanStrings(model.Modalities.Output),
InputCost: inputCost,
OutputCost: outputCost,
Tags: cleanStrings(model.Tags),
Source: source,
}
}
func normalizedServiceTiers(tiers []string, legacy []string) []string {
values := append(append([]string{}, tiers...), legacy...)
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = normalizeServiceTier(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func normalizeServiceTier(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "fast" {
return "priority"
}
return value
}
func (model remoteModel) supportedReasoningEfforts() []string {
efforts := append([]string{}, model.ReasoningEfforts...)
var info remoteReasoningInfo
if err := json.Unmarshal(bytes.TrimSpace(model.ReasoningRaw), &info); err == nil {
efforts = append(efforts, info.Supported...)
}
return cleanStrings(efforts)
}
func (model remoteModel) supportsReasoning() bool {
raw := bytes.TrimSpace(model.ReasoningRaw)
if len(raw) == 0 || string(raw) == "null" {
return false
}
var flag bool
if err := json.Unmarshal(raw, &flag); err == nil {
return flag
}
var info remoteReasoningInfo
if err := json.Unmarshal(raw, &info); err != nil {
return false
}
return info.Mandatory || info.DefaultEnabled || len(info.Supported) > 0
}
func annotateFreeModel(model Model, raw remoteModel) Model {
if !raw.Free && !raw.IsFree && !strings.HasSuffix(strings.ToLower(model.ID), ":free") {
return model
}
if !containsFold(model.Tags, "free") {
model.Tags = append(append([]string{}, model.Tags...), "free")
}
if model.Description != "" && !strings.Contains(strings.ToLower(model.Description), "free") {
model.Description = model.Description + " (free)"
}
return model
}
func fetchJSON(ctx context.Context, endpoint string, client *http.Client) ([]byte, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
@@ -424,7 +213,6 @@ func fetchJSON(ctx context.Context, endpoint string, client *http.Client) ([]byt
return nil, err
}
defer response.Body.Close()
// OpenRouter's full catalog is ~0.5MB today; keep headroom for growth.
body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
return nil, err
@@ -439,27 +227,11 @@ func defaultedOpenGatewayURL(provider providercatalog.Descriptor, override strin
if override = strings.TrimSpace(override); override != "" {
return override
}
return defaultedOpenAIModelsURL(provider.DefaultBaseURL, defaultOpenGatewayURL)
}
func defaultedOpenRouterURL(provider providercatalog.Descriptor, override string) string {
if override = strings.TrimSpace(override); override != "" {
return override
}
return defaultedOpenAIModelsURL(provider.DefaultBaseURL, defaultOpenRouterURL)
}
// defaultedOpenAIModelsURL appends /models to an OpenAI-compatible base URL
// (e.g. https://host/v1 → https://host/v1/models).
func defaultedOpenAIModelsURL(baseURL string, fallback string) string {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
parsed, err := url.Parse(provider.DefaultBaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fallback
return "https://opengateway.gitlawb.com/zero/models.json"
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/models"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
return parsed.Scheme + "://" + parsed.Host + "/zero/models.json"
}
func sortModels(models []Model) {
@@ -507,20 +279,6 @@ func firstPositiveFloat(values ...float64) float64 {
return 0
}
// parsePricingString converts OpenRouter/OpenGateway pricing.prompt and
// pricing.completion values (USD per token) into the Model.InputCost/OutputCost
// unit used everywhere else (USD per million tokens, matching models.dev
// cost.input / cost.output). Values <= 0, including OpenRouter's "-1"
// variable/BYOK marker, and non-finite values (NaN/Inf), map to 0 so the
// picker shows no price rather than a bogus number.
func parsePricingString(s string) float64 {
val, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil || val <= 0 || math.IsNaN(val) || math.IsInf(val, 0) {
return 0
}
return val * 1e6
}
func cleanStrings(values []string) []string {
result := make([]string, 0, len(values))
seen := map[string]bool{}
+8 -126
View File
@@ -89,13 +89,9 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
"tags": ["free"]
},
{
"id": "auto",
"name": "Auto (smart routing)",
"description": "picks the cheapest capable model"
},
{
"id": "whisper-1",
"name": "Whisper"
"id": "image-route",
"name": "Image Route",
"modalities": {"input": ["text"], "output": ["image"]}
}
]
}`)
@@ -104,18 +100,10 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
if err != nil {
t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err)
}
// Gateway list is trusted: keep auto + coding routes; drop known non-coding.
// Sorted by description label (agentic…, free…, picks…).
if got := strings.Join(modelIDs(models), ","); got != "minimax-m3,tencent/hy3,auto" {
t.Fatalf("models = %#v, want gateway live models including auto", got)
}
var model Model
for _, entry := range models {
if entry.ID == "minimax-m3" {
model = entry
break
}
if got := strings.Join(modelIDs(models), ","); got != "minimax-m3,tencent/hy3" {
t.Fatalf("models = %#v, want gateway coding models", got)
}
model := models[0]
if model.ID != "minimax-m3" || model.Description != "agentic coding route" {
t.Fatalf("gateway model = %#v, want rich description", model)
}
@@ -128,114 +116,8 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
if model.Source != "opengateway" {
t.Fatalf("gateway source = %q, want opengateway", model.Source)
}
var hy3 Model
for _, entry := range models {
if entry.ID == "tencent/hy3" {
hy3 = entry
break
}
}
if hy3.ID != "tencent/hy3" || hy3.ContextWindow != 262144 || !hy3.ToolCall {
t.Fatalf("gateway HY3 model = %#v, want Tencent HY3 with context/tools", hy3)
}
}
func TestParseOpenRouterCatalogMapsLiveMetadata(t *testing.T) {
body := []byte(`{
"data": [
{
"id": "anthropic/claude-sonnet-4.5",
"name": "Anthropic: Claude Sonnet 4.5",
"description": "A long marketing blurb that should not win the picker label.",
"context_length": 200000,
"pricing": {
"prompt": "0.000003",
"completion": "0.000015"
},
"architecture": {
"input_modalities": ["text", "image"],
"output_modalities": ["text"]
},
"supported_parameters": ["tools", "tool_choice", "reasoning", "temperature"]
},
{
"id": "vendor/image-only",
"name": "Image Only",
"architecture": {
"input_modalities": ["text"],
"output_modalities": ["image"]
}
},
{
"id": "openai/gpt-4o",
"name": "OpenAI: GPT-4o",
"context_length": 128000,
"supported_parameters": ["tools"]
}
]
}`)
models, err := ParseOpenRouterCatalog(body)
if err != nil {
t.Fatalf("ParseOpenRouterCatalog returned error: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "anthropic/claude-sonnet-4.5,openai/gpt-4o" {
t.Fatalf("models = %#v, want coding OpenRouter models only", got)
}
var claude Model
for _, entry := range models {
if entry.ID == "anthropic/claude-sonnet-4.5" {
claude = entry
break
}
}
if claude.Description != "Anthropic: Claude Sonnet 4.5" {
t.Fatalf("description = %q, want short display name", claude.Description)
}
if claude.ContextWindow != 200000 || !claude.ToolCall || !claude.Reasoning {
t.Fatalf("claude capabilities = %#v, want context/tools/reasoning from live payload", claude)
}
if strings.Join(claude.InputModalities, ",") != "text,image" || strings.Join(claude.OutputModalities, ",") != "text" {
t.Fatalf("claude modalities = %#v/%#v", claude.InputModalities, claude.OutputModalities)
}
// Live pricing.prompt is USD per token; Model costs are USD per million tokens.
if claude.InputCost != 3 || claude.OutputCost != 15 {
t.Fatalf("claude costs = %f/%f, want 3/15 (per-million, matching models.dev)", claude.InputCost, claude.OutputCost)
}
if claude.Source != "openrouter" {
t.Fatalf("source = %q, want openrouter", claude.Source)
}
}
func TestParsePricingString(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"0.000003", 3},
{" 0.000015 ", 15},
{"-1", 0},
{"0", 0},
{"", 0},
{"not-a-number", 0},
{"Inf", 0},
{"+Inf", 0},
{"-Inf", 0},
{"NaN", 0},
}
for _, tc := range cases {
if got := parsePricingString(tc.in); got != tc.want {
t.Fatalf("parsePricingString(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestPublicLiveCatalog(t *testing.T) {
if !PublicLiveCatalog("openrouter") || !PublicLiveCatalog("gitlawb-opengateway") {
t.Fatal("openrouter and gitlawb-opengateway should advertise a public live catalog")
}
if PublicLiveCatalog("openai") {
t.Fatal("openai should not be treated as a public live catalog provider")
if models[1].ID != "tencent/hy3" || models[1].ContextWindow != 262144 || !models[1].ToolCall {
t.Fatalf("gateway HY3 model = %#v, want Tencent HY3 with context/tools", models[1])
}
}
+51 -270
View File
@@ -19,48 +19,34 @@ import (
"github.com/Gitlawb/zero/internal/redaction"
)
const (
anthropicVersion = "2023-06-01"
chatGPTModelsProtocolVersion = "0.146.0"
)
const anthropicVersion = "2023-06-01"
type Model struct {
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
ReasoningEfforts []string
DefaultReasoningEffort string
ServiceTiers []string
DefaultServiceTier string
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
}
type Options struct {
HTTPClient *http.Client
ModelsDevURL string
OpenGatewayURL string
OpenRouterURL string
OAuthResolver providerio.TokenResolver
CodexAccountResolver openai.CodexAccountResolver
UserAgent string
ClientVersion string
}
func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) {
catalogModels, catalogErr := fetchCatalogModels(ctx, provider, options)
// OpenRouter and OpenGateway publish public live model lists. Probe them even
// without credentials so the picker stays current before a key is entered.
canProbeProvider := modelDiscoveryAllowed(profile) && (!provider.RequiresAuth ||
discoveryHasCredential(profile) ||
options.OAuthResolver != nil ||
publicLiveCatalogProvider(provider, profile))
canProbeProvider := modelDiscoveryAllowed(profile) && (!provider.RequiresAuth || discoveryHasCredential(profile))
if canProbeProvider {
liveModels, liveErr := Discover(ctx, profile, options)
if liveErr == nil {
@@ -84,11 +70,6 @@ func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, p
return nil, fmt.Errorf("no provider models discovered")
}
func publicLiveCatalogProvider(provider providercatalog.Descriptor, profile config.ProviderProfile) bool {
return providermodelcatalog.PublicLiveCatalog(provider.ID) ||
providermodelcatalog.PublicLiveCatalog(profile.CatalogID)
}
// discoveryHasCredential reports whether the profile carries a usable credential
// for an authenticated /models probe. A profile may authenticate via a raw
// auth-header value instead of APIKey, so treat either as present — consistent
@@ -213,18 +194,6 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
}
var configure func(*http.Request)
if providercatalog.NormalizeID(profile.CatalogID) == "chatgpt" {
parsed, parseErr := url.Parse(endpoint)
if parseErr != nil {
return nil, fmt.Errorf("parse ChatGPT models endpoint: %w", parseErr)
}
clientVersion := strings.TrimSpace(options.ClientVersion)
if clientVersion == "" {
clientVersion = chatGPTModelsProtocolVersion
}
query := parsed.Query()
query.Set("client_version", clientVersion)
parsed.RawQuery = query.Encode()
endpoint = parsed.String()
configure = func(request *http.Request) {
account := ""
if options.CodexAccountResolver != nil {
@@ -279,8 +248,7 @@ func fetchProviderModels(ctx context.Context, endpoint string, profile config.Pr
}
defer response.Body.Close()
// OpenRouter's full catalog is ~0.5MB today; keep headroom for growth.
body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return nil, redactDiscoveryError(err, profile)
}
@@ -347,39 +315,12 @@ func anthropicModelsEndpoint(baseURL string) (string, error) {
return parsed.String(), nil
}
type modelsResponseItem struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
ContextWindow int `json:"context_window"`
ContextWindowAlt int `json:"contextWindow"`
ContextLength int `json:"context_length"`
MaxContextLength int `json:"max_context_length"`
Free bool `json:"free"`
IsFree bool `json:"is_free"`
ToolCall bool `json:"tool_call"`
ToolCallCamel bool `json:"toolCall"`
Tools bool `json:"tools"`
Reasoning bool `json:"reasoning"`
ReasoningEfforts []string `json:"reasoning_efforts"`
DefaultReasoning string `json:"default_reasoning_level"`
SupportedReasoning []struct {
Effort string `json:"effort"`
} `json:"supported_reasoning_levels"`
ServiceTiers []struct {
ID string `json:"id"`
} `json:"service_tiers"`
AdditionalSpeedTiers []string `json:"additional_speed_tiers"`
DefaultServiceTier string `json:"default_service_tier"`
SupportedParameters []string `json:"supported_parameters"`
}
type modelsResponse struct {
Data []modelsResponseItem `json:"data"`
Models []modelsResponseItem `json:"models"`
Data []struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
} `json:"data"`
}
func parseModelsResponse(body []byte) ([]Model, error) {
@@ -388,58 +329,18 @@ func parseModelsResponse(body []byte) ([]Model, error) {
return nil, fmt.Errorf("decode models response: %w", err)
}
seen := map[string]bool{}
items := payload.Data
if len(items) == 0 {
items = payload.Models
}
models := make([]Model, 0, len(items))
for _, item := range items {
id := firstNonEmptyDiscovery(item.ID, item.Slug)
models := make([]Model, 0, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
if id == "" || seen[id] {
continue
}
// The ChatGPT catalog includes hidden/internal entries alongside picker
// models. Generic OpenAI-compatible responses omit visibility entirely.
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
continue
}
seen[id] = true
description := firstNonEmptyDiscovery(
item.DisplayName,
item.Name,
item.Description,
)
if (item.Free || item.IsFree || strings.HasSuffix(strings.ToLower(id), ":free")) &&
description != "" &&
!strings.Contains(strings.ToLower(description), "free") {
description = description + " (free)"
description := strings.TrimSpace(item.DisplayName)
if description == "" {
description = strings.TrimSpace(item.Name)
}
contextWindow := firstPositiveDiscovery(
item.ContextWindow,
item.ContextWindowAlt,
item.ContextLength,
item.MaxContextLength,
)
toolCall := item.ToolCall || item.ToolCallCamel || item.Tools ||
discoveryContainsFold(item.SupportedParameters, "tools")
reasoning := item.Reasoning ||
discoveryContainsFold(item.SupportedParameters, "reasoning") ||
discoveryContainsFold(item.SupportedParameters, "reasoning_effort") ||
discoveryContainsFold(item.SupportedParameters, "include_reasoning")
efforts := reasoningEfforts(item)
serviceTiers := modelServiceTiers(item)
models = append(models, Model{
ID: id,
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning || len(efforts) > 0,
ReasoningEfforts: efforts,
DefaultReasoningEffort: strings.TrimSpace(item.DefaultReasoning),
ServiceTiers: serviceTiers,
DefaultServiceTier: normalizeServiceTier(item.DefaultServiceTier),
Source: "live",
})
models = append(models, Model{ID: id, Description: description})
}
sort.SliceStable(models, func(i, j int) bool {
return models[i].ID < models[j].ID
@@ -450,56 +351,11 @@ func parseModelsResponse(body []byte) ([]Model, error) {
return models, nil
}
func modelServiceTiers(item modelsResponseItem) []string {
values := append([]string{}, item.AdditionalSpeedTiers...)
for _, tier := range item.ServiceTiers {
values = append(values, tier.ID)
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = normalizeServiceTier(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func normalizeServiceTier(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "fast" {
return "priority"
}
return value
}
func reasoningEfforts(item modelsResponseItem) []string {
efforts := append([]string{}, item.ReasoningEfforts...)
for _, level := range item.SupportedReasoning {
efforts = append(efforts, level.Effort)
}
seen := map[string]bool{}
result := make([]string, 0, len(efforts))
for _, effort := range efforts {
effort = strings.ToLower(strings.TrimSpace(effort))
if effort == "" || seen[effort] {
continue
}
seen[effort] = true
result = append(result, effort)
}
return result
}
func fetchCatalogModels(ctx context.Context, provider providercatalog.Descriptor, options Options) ([]Model, error) {
models, err := providermodelcatalog.FetchRemote(ctx, provider, providermodelcatalog.FetchOptions{
HTTPClient: options.HTTPClient,
ModelsDevURL: options.ModelsDevURL,
OpenGatewayURL: options.OpenGatewayURL,
OpenRouterURL: options.OpenRouterURL,
})
if err != nil {
return nil, err
@@ -511,21 +367,17 @@ func modelsFromCatalog(models []providermodelcatalog.Model) []Model {
result := make([]Model, 0, len(models))
for _, model := range models {
result = append(result, Model{
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
DefaultReasoningEffort: model.DefaultReasoningEffort,
ServiceTiers: append([]string{}, model.ServiceTiers...),
DefaultServiceTier: model.DefaultServiceTier,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
})
}
return result
@@ -537,60 +389,20 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
byID[model.ID] = model
}
hasCatalog := len(byID) > 0
// Aggregators publish the live list as the source of truth. Keep live-only
// ids even when a remote catalog also loaded, instead of intersecting.
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
result := make([]Model, 0, len(liveModels))
for _, live := range liveModels {
if catalog, ok := byID[live.ID]; ok {
if preferLive {
if providermodelcatalog.IsKnownNonCodingModelID(catalog.ID) {
continue
}
} else if !providermodelcatalog.IsCodingModel(catalogModelFromDiscovery(catalog)) {
if !providermodelcatalog.IsCodingModel(catalogModelFromDiscovery(catalog)) {
continue
}
// Prefer catalog metadata (tools, cost) but fill gaps from live.
if catalog.ContextWindow == 0 && live.ContextWindow > 0 {
catalog.ContextWindow = live.ContextWindow
}
if strings.TrimSpace(catalog.Description) == "" && strings.TrimSpace(live.Description) != "" {
catalog.Description = live.Description
}
if len(live.ReasoningEfforts) > 0 {
catalog.ReasoningEfforts = append([]string{}, live.ReasoningEfforts...)
}
if live.DefaultReasoningEffort != "" {
catalog.DefaultReasoningEffort = live.DefaultReasoningEffort
}
if live.Reasoning || len(live.ReasoningEfforts) > 0 || live.DefaultReasoningEffort != "" {
catalog.Reasoning = true
}
if len(live.ServiceTiers) > 0 {
catalog.ServiceTiers = append([]string{}, live.ServiceTiers...)
}
if live.DefaultServiceTier != "" {
catalog.DefaultServiceTier = live.DefaultServiceTier
}
catalog.Source = firstDiscoverySource(catalog.Source, "live")
result = append(result, catalog)
continue
}
if hasCatalog && !preferLive {
if hasCatalog {
continue
}
if preferLive {
if providermodelcatalog.IsKnownNonCodingModelID(live.ID) {
continue
}
// OpenRouter keeps coding-capable live-only models (tools/reasoning
// or coding-like ids). OpenGateway trusts the gateway list.
if providercatalog.NormalizeID(provider.ID) == "openrouter" &&
!providermodelcatalog.IsCodingModel(catalogModelFromDiscovery(live)) {
continue
}
} else if !liveModelAllowedWithoutCatalog(provider, live.ID) {
if !liveModelAllowedWithoutCatalog(provider, live.ID) {
continue
}
live.Source = firstDiscoverySource(live.Source, "live")
@@ -599,33 +411,6 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
return result
}
func discoveryContainsFold(values []string, want string) bool {
for _, value := range values {
if strings.EqualFold(strings.TrimSpace(value), want) {
return true
}
}
return false
}
func firstNonEmptyDiscovery(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func firstPositiveDiscovery(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func liveModelAllowedWithoutCatalog(provider providercatalog.Descriptor, id string) bool {
if !providermodelcatalog.ModelIDAllowedForProvider(provider.ID, id) {
return false
@@ -641,21 +426,17 @@ func liveModelAllowedWithoutCatalog(provider providercatalog.Descriptor, id stri
func catalogModelFromDiscovery(model Model) providermodelcatalog.Model {
return providermodelcatalog.Model{
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
DefaultReasoningEffort: model.DefaultReasoningEffort,
ServiceTiers: append([]string{}, model.ServiceTiers...),
DefaultServiceTier: model.DefaultServiceTier,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
}
}
@@ -52,237 +52,6 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) {
}
}
func TestParseModelsResponseCapturesContextAndFree(t *testing.T) {
models, err := parseModelsResponse([]byte(`{
"data": [
{"id": "xiaomi/mimo-v2.5-pro", "name": "MiMo V2.5-Pro", "context_window": 262144},
{"id": "nvidia/nemotron-3-ultra:free", "name": "Nemotron", "context_length": 128000, "is_free": true}
]
}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if len(models) != 2 {
t.Fatalf("models = %#v, want 2", models)
}
if got, want := modelIDs(models), []string{"nvidia/nemotron-3-ultra:free", "xiaomi/mimo-v2.5-pro"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("models = %#v, want %#v", got, want)
}
byID := map[string]Model{}
for _, model := range models {
byID[model.ID] = model
}
if byID["xiaomi/mimo-v2.5-pro"].ContextWindow != 262144 {
t.Fatalf("mimo context = %d", byID["xiaomi/mimo-v2.5-pro"].ContextWindow)
}
if byID["nvidia/nemotron-3-ultra:free"].Description != "Nemotron (free)" {
t.Fatalf("free model description = %q, want annotated free label", byID["nvidia/nemotron-3-ultra:free"].Description)
}
}
func TestParseModelsResponseSupportsChatGPTCatalog(t *testing.T) {
models, err := parseModelsResponse([]byte(`{
"models": [
{"slug":"gpt-5.6-sol","display_name":"GPT-5.6 Sol","description":"Frontier coding model","visibility":"list","context_window":400000,"default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"}],"service_tiers":[{"id":"priority"}],"default_service_tier":"standard"},
{"slug":"gpt-5.6-terra","display_name":"GPT-5.6 Terra","visibility":"list"},
{"slug":"internal-router","display_name":"Internal","visibility":"hide"},
{"slug":"server-only","display_name":"Server only","visibility":"none"}
]
}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got, want := modelIDs(models), []string{"gpt-5.6-sol", "gpt-5.6-terra"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("models = %#v, want visible ChatGPT models %#v", got, want)
}
if models[0].Description != "GPT-5.6 Sol" || models[0].ContextWindow != 400000 {
t.Fatalf("ChatGPT metadata = %#v", models[0])
}
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,medium,high,xhigh" {
t.Fatalf("reasoning efforts = %q", got)
}
if models[0].DefaultReasoningEffort != "high" || !models[0].Reasoning {
t.Fatalf("reasoning metadata = %#v", models[0])
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" || models[0].DefaultServiceTier != "standard" {
t.Fatalf("service tier metadata = %#v", models[0])
}
}
func TestParseModelsResponseNormalizesLegacyFastTier(t *testing.T) {
models, err := parseModelsResponse([]byte(`{"data":[{"id":"gpt-test","additional_speed_tiers":["fast","priority"]}]}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
t.Fatalf("service tiers = %q, want priority", got)
}
}
func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
[]Model{
{ID: "gpt-5.5", ReasoningEfforts: []string{"low", "high"}, ServiceTiers: []string{"priority"}},
{ID: "gpt-5.6-sol", ReasoningEfforts: []string{"low", "ultra"}},
},
[]Model{{ID: "gpt-5.5", Description: "fallback"}},
)
if got, want := strings.Join(modelIDs(models), ","), "gpt-5.5,gpt-5.6-sol"; got != want {
t.Fatalf("models = %q, want %q", got, want)
}
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,high" {
t.Fatalf("merged reasoning efforts = %q", got)
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
t.Fatalf("merged service tiers = %q", got)
}
}
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
[]Model{{
ID: "gpt-live",
Reasoning: true,
DefaultReasoningEffort: "high",
DefaultServiceTier: "priority",
}},
[]Model{{
ID: "gpt-live",
Reasoning: false,
DefaultReasoningEffort: "low",
DefaultServiceTier: "standard",
}},
)
if len(models) != 1 {
t.Fatalf("merged models = %#v, want one model", models)
}
got := models[0]
if !got.Reasoning || got.DefaultReasoningEffort != "high" || got.DefaultServiceTier != "priority" {
t.Fatalf("merged live metadata = %#v", got)
}
}
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
// Catalog and live endpoints return distinct payloads so the merge must keep
// live-only ids that are absent from the remote catalog response.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/catalog/v1/models":
_, _ = w.Write([]byte(`{"data":[
{"id":"auto","name":"Auto (smart routing)"},
{"id":"xiaomi/mimo-v2.5-pro","name":"MiMo V2.5-Pro","context_window":262144}
]}`))
case "/v1/models":
_, _ = w.Write([]byte(`{"data":[
{"id":"auto","name":"Auto (smart routing)"},
{"id":"xiaomi/mimo-v2.5-pro","name":"MiMo V2.5-Pro","context_window":262144},
{"id":"live-only-model","name":"Live Only","context_window":64000}
]}`))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer server.Close()
provider := providercatalog.Descriptor{
ID: "gitlawb-opengateway",
Transport: providercatalog.TransportOpenAICompat,
DefaultBaseURL: server.URL + "/v1",
RequiresAuth: true,
}
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
CatalogID: "gitlawb-opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
// No API key: public live catalog must still load.
}, Options{
HTTPClient: server.Client(),
OpenGatewayURL: server.URL + "/catalog/v1/models",
})
if err != nil {
t.Fatalf("DiscoverCatalog: %v", err)
}
got := strings.Join(modelIDs(models), ",")
if !strings.Contains(got, "auto") || !strings.Contains(got, "xiaomi/mimo-v2.5-pro") || !strings.Contains(got, "live-only-model") {
t.Fatalf("models = %q, want auto + mimo + live-only", got)
}
for _, model := range models {
if model.ID == "xiaomi/mimo-v2.5-pro" && model.ContextWindow != 262144 {
t.Fatalf("mimo metadata = %#v", model)
}
if model.ID == "live-only-model" && model.ContextWindow != 64000 {
t.Fatalf("live-only metadata = %#v", model)
}
}
}
func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) {
// Catalog omits anthropic/claude-sonnet-4.5 and the generic tools-only id;
// live retains both so preferLive keeps coding-capable live-only entries
// (id heuristic + capability flags). No API key: public listing is unauth.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/catalog/api/v1/models":
_, _ = w.Write([]byte(`{"data":[
{"id":"openai/gpt-4.1","name":"GPT-4.1","context_length":1048576,"supported_parameters":["tools"]},
{"id":"text-embedding-3-large","name":"Embedding"}
]}`))
case "/api/v1/models", "/v1/models":
_, _ = w.Write([]byte(`{"data":[
{"id":"openai/gpt-4.1","name":"GPT-4.1","context_length":1048576,"supported_parameters":["tools"]},
{"id":"anthropic/claude-sonnet-4.5","name":"Claude Sonnet 4.5","context_length":200000,"supported_parameters":["tools","reasoning"]},
{"id":"vendor/generic-tools-model","name":"Generic Tools","context_length":32000,"supported_parameters":["tools"]},
{"id":"text-embedding-3-large","name":"Embedding"}
]}`))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer server.Close()
provider := providercatalog.Descriptor{
ID: "openrouter",
Transport: providercatalog.TransportOpenAICompat,
DefaultBaseURL: server.URL + "/api/v1",
RequiresAuth: true,
}
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
CatalogID: "openrouter",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/api/v1",
// No API key: unauthenticated public listing path.
}, Options{
HTTPClient: server.Client(),
OpenRouterURL: server.URL + "/catalog/api/v1/models",
})
if err != nil {
t.Fatalf("DiscoverCatalog: %v", err)
}
got := strings.Join(modelIDs(models), ",")
if !strings.Contains(got, "openai/gpt-4.1") || !strings.Contains(got, "anthropic/claude-sonnet-4.5") {
t.Fatalf("models = %q, want live openrouter coding models including live-only claude", got)
}
if !strings.Contains(got, "vendor/generic-tools-model") {
t.Fatalf("models = %q, want capability-marked live-only generic tools model", got)
}
if strings.Contains(got, "text-embedding-3-large") {
t.Fatalf("models = %q, embedding model should be filtered", got)
}
for _, model := range models {
if model.ID == "openai/gpt-4.1" && model.ContextWindow != 1048576 {
t.Fatalf("gpt-4.1 metadata = %#v, want live context window", model)
}
if model.ID == "anthropic/claude-sonnet-4.5" && model.ContextWindow != 200000 {
t.Fatalf("claude live-only metadata = %#v, want live context window", model)
}
if model.ID == "vendor/generic-tools-model" && (!model.ToolCall || model.ContextWindow != 32000) {
t.Fatalf("generic tools live-only = %#v, want tools + context from live payload", model)
}
}
}
func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
var requests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -290,9 +59,6 @@ func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
if got := r.URL.Path; got != "/backend-api/codex/models" {
t.Errorf("path = %q, want Codex models endpoint", got)
}
if got := r.URL.Query().Get("client_version"); got != chatGPTModelsProtocolVersion {
t.Errorf("client_version = %q, want %q", got, chatGPTModelsProtocolVersion)
}
wantToken := "Bearer old-token"
wantAccount := "old-account"
if requests == 2 {
-56
View File
@@ -1,56 +0,0 @@
package providers
import (
"context"
"errors"
"net/http"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// OAuthLoginForProfile resolves the OAuth login used by profile once and
// returns both its bearer resolver and credential-store key. Keeping the key
// alongside the resolver lets auxiliary requests derive account-scoped headers
// from the same login that supplies the bearer.
func OAuthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil, ""
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
return nil, ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AllowPresets: true,
})
if err != nil {
return nil, ""
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var resolveErr error
if forceRefresh {
token, resolveErr = manager.Handle401(ctx, key)
} else {
token, resolveErr = manager.GetFresh(ctx, key)
}
if errors.Is(resolveErr, oauth.ErrNoToken) {
return "", "", false, nil
}
if resolveErr != nil {
return "", "", false, resolveErr
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
}
-68
View File
@@ -1,68 +0,0 @@
package providers
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestOAuthLoginForProfileBindsBearerAndAccountToSameLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
key := oauth.ProviderKey("chatgpt")
if err := store.Save(key, oauth.Token{
AccessToken: "subscription-token",
Account: "account-42",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("save OAuth token: %v", err)
}
resolver, loginKey := OAuthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"})
if resolver == nil || loginKey != key {
t.Fatalf("OAuth login = (%v, %q), want resolver bound to %q", resolver != nil, loginKey, key)
}
header, value, ok, err := resolver(context.Background(), false)
if err != nil || !ok || header != "Authorization" || value != "Bearer subscription-token" {
t.Fatalf("bearer resolution = (%q, %q, %v, %v)", header, value, ok, err)
}
account, ok, err := CodexAccountResolverForLogin(loginKey)(context.Background())
if err != nil || !ok || account != "account-42" {
t.Fatalf("account resolution = (%q, %v, %v)", account, ok, err)
}
}
func TestOAuthLoginForProfileReturnsNoResolverWithoutUsableLogin(t *testing.T) {
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(t.TempDir(), "oauth-tokens.json"))
for _, test := range []struct {
name string
profile config.ProviderProfile
}{
{
name: "profile with its own credential has no OAuth candidates",
profile: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKey: "sk-configured"},
},
{
name: "candidate has no stored login",
profile: config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
},
} {
t.Run(test.name, func(t *testing.T) {
resolver, key := OAuthLoginForProfile(test.profile)
if resolver != nil || key != "" {
t.Fatalf("OAuthLoginForProfile() = (%v, %q), want (nil, empty)", resolver != nil, key)
}
})
}
}
@@ -80,7 +80,6 @@ type responsesRequest struct {
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
}
// responsesReasoning carries the reasoning controls for the Responses API. The
@@ -266,9 +265,6 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
// long thinking phase shows live progress instead of looking hung.
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
}
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
req.ServiceTier = tier
}
return req, nil
}
-43
View File
@@ -492,49 +492,6 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
}
}
func TestCodexProviderNormalizesServiceTier(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{name: "priority", input: "priority", want: "priority"},
{name: "flex", input: "flex", want: "flex"},
{name: "unsupported omitted", input: "standard", want: ""},
} {
t.Run(test.name, func(t *testing.T) {
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ServiceTier: test.input,
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drainCodexEvents(t, stream)
if test.want == "" {
if _, ok := rec.body["service_tier"]; ok {
t.Fatalf("body.service_tier = %#v, want omitted", rec.body["service_tier"])
}
return
}
if got := rec.body["service_tier"]; got != test.want {
t.Fatalf("body.service_tier = %#v, want %q", got, test.want)
}
})
}
}
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
// "thinking"), in order, alongside the normal text output. Without this a long
+1 -13
View File
@@ -467,9 +467,6 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
mapped.ReasoningEffort = effort
}
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
mapped.ServiceTier = tier
}
// prompt_cache_key is a documented OpenAI parameter for server-side prefix
// cache routing. Official OpenAI accepts it; many openai-compatible
// gateways (NVIDIA NIM, strict local proxies) reject unknown fields with a
@@ -508,16 +505,7 @@ func promptCacheKeyDisabled() bool {
// is dropped rather than risking a 400 on an unrecognized enum.
func openAIReasoningEffort(requested string) string {
switch strings.ToLower(strings.TrimSpace(requested)) {
case "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
return strings.ToLower(strings.TrimSpace(requested))
default:
return ""
}
}
func openAIServiceTier(requested string) string {
switch strings.ToLower(strings.TrimSpace(requested)) {
case "priority", "flex":
case "minimal", "low", "medium", "high":
return strings.ToLower(strings.TrimSpace(requested))
default:
return ""
@@ -860,51 +860,6 @@ func TestStreamCompletionSendsReasoningEffort(t *testing.T) {
}
}
func TestStreamCompletionNormalizesServiceTier(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{name: "priority", input: "priority", want: "priority"},
{name: "flex with surrounding whitespace", input: " FlEx ", want: "flex"},
{name: "unsupported omitted", input: "standard", want: ""},
} {
t.Run(test.name, func(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)
}
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "gpt-test"})
if err != nil {
t.Fatalf("New: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ServiceTier: test.input,
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drain(stream)
if test.want == "" {
if _, ok := gotBody["service_tier"]; ok {
t.Fatalf("service_tier = %#v, want omitted", gotBody["service_tier"])
}
return
}
if got := gotBody["service_tier"]; got != test.want {
t.Fatalf("service_tier = %#v, want %q", got, test.want)
}
})
}
}
func TestStreamCompletionOmitsReasoningEffortWhenUnsetOrInvalid(t *testing.T) {
for _, effort := range []string{"", "none", "bogus"} {
var gotBody map[string]any
@@ -1,34 +0,0 @@
package openai
import "testing"
func TestOpenAIReasoningEffortPreservesKnownProviderTiers(t *testing.T) {
for _, effort := range []string{"minimal", "low", "medium", "high", "xhigh", "max", "ultra"} {
if got := openAIReasoningEffort(effort); got != effort {
t.Fatalf("openAIReasoningEffort(%q) = %q", effort, got)
}
}
if got := openAIReasoningEffort("unknown"); got != "" {
t.Fatalf("unknown effort = %q, want omitted", got)
}
}
func TestOpenAIServiceTierNormalizesKnownValues(t *testing.T) {
for _, test := range []struct {
input string
want string
}{
{input: "priority", want: "priority"},
{input: "flex", want: "flex"},
{input: " PRIORITY ", want: "priority"},
{input: "FlEx", want: "flex"},
{input: "", want: ""},
{input: "standard", want: ""},
} {
t.Run(test.input, func(t *testing.T) {
if got := openAIServiceTier(test.input); got != test.want {
t.Fatalf("openAIServiceTier(%q) = %q, want %q", test.input, got, test.want)
}
})
}
}
+2 -4
View File
@@ -153,8 +153,8 @@ func (s *turnSession) observePrefix(ctx context.Context, request zeroruntime.Com
// computeFingerprint digests the wire-affecting request parameters: the
// session's model and max-tokens (fixed at construction — CompletionRequest
// carries no model field), the normalized reasoning effort as it would appear
// on the wire, the normalized service tier, the prompt-cache key (constant
// within a session, and part of the request the provider serializes), and an order-preserving digest of the
// on the wire, the prompt-cache key (constant within a session, and part of
// the request the provider serializes), and an order-preserving digest of the
// advertised tools (request serialization preserves tool order, so a reorder
// changes the wire bytes and counts as drift). Messages are excluded — they
// grow every turn; this fingerprints request parameters, not conversation
@@ -175,8 +175,6 @@ func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest)
builder.WriteByte('|')
builder.WriteString(openAIReasoningEffort(request.ReasoningEffort))
builder.WriteByte('|')
builder.WriteString(openAIServiceTier(request.ServiceTier))
builder.WriteByte('|')
builder.WriteString(request.PromptCacheKey)
builder.WriteByte('|')
builder.WriteString(wireToolsDigest(request.Tools))
+1 -8
View File
@@ -231,18 +231,11 @@ func TestTurnSessionFingerprintDriftOnToolsAndEffort(t *testing.T) {
// Unrecognized efforts normalize to omitted — same as empty.
droppedEffort := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
ReasoningEffort: "bogus",
ReasoningEffort: "xhigh",
})
if base != droppedEffort {
t.Fatal("an effort the wire would omit must fingerprint like no effort")
}
changedTier := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
ServiceTier: "priority",
})
if base == changedTier {
t.Fatal("fingerprint did not drift on a changed service tier")
}
}
func TestTurnSessionFingerprintDriftOnToolDescription(t *testing.T) {
-1
View File
@@ -6,7 +6,6 @@ type chatCompletionRequest struct {
Tools []toolDefinition `json:"tools,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
// PromptCacheKey asks the backend to route the request to a replica that
-124
View File
@@ -61,19 +61,6 @@ func TestRedactString_CompoundKeyForms(t *testing.T) {
}
}
func TestRedactString_OverlappingExtraSecrets(t *testing.T) {
opts := Options{
ExtraSecretValues: []string{"secret", "secret_token"},
}
out := RedactString("my secret_token value", opts)
if strings.Contains(out, "_token") {
t.Fatalf("RedactString left partial fragment '_token': got %q", out)
}
if !strings.Contains(out, RedactedSecret) {
t.Fatalf("expected RedactedSecret in output: got %q", out)
}
}
func TestRedactString_AuthHeaderSchemes(t *testing.T) {
o := Options{}
// Opaque values (no token-format prefix) so only the header/colon logic can
@@ -148,117 +135,6 @@ func TestRedactString_PreservesFileLineColons(t *testing.T) {
}
}
func TestRedactString_OpenAIKeyDigitFilterAndVendorPrefixes(t *testing.T) {
o := Options{}
// Vendor / fixture shapes that the enumerated-prefix approach missed.
for _, secret := range []string{
"sk-test-secret1234567890",
"sk-fw-1SENTINEL_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sk-live-1SENTINELaaaaaaaaaa_bbbbbbbbbb-cccc",
} {
out := RedactString("approved with "+secret, o)
if strings.Contains(out, secret) {
t.Errorf("RedactString leaked %q: %q", secret, out)
}
}
// Known OpenAI prefixes redact even with an alphabet-only body.
for _, secret := range []string{
"sk-proj-abcdefghijklmnopqrstuvwxyz",
"sk-svcacct-abcdefghijklmnopqrstuvwx",
"sk-admin-abcdefghijklmnopqrstuvwxyz",
} {
out := RedactString("token="+secret, o)
if strings.Contains(out, secret) {
t.Errorf("alphabet-only known OpenAI form leaked %q: %q", secret, out)
}
}
// No-digit kebab phrase must survive (parity with secrets.Scan).
phrase := "sk-learn-machine-learning-model"
out := RedactString("testing "+phrase+" in text", o)
if !strings.Contains(out, phrase) {
t.Fatalf("digit filter over-redacted kebab phrase: %q", out)
}
}
func TestRedactString_LooseJWTForms(t *testing.T) {
o := Options{}
for _, token := range []string{
"eyJhbGciOiJIUzI1NiJ9.U0VOVElORUxwYXlsb2Fk.SENTINELsignature123",
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.SENTINELencryptedkey.SENTINELiv",
} {
out := RedactString("auth="+token, o)
if strings.Contains(out, "eyJhbGci") {
t.Errorf("RedactString leaked jwt material from %q: %q", token, out)
}
}
}
// Patterns whose body class allows "-" must redact a secret that ends in "-"
// right before a delimiter: a trailing \b anchor would force the engine to
// drop that last character and leave the hyphen visible (parity with
// secrets.Scan).
func TestRedactString_TrailingHyphenWithoutTailLeak(t *testing.T) {
o := Options{}
cases := []string{
"xoxb-1234567890-abcdefghi-",
"AIzaSyA1234567890abcdefghijklmnopqrstu-",
"sk-proj-abcDEF123_ghiJKL456-mnoPQR789st-",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fw-",
}
for _, secret := range cases {
in := "secret is " + secret + " end"
out := RedactString(in, o)
if strings.Contains(out, secret) {
t.Errorf("RedactString leaked trailing-hyphen secret %q: got %q", secret, out)
}
if strings.Contains(out, "- end") || strings.Contains(out, "-] end") {
t.Errorf("trailing hyphen leaked for %q: %q", secret, out)
}
want := "secret is " + RedactedSecret + " end"
if out != want {
t.Errorf("RedactString(%q) = %q, want %q", in, out, want)
}
}
}
// A credential with more word characters appended right after its body must
// still have its real secret material redacted; the appended run itself is
// outside the body class and stays. A trailing \b would fail the whole match
// (parity with secrets.Scan).
func TestRedactString_CredentialWithAppendedSuffix(t *testing.T) {
o := Options{}
cases := []struct {
secret string
suffix string
}{
{"AKIAIOSFODNN7EXAMPLE", "EXTRA"},
{"ghp_1234567890abcdefghijklmnopqrstuvwxyz", "_suffix"},
}
for _, tc := range cases {
in := "token=" + tc.secret + tc.suffix + " end"
out := RedactString(in, o)
if strings.Contains(out, tc.secret) {
t.Errorf("RedactString leaked credential %q: got %q", tc.secret, out)
}
// Bare "token" is a sensitive key, so the assignment/query path may
// redact the whole value including the suffix. Either outcome is fine
// as long as the credential itself is gone and a marker is present.
if !strings.Contains(out, RedactedSecret) {
t.Errorf("expected redaction marker for %q: got %q", in, out)
}
}
// Standalone (no sensitive-key assignment) so only textSecretPatterns run.
in := "aws key AKIAIOSFODNN7EXAMPLEEXTRA tail"
out := RedactString(in, o)
if strings.Contains(out, "AKIAIOSFODNN7EXAMPLE") {
t.Fatalf("appended-suffix AWS key leaked: %q", out)
}
want := "aws key " + RedactedSecret + "EXTRA tail"
if out != want {
t.Fatalf("RedactString(%q) = %q, want %q", in, out, want)
}
}
func TestRedactValue_CompoundKeys(t *testing.T) {
o := Options{}
in := map[string]any{
+12 -62
View File
@@ -6,7 +6,6 @@ import (
"net/url"
"reflect"
"regexp"
"sort"
"strings"
"unicode"
)
@@ -68,32 +67,16 @@ var sensitiveKeys = map[string]struct{}{
"zero_api_key": {},
}
// openaiKeyPattern mirrors secrets.Scan's broad sk- body. Known OpenAI
// prefixes (sk-proj-/sk-svcacct-/sk-admin-) are always redacted; other sk-
// digit-free matches with an interior hyphen are left alone (kebab-case false
// positives), while digit-free legacy sk- credentials are still redacted.
// Applied via ReplaceAllStringFunc rather than the plain list below.
var openaiKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`)
// textSecretPatterns mirror secrets.Scan for end-boundary behavior and the
// shared high-confidence shapes. A leading \b keeps each pattern from firing
// mid-word; a trailing \b is omitted so a secret followed by more word
// characters outside its body class (e.g. AKIA…EXAMPLEEXTRA) still matches,
// and a secret that ends in "-" (allowed by some body classes) is fully
// redacted rather than leaving the hyphen behind. glpat is redaction-only
// (not in secrets.Scan); ASIA temporary access keys are kept alongside AKIA.
// openai keys are handled separately (digit filter). JWT has a strict form
// (both segments start with eyJ) and a looser three-segment form.
var textSecretPatterns = []*regexp.Regexp{
regexp.MustCompile(`\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}`),
regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`),
regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}`),
regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{12,}`),
regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`),
regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`),
regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}`),
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`),
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`),
regexp.MustCompile(`\bsk-(?:proj-)?[A-Za-z0-9._-]{12,}\b`),
regexp.MustCompile(`\bsk-ant-api\d{2}-[A-Za-z0-9._-]{12,}\b`),
regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{12,}\b`),
regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9_]{12,}\b`),
regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{12,}\b`),
regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{12,}\b`),
regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{12,}\b`),
regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}\b`),
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
}
var (
@@ -173,15 +156,9 @@ func keyLooksSensitive(normalized string) bool {
func RedactString(value string, options Options) string {
replacement := replacement(options)
redacted := value
if len(options.ExtraSecretValues) > 0 {
secrets := append([]string{}, options.ExtraSecretValues...)
sort.SliceStable(secrets, func(i, j int) bool {
return len(secrets[i]) > len(secrets[j])
})
for _, secret := range secrets {
if strings.TrimSpace(secret) != "" {
redacted = strings.ReplaceAll(redacted, secret, replacement)
}
for _, secret := range options.ExtraSecretValues {
if strings.TrimSpace(secret) != "" {
redacted = strings.ReplaceAll(redacted, secret, replacement)
}
}
@@ -224,39 +201,12 @@ func RedactString(value string, options Options) string {
}
return parts[1] + parts[2] + "=" + replacement
})
// openai keys first so the filter can drop kebab-case false positives
// before any other pattern rewrites nearby text.
redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string {
if !knownOpenAIKeyPrefix(match) && !secretMatchHasDigit(match) &&
strings.Contains(strings.TrimPrefix(match, "sk-"), "-") {
return match
}
return replacement
})
for _, pattern := range textSecretPatterns {
redacted = pattern.ReplaceAllString(redacted, replacement)
}
return redacted
}
// knownOpenAIKeyPrefix is the redaction-side twin of secrets.knownOpenAIKeyPrefix:
// known OpenAI-issued forms redact even with an alphabet-only body.
func knownOpenAIKeyPrefix(match string) bool {
return strings.HasPrefix(match, "sk-proj-") ||
strings.HasPrefix(match, "sk-svcacct-") ||
strings.HasPrefix(match, "sk-admin-")
}
// secretMatchHasDigit is the redaction-side twin of secrets.containsDigit.
func secretMatchHasDigit(s string) bool {
for _, r := range s {
if r >= '0' && r <= '9' {
return true
}
}
return false
}
func RedactValue(value any, options Options) any {
return redactReflect(reflect.ValueOf(value), redactionContext{
options: options,
-7
View File
@@ -131,13 +131,6 @@ func unavailableBackend(goos string, message string) Backend {
}
}
// BuildPlan renders the plan for introspection (`zero sandbox policy`). It has
// no command to describe, so the profile is built with no command context: the
// credential baseline resolves relative token overrides against THIS process's
// environment and working directory only. Execution goes through
// BuildCommandPlan instead, which passes the command's own dir and env and can
// therefore produce additional deny-if-exists entries. Callers exporting this
// plan should say which of the two they are showing.
func (backend Backend) BuildPlan(workspaceRoot string, policy Policy) BackendPlan {
effectivePolicy := policy
if effectivePolicy.Mode == "" {
+1 -21
View File
@@ -2,7 +2,6 @@
package sandbox
import (
"os"
"path/filepath"
"strings"
)
@@ -11,20 +10,6 @@ func FormatGrantList(grants []Grant) string {
return FormatGrantListWithCommandPrefixes(grants, nil)
}
// PinProcessCredentialBaseDir re-pins the directory relative credential
// overrides resolve against, and restores it when the test ends.
//
// Production pins this once during package initialization, which is what makes
// it equal the directory the token stores resolved against when they opened. A
// test that chdirs to simulate a process running elsewhere has to move the pin
// with it — re-reading the working directory per call is exactly the drift this
// seam exists to keep out of the production path.
func PinProcessCredentialBaseDir(t interface{ Cleanup(func()) }, dir string) {
previous := processCredentialBaseDir
processCredentialBaseDir = filepath.Clean(dir)
t.Cleanup(func() { processCredentialBaseDir = previous })
}
func DefaultPermissionProfile(workspaceRoot string) PermissionProfile {
return PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil)
}
@@ -73,12 +58,7 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy)
fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root})
}
}
fs.DenyRead = normalizeProfilePaths(policy.DenyRead)
credentials := finalizeCredentialDenyPaths(credentialDenyReadPaths(policy, "", os.Environ(), nil), fs.DenyRead)
fs.DenyReadIfExists = credentials.Paths
fs.DenyReadCarveouts = credentials.Carveouts
fs.EnsureDenyReadDirs = credentials.EnsureDirs
fs.ProcessTrustedDenyReadFiles = credentials.ProcessTrustedFinalFiles
fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...))
fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite)
return PermissionProfile{
FileSystem: fs,
@@ -1,99 +0,0 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
)
// These two tests come from #816. That change landed against the single-home
// credentialDenyReadPathsForEnvironment helper, which this branch replaced with
// the multi-home credentialDenyReadPathsIn; they are ported rather than merged
// so the coverage survives the refactor. They live in an internal test file
// because profile_test.go is an external (sandbox_test) package and these need
// the unexported builder.
// git's credential store holds host passwords and personal access tokens in
// cleartext, in one of two locations depending on whether the user is on the
// XDG layout. Neither was denied, so a sandboxed command could read them
// (#815).
//
// Scoped to the credential files on purpose. Denying ~/.ssh as well would stop
// a sandboxed git push over SSH from working, which is a functional trade that
// issue tracks separately; these two cost nothing, because git reads them for
// authentication rather than identity.
func TestCredentialDenyReadPathsCoversGitCredentialStores(t *testing.T) {
home := t.TempDir()
configHome := filepath.Join(home, ".config")
if err := os.MkdirAll(filepath.Join(configHome, "git"), 0o700); err != nil {
t.Fatal(err)
}
gitCredentials := filepath.Join(home, ".git-credentials")
xdgCredentials := filepath.Join(configHome, "git", "credentials")
gitConfig := filepath.Join(configHome, "git", "config")
for _, path := range []string{gitCredentials, xdgCredentials, gitConfig} {
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
denied := credentialDenyReadPathsIn(credentialPathOptions{
Homes: []string{home},
ConfigDirs: []string{configHome},
}, nil).Paths
covered := func(target string) bool {
norm := normalizeProfilePath(target)
for _, entry := range denied {
if entry == norm || pathWithinRoot(entry, norm) {
return true
}
}
return false
}
if !covered(gitCredentials) {
t.Fatalf("~/.git-credentials is readable; deny list = %v", denied)
}
if !covered(xdgCredentials) {
t.Fatalf("~/.config/git/credentials is readable; deny list = %v", denied)
}
// The global git config must stay readable. userGitConfigReadPaths grants it
// deliberately so a sandboxed git can read user.name and aliases rather than
// failing with "unable to access", so denying the directory instead of the
// credential file would break that on purpose-built setups.
if covered(gitConfig) {
t.Fatalf("~/.config/git/config was denied; a sandboxed git loses its identity config")
}
}
// An explicit read grant still wins, matching how every other entry behaves: a
// user who deliberately scopes a workspace over one of these paths is not
// overridden by the default deny.
//
// Asserted from both sides on purpose. Checking only that the path is absent
// under an allowRead is satisfied just as well by a deny rule that was never
// added, so the run without allowRead has to establish that there is something
// there for the grant to override.
func TestGitCredentialDenyYieldsToExplicitAllowRead(t *testing.T) {
home := t.TempDir()
gitCredentials := filepath.Join(home, ".git-credentials")
if err := os.WriteFile(gitCredentials, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
target := normalizeProfilePath(gitCredentials)
listed := func(entries []string) bool {
for _, entry := range entries {
if entry == target {
return true
}
}
return false
}
if !listed(credentialDenyReadPathsIn(credentialPathOptions{Homes: []string{home}}, nil).Paths) {
t.Fatalf("~/.git-credentials is not denied without an allowRead; nothing for the grant to override")
}
if listed(credentialDenyReadPathsIn(credentialPathOptions{Homes: []string{home}}, []string{home}).Paths) {
t.Fatalf("explicit allowRead %q did not re-include the credential store", home)
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ func validateLandlockProfile(profile PermissionProfile) error {
if fs.Kind != FileSystemRestricted {
return nil
}
if len(fs.DenyRead) > 0 || len(fs.DenyReadIfExists) > 0 {
if len(fs.DenyRead) > 0 {
return errors.New("deny-read paths require the bubblewrap helper mode")
}
if len(fs.DenyWrite) > 0 {
+4 -87
View File
@@ -161,9 +161,6 @@ func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxB
if config.UseLandlock {
return linuxSandboxBwrapPlan{}, errors.New("linux landlock helper mode is not implemented yet")
}
if err := validateLinuxBwrapPermissionProfile(config.PermissionProfile); err != nil {
return linuxSandboxBwrapPlan{}, err
}
helperPath := strings.TrimSpace(options.HelperPath)
if helperPath == "" {
return linuxSandboxBwrapPlan{}, errors.New("linux sandbox helper path is required")
@@ -220,30 +217,6 @@ func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxB
}, nil
}
func validateLinuxBwrapPermissionProfile(profile PermissionProfile) error {
if files := profile.FileSystem.ProcessTrustedDenyReadFiles; len(files) > 0 {
return fmt.Errorf("bubblewrap cannot securely deny credential files outside the Zero config directory across atomic replacement: %s; move the store under $XDG_CONFIG_HOME/zero or add its path to sandbox allowRead", strings.Join(files, ", "))
}
// The same limitation, for a token store named by the command's own
// environment. A /dev/null bind over the pathname is detached by the store's
// next atomic rename, and an absent one is skipped entirely — so plaintext
// published during the run stays readable. Refusing the command is the only
// honest answer bubblewrap can give; a pathname-policy backend enforces
// these without help. The path is not created or mutated to make the mask
// work: doing that for a command-supplied value would let a command steer
// Zero into creating host directories.
if files := profile.FileSystem.CommandDenyReadFinalFiles; len(files) > 0 {
return fmt.Errorf("bubblewrap cannot securely deny command-supplied credential files outside the Zero config directory across atomic replacement: %s; move the store under $XDG_CONFIG_HOME/zero or add its path to sandbox allowRead", strings.Join(files, ", "))
}
for _, dir := range profile.FileSystem.CommandDenyReadDirs {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return fmt.Errorf("bubblewrap cannot securely deny command-supplied credential directory created after launch: %s; create the directory before running the command, move it outside the command environment, or add its path to sandbox allowRead", dir)
}
}
return nil
}
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return buildLinuxBwrapFilesystemPlan(profile).Args
}
@@ -304,24 +277,7 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst
args = appendReadOnlyLinuxPathArgs(args, path)
}
for _, path := range fs.DenyRead {
args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts)
}
// The profile includes only trusted, process-environment-derived directories
// here. Command-controlled credential roots remain deny-if-present and must
// never cause host filesystem mutations before sandbox launch.
ensureLinuxDenyReadDirs(fs.EnsureDenyReadDirs)
for _, path := range fs.DenyReadIfExists {
if !pathExists(path) {
// A baseline credential path is emitted for every run, so an absent
// entry is the common case on a fresh machine — a third-party store
// such as ~/.aws that Zero must not create. The read-all profile starts
// from a read-only host-root bind where bubblewrap cannot create a
// missing mount destination, and masking the nearest existing parent
// could hide HOME, /tmp, or the workspace. Path-based backends
// (seatbelt) still deny these paths before they exist.
continue
}
args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts)
args = appendUnreadableLinuxPathArgs(args, path)
}
return linuxBwrapFilesystemPlan{
Args: args,
@@ -397,54 +353,15 @@ func appendReadOnlyLinuxPathArgs(args []string, path string) []string {
return append(args, "--perms", "555", "--tmpfs", path, "--remount-ro", path)
}
func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string {
path = normalizeProfilePath(path)
func appendUnreadableLinuxPathArgs(args []string, path string) []string {
path = strings.TrimSpace(path)
if path == "" {
return args
}
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return append(args, "--ro-bind", "/dev/null", path)
}
nested := nestedCarveoutPaths(path, carveouts)
if len(nested) == 0 {
return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path)
}
// A carveout has to stay reachable, and traversing into a directory needs the
// execute bit, so the mask is 111 (--x--x--x) instead of 000: the directory's
// contents remain unlistable and unreadable, while an explicitly re-bound
// subpath below it can still be resolved. The binds must precede the
// --remount-ro, which is what freezes the tmpfs.
args = append(args, "--perms", "111", "--tmpfs", path)
for _, carveout := range nested {
if info, err := os.Lstat(carveout); err == nil && info.IsDir() {
args = append(args, "--ro-bind", carveout, carveout)
}
}
return append(args, "--remount-ro", path)
}
// nestedCarveoutPaths returns the carveouts that sit strictly inside root,
// shallowest first so a parent bind is created before a nested one.
func nestedCarveoutPaths(root string, carveouts []string) []string {
if len(carveouts) == 0 {
return nil
}
out := credentialCarveoutPaths([]string{root}, carveouts)
sort.SliceStable(out, func(i, j int) bool { return pathDepth(out[i]) < pathDepth(out[j]) })
return dedupeStrings(out)
}
// ensureLinuxDenyReadDirs creates trusted Zero-process directories a deny mask
// needs to exist for. Best effort: a failure leaves the path unmasked and never
// blocks the command.
func ensureLinuxDenyReadDirs(dirs []string) {
for _, dir := range dirs {
dir = strings.TrimSpace(dir)
if dir == "" || pathExists(dir) {
continue
}
_ = os.MkdirAll(dir, 0o700)
}
return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path)
}
func shouldUnshareLinuxNetwork(policy NetworkPolicy) bool {
-140
View File
@@ -2,7 +2,6 @@ package sandbox
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
@@ -266,145 +265,6 @@ func TestLinuxBwrapFilesystemPlanPreservesMissingProtectedMetadata(t *testing.T)
}
}
func TestLinuxBwrapSkipsMissingCredentialBaselines(t *testing.T) {
root := t.TempDir()
missingCredential := filepath.Join(root, "home", ".config", "zero")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: root}},
DenyReadIfExists: []string{missingCredential},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
plan := buildLinuxBwrapFilesystemPlan(profile)
if stringSliceContains(plan.Args, missingCredential) {
t.Fatalf("absent credential baseline must not become a mount target: %#v", plan.Args)
}
if stringSliceContains(plan.ProtectedCreateTargets, missingCredential) {
t.Fatalf("credential baselines are not workspace metadata create targets: %#v", plan.ProtectedCreateTargets)
}
if _, err := os.Stat(filepath.Dir(missingCredential)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("building the plan materialized a host path: %v", err)
}
if err := os.MkdirAll(missingCredential, 0o700); err != nil {
t.Fatalf("MkdirAll credential dir: %v", err)
}
plan = buildLinuxBwrapFilesystemPlan(profile)
normalizedCredential := normalizeProfilePath(missingCredential)
assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", normalizedCredential, "--remount-ro", normalizedCredential)
}
// TestLinuxBwrapCreatesOwnedCredentialDirsBeforeMasking covers the long-lived
// session race: bubblewrap cannot mount over a path that does not exist, so a
// store written after the namespace was assembled would stay readable through
// the live read-only host-root bind. Zero's own directories are therefore
// created up front and masked, unlike third-party stores it must not create.
func TestLinuxBwrapCreatesOwnedCredentialDirsBeforeMasking(t *testing.T) {
root := t.TempDir()
ownedDir := filepath.Join(root, "config", "zero")
thirdParty := filepath.Join(root, "home", ".aws")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: root}},
DenyReadIfExists: []string{ownedDir, thirdParty},
EnsureDenyReadDirs: []string{ownedDir},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
plan := buildLinuxBwrapFilesystemPlan(profile)
if info, err := os.Stat(ownedDir); err != nil || !info.IsDir() {
t.Fatalf("owned credential dir was not created: err=%v", err)
}
normalizedOwnedDir := normalizeProfilePath(ownedDir)
assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", normalizedOwnedDir, "--remount-ro", normalizedOwnedDir)
if stringSliceContains(plan.Args, thirdParty) {
t.Fatalf("absent third-party store must stay unmounted and uncreated: %#v", plan.Args)
}
if _, err := os.Stat(thirdParty); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("third-party store must not be created by the sandbox: %v", err)
}
}
// TestLinuxBwrapKeepsCarveoutsReachableInsideMaskedDir covers the user plugin
// root inside the denied Zero config directory: the mask has to keep the
// traverse bit and re-bind the carveout, otherwise an installed user plugin
// cannot be executed through the sandbox at all.
func TestLinuxBwrapKeepsCarveoutsReachableInsideMaskedDir(t *testing.T) {
root := t.TempDir()
credentialDir := filepath.Join(root, "config", "zero")
pluginRoot := filepath.Join(credentialDir, "plugins")
if err := os.MkdirAll(pluginRoot, 0o700); err != nil {
t.Fatal(err)
}
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: root}},
DenyReadIfExists: []string{credentialDir},
DenyReadCarveouts: []string{pluginRoot},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
plan := buildLinuxBwrapFilesystemPlan(profile)
normalizedCredentialDir := normalizeProfilePath(credentialDir)
normalizedPluginRoot := normalizeCredentialCarveoutPath(pluginRoot)
// 111 rather than 000: a 000 directory cannot be traversed, so the re-bound
// subpath below it would be unreachable. Contents stay unlistable either way.
assertArgsContainSequence(t, plan.Args, "--perms", "111", "--tmpfs", normalizedCredentialDir)
assertArgsContainSequence(t, plan.Args, "--ro-bind", normalizedPluginRoot, normalizedPluginRoot)
assertArgsContainSequence(t, plan.Args, "--remount-ro", normalizedCredentialDir)
bindIdx := argsSequenceIndex(plan.Args, "--ro-bind", normalizedPluginRoot, normalizedPluginRoot)
remountIdx := argsSequenceIndex(plan.Args, "--remount-ro", normalizedCredentialDir)
if bindIdx < 0 || remountIdx < 0 || bindIdx > remountIdx {
t.Fatalf("carveout bind (%d) must precede the tmpfs remount-ro (%d): %#v", bindIdx, remountIdx, plan.Args)
}
}
func TestLinuxBwrapDoesNotBindSymlinkCarveout(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is not reliably available on Windows CI")
}
root := t.TempDir()
credentialDir := filepath.Join(root, "config", "zero")
if err := os.MkdirAll(credentialDir, 0o700); err != nil {
t.Fatal(err)
}
secret := filepath.Join(credentialDir, "oauth-tokens.json")
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
pluginRoot := filepath.Join(credentialDir, "plugins")
if err := os.Symlink(secret, pluginRoot); err != nil {
t.Fatal(err)
}
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: root}},
DenyReadIfExists: []string{credentialDir},
DenyReadCarveouts: []string{pluginRoot},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
plan := buildLinuxBwrapFilesystemPlan(profile)
normalizedCredentialDir := normalizeProfilePath(credentialDir)
assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", normalizedCredentialDir, "--remount-ro", normalizedCredentialDir)
if argsContainSequence(plan.Args, "--ro-bind", pluginRoot, pluginRoot) || argsContainSequence(plan.Args, "--ro-bind", secret, secret) {
t.Fatalf("symlink carveout was rebound into credential mask: %#v", plan.Args)
}
}
func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
File diff suppressed because it is too large. Load diff
+126 -779
View File
@@ -22,51 +22,13 @@ type PermissionProfile struct {
}
type FileSystemPolicy struct {
Kind FileSystemPolicyKind `json:"kind"`
ReadRoots []string `json:"readRoots,omitempty"`
WriteRoots []WritableRoot `json:"writeRoots,omitempty"`
DenyRead []string `json:"denyRead,omitempty"`
// DenyReadIfExists contains best-effort baseline paths. Backends with
// path-based policies can protect future paths; mount-based Linux only
// masks entries that exist when the namespace is assembled.
DenyReadIfExists []string `json:"denyReadIfExists,omitempty"`
// DenyReadCarveouts are subtrees that stay readable INSIDE a denied root.
// They exist so a directory-level credential deny can also cover the files
// a store publishes (arbitrary temporary names, files created later in the
// session) without hiding the supported non-secret subtrees that live in the
// same directory — Zero's user plugin/specialist/command roots, whose
// commands and scripts are themselves executed through the sandbox.
DenyReadCarveouts []string `json:"denyReadCarveouts,omitempty"`
// EnsureDenyReadDirs are directories Zero owns that a mount-based backend
// may create (0700) so a mask exists for them. bubblewrap cannot mount over
// a path that is absent when the namespace is assembled, so without this a
// store created mid-session would be readable by an already-running sandbox.
EnsureDenyReadDirs []string `json:"ensureDenyReadDirs,omitempty"`
// ProcessTrustedDenyReadFiles are final credential-store pathnames derived
// only from Zero's own process environment. Mount-based Linux cannot
// durably deny an individual pathname because an atomic rename can replace
// and detach the mount; its planner must fail closed while any remain after
// profile finalization. Pathname-policy backends enforce these normally.
ProcessTrustedDenyReadFiles []string `json:"processTrustedDenyReadFiles,omitempty"`
// CommandDenyReadFinalFiles are the same kind of final credential-store
// pathname, derived instead from a command-supplied environment (including
// MCP-injected env). They stay separate from the process-trusted list
// because they earn none of its host-side privileges: no carveouts, and
// never an EnsureDenyReadDir, since a command must not be able to make Zero
// create directories on the host. What they DO share is durability. A
// bubblewrap mask over one of these is bypassable by the same atomic rename,
// and an absent one is skipped entirely, so a backend that cannot deny them
// durably must refuse the command rather than run it behind a mask that does
// not hold. Pathname-policy backends enforce them normally, absent or not.
CommandDenyReadFinalFiles []string `json:"commandDenyReadFinalFiles,omitempty"`
// CommandDenyReadDirs are directory-shaped credential roots derived from a
// command-supplied environment. Existing directories can be masked without
// host mutation; a mount-based backend must fail closed when one is absent,
// because creating an arbitrary command-selected host directory is unsafe.
CommandDenyReadDirs []string `json:"commandDenyReadDirs,omitempty"`
DenyWrite []string `json:"denyWrite,omitempty"`
IncludePlatformRoots bool `json:"includePlatformRoots,omitempty"`
AllowTemp bool `json:"allowTemp,omitempty"`
Kind FileSystemPolicyKind `json:"kind"`
ReadRoots []string `json:"readRoots,omitempty"`
WriteRoots []WritableRoot `json:"writeRoots,omitempty"`
DenyRead []string `json:"denyRead,omitempty"`
DenyWrite []string `json:"denyWrite,omitempty"`
IncludePlatformRoots bool `json:"includePlatformRoots,omitempty"`
AllowTemp bool `json:"allowTemp,omitempty"`
}
type WritableRoot struct {
@@ -108,10 +70,6 @@ func gitMetadataWriteCarveouts(root string) []string {
}
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
return permissionProfileFromPolicy(workspaceRoot, policy, scope, "", nil)
}
func permissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope, credentialCommandDir string, credentialEnv []string) PermissionProfile {
if policy.Mode == "" {
policy = DefaultPolicy()
}
@@ -135,30 +93,15 @@ func permissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco
ProtectedMetadataNames: append([]string{}, sandboxFullyProtectedMetadataNames...),
})
}
userDenyRead := normalizeProfilePaths(policy.DenyRead)
commandAllowedRoots := append([]string{}, roots...)
for _, root := range readRoots {
if root != profileRootPath() {
commandAllowedRoots = append(commandAllowedRoots, root)
}
}
credentials := credentialDenyReadPaths(policy, credentialCommandDir, credentialEnv, dedupeStrings(commandAllowedRoots))
credentials = finalizeCredentialDenyPaths(credentials, userDenyRead)
return PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: readRoots,
WriteRoots: writeRoots,
DenyRead: userDenyRead,
DenyReadIfExists: credentials.Paths,
DenyReadCarveouts: credentials.Carveouts,
EnsureDenyReadDirs: credentials.EnsureDirs,
ProcessTrustedDenyReadFiles: credentials.ProcessTrustedFinalFiles,
CommandDenyReadFinalFiles: credentials.CommandFinalFiles,
CommandDenyReadDirs: credentials.CommandDirs,
DenyWrite: normalizeProfilePaths(policy.DenyWrite),
IncludePlatformRoots: true,
AllowTemp: true,
Kind: FileSystemRestricted,
ReadRoots: readRoots,
WriteRoots: writeRoots,
DenyRead: dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...)),
DenyWrite: normalizeProfilePaths(policy.DenyWrite),
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NormalizeNetworkMode(policy.Network)},
}
@@ -201,28 +144,11 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
return dedupeStrings(readRoots)
}
// credentialDenyPaths is the credential baseline a profile derives from the
// environment: the paths to deny reads on, the known directory paths among
// them, the trusted non-secret subtrees that stay readable, and the trusted
// Zero-owned directories a mount-based backend may create so its mask exists.
type credentialDenyPaths struct {
Paths []string
Carveouts []string
EnsureDirs []string
Dirs []string
ProcessTrustedFinalFiles []string
// CommandFinalFiles are final token pathnames derived from a
// command-supplied environment. Kept apart from the trusted list so they
// never gain its host-side privileges, but tracked so a backend that cannot
// deny a replaceable pathname can fail closed on them too.
CommandFinalFiles []string
CommandDirs []string
}
// credentialDenyReadPaths returns default deny-read entries for well-known
// credential stores, including tool configuration files discoverable through
// the preserved caller environment and Zero's own config/token stores. Four
// deliberate limits:
// credential stores so sandboxed commands cannot read secrets under the
// read-all workspace posture. This includes tool configuration files that can
// carry credentials and are now discoverable through the preserved caller
// environment. Two deliberate limits:
//
// - Windows is skipped: a non-empty profile DenyRead switches the Windows
// runner onto the capability-SID/ACL deny path and away from the
@@ -230,683 +156,146 @@ type credentialDenyPaths struct {
// once the Windows deny-read model is settled.
// - A candidate nested under a user-configured AllowRead entry is dropped,
// so `allowRead: ["~/.aws"]` remains an explicit opt-out.
// - Candidates are emitted whether or not they currently exist on disk.
// Pathname-policy backends such as Seatbelt can enforce future paths;
// mount-based Linux masks a path only if it exists when the namespace is
// assembled, which is why directories derived from Zero's own process
// environment are also reported as EnsureDirs (command-controlled roots
// never are) and why third-party stores such as ~/.aws stay best-effort.
// - Zero's own config directory is denied WHOLE, with the supported
// non-secret subtrees carved back out. Only a directory-level rule covers
// the temporary names its stores publish through and the files a concurrent
// login creates mid-session; the carveouts keep the user plugin,
// specialist, and command roots readable, since those are executed through
// the sandbox.
//
// These are profile-level rules only; they are intentionally NOT merged into
// Policy.DenyRead, whose emptiness gates escalated (unsandboxed) execution and
// must keep reflecting user configuration alone.
func credentialDenyReadPaths(policy Policy, commandDir string, commandEnv []string, commandAllowedRoots []string) credentialDenyPaths {
func credentialDenyReadPaths(policy Policy) []string {
if runtime.GOOS == "windows" {
return credentialDenyPaths{}
}
// Only roots derived from Zero's own process environment may produce
// carveouts or host directories. CommandSpec.Env can contain project-controlled
// MCP environment overrides, so it contributes deny-if-present paths only.
processBaseDirs := credentialProcessBaseDirs()
processOptions := credentialPathOptionsFromEnvironment(processBaseDirs, os.Environ())
trusted := credentialDenyReadPathsIn(
processOptions,
policy.AllowRead,
)
processFinalFiles := credentialFinalTokenFiles(processOptions)
allowRoots := normalizeProfilePaths(policy.AllowRead)
for _, file := range processFinalFiles {
// The token blob and encryption key are independently atomically replaced.
// Preserve each terminal lexical name: rename replaces a terminal symlink
// rather than following it, so allowing only its resolved target must not
// expose the replacement pathname (nor does allowing the blob imply its key).
for _, final := range []string{file, file + ".secret"} {
candidate := normalizeCredentialFinalPath(final)
if candidate == "" || credentialPathReincluded(allowRoots, candidate) {
continue
}
trusted.Paths = append(trusted.Paths, candidate)
trusted.ProcessTrustedFinalFiles = append(trusted.ProcessTrustedFinalFiles, candidate)
}
}
trusted.Paths = dedupeStrings(trusted.Paths)
trusted.ProcessTrustedFinalFiles = dedupeStrings(trusted.ProcessTrustedFinalFiles)
processDirs := append([]string{}, trusted.Dirs...)
appendUntrusted := func(options credentialPathOptions) {
paths := credentialDenyReadPathsIn(options, policy.AllowRead)
// A command-controlled credential setting cannot revoke a root that was
// deliberately granted to that same command. Dropping only overlapping
// command candidates preserves all unrelated process and command denies.
paths.Paths = pathsOutsideOverlappingRoots(paths.Paths, commandAllowedRoots)
paths.Dirs = pathsOutsideOverlappingRoots(paths.Dirs, commandAllowedRoots)
trusted.Paths = append(trusted.Paths, paths.Paths...)
trusted.Dirs = append(trusted.Dirs, paths.Dirs...)
trusted.CommandDirs = append(trusted.CommandDirs, paths.Dirs...)
// Carry the final token pathnames too. They are still untrusted — no
// carveouts, no EnsureDirs, no host mutation — but a deny that a rename
// can detach is not a deny, and skipping an absent one leaves whatever
// the run publishes there readable. Recording them lets a backend that
// cannot hold them durably fail closed instead, exactly as it already
// does for the process-trusted stores.
for _, file := range credentialFinalTokenFiles(options) {
for _, final := range []string{file, file + ".secret"} {
candidate := normalizeCredentialFinalPath(final)
if candidate == "" || credentialPathReincluded(allowRoots, candidate) || len(pathsOutsideOverlappingRoots([]string{candidate}, commandAllowedRoots)) == 0 {
continue
}
trusted.Paths = append(trusted.Paths, candidate)
trusted.CommandFinalFiles = append(trusted.CommandFinalFiles, candidate)
}
}
}
commandBaseDirs := credentialCommandBaseDirs(commandDir)
if len(commandBaseDirs) > 0 {
// A nested Zero inherits the process environment but resolves relative
// values from the sandboxed command's cwd.
appendUntrusted(credentialPathOptionsFromEnvironment(commandBaseDirs, os.Environ()))
}
if commandEnv != nil {
// A supplied environment may also be consumed by code in the running Zero
// process before exec, so conservatively deny both process- and
// command-relative resolutions. These remain untrusted: neither resolution
// contributes carveouts or host directory creation.
baseDirs := dedupeStrings(append(append([]string{}, processBaseDirs...), commandBaseDirs...))
appendUntrusted(credentialPathOptionsFromEnvironment(baseDirs, commandEnv))
}
trusted.Paths = dedupeStrings(trusted.Paths)
trusted.Dirs = dedupeStrings(trusted.Dirs)
trusted.CommandDirs = pathsExcluding(dedupeStrings(trusted.CommandDirs), processDirs)
return trusted
}
// zeroConfigReadCarveoutNames are the supported non-secret subtrees of
// <configDir>/zero. Their contents are extension code and prompts that a
// sandboxed command legitimately executes or reads (a user plugin's tool
// command lives below the plugin root), so the credential deny must not hide
// them. Nothing here holds a secret: credentials, tokens, and config live in
// files directly under <configDir>/zero, not in these subtrees.
var zeroConfigReadCarveoutNames = []string{"plugins", "specialists", "commands"}
// processCredentialBaseDir is the working directory this process started in,
// captured once during package initialization.
//
// It must not be re-read per call. The token stores resolve a relative override
// such as ZERO_OAUTH_TOKENS_PATH with filepath.Abs when the store is opened and
// then write to that fixed path for the rest of the run, so a deny rule computed
// from a later os.Getwd() would name a different file than the one being
// written — denying a path nothing uses while the real store stayed readable.
// Nothing in Zero calls os.Chdir today, which is what keeps this equal to the
// writers' resolution; pinning it means that stays true if one is ever added,
// instead of the two drifting apart per BuildCommandPlan.
var processCredentialBaseDir = func() string {
cwd, err := os.Getwd()
if err != nil {
return ""
}
return filepath.Clean(cwd)
}()
// credentialProcessBaseDirs returns the directory the running Zero process uses
// to resolve relative credential overrides.
func credentialProcessBaseDirs() []string {
if strings.TrimSpace(processCredentialBaseDir) == "" {
return nil
}
return []string{processCredentialBaseDir}
}
// credentialCommandBaseDirs returns the directory a sandboxed child (including
// a nested Zero) uses to resolve inherited relative credential overrides.
func credentialCommandBaseDirs(commandDir string) []string {
if dir := strings.TrimSpace(commandDir); dir != "" {
return []string{filepath.Clean(dir)}
}
return nil
}
func credentialPathOptionsFromEnvironment(baseDirs []string, env []string) credentialPathOptions {
homes := resolveCredentialOverridePaths(credentialEnvValue(env, "HOME"), baseDirs)
if len(homes) == 0 {
homes = resolveCredentialOverridePaths(credentialEnvValue(env, "USERPROFILE"), baseDirs)
}
if len(homes) == 0 {
home, err := os.UserHomeDir()
if err == nil {
homes = resolveCredentialOverridePaths(home, baseDirs)
}
}
configDirs := resolveCredentialOverridePaths(credentialEnvValue(env, "XDG_CONFIG_HOME"), baseDirs)
if len(configDirs) == 0 {
for _, home := range homes {
configDirs = append(configDirs, filepath.Join(home, ".config"))
}
}
cloudSDKConfigDirs := resolveCredentialOverridePaths(credentialEnvValue(env, "CLOUDSDK_CONFIG"), baseDirs)
if len(cloudSDKConfigDirs) == 0 {
for _, configDir := range configDirs {
cloudSDKConfigDirs = append(cloudSDKConfigDirs, filepath.Join(configDir, "gcloud"))
}
// gcloud's default remains ~/.config/gcloud even when XDG_CONFIG_HOME is
// set. Keep both roots so the deny follows the tool as well as the
// preserved upstream config-home baseline.
for _, home := range homes {
cloudSDKConfigDirs = append(cloudSDKConfigDirs, filepath.Join(home, ".config", "gcloud"))
}
}
npmUserConfigs := resolveCredentialOverridePaths(firstNonEmpty(
credentialEnvValue(env, "NPM_CONFIG_USERCONFIG"),
credentialEnvValue(env, "npm_config_userconfig"),
), baseDirs)
if len(npmUserConfigs) == 0 {
for _, home := range homes {
npmUserConfigs = append(npmUserConfigs, filepath.Join(home, ".npmrc"))
}
}
ghConfigDirs := resolveCredentialOverridePaths(credentialEnvValue(env, "GH_CONFIG_DIR"), baseDirs)
if len(ghConfigDirs) == 0 {
for _, configDir := range configDirs {
ghConfigDirs = append(ghConfigDirs, filepath.Join(configDir, "gh"))
}
}
netrcs := resolveCredentialOverridePaths(credentialEnvValue(env, "NETRC"), baseDirs)
if len(netrcs) == 0 {
for _, home := range homes {
netrcs = append(netrcs, filepath.Join(home, ".netrc"))
}
}
dockerConfigDirs := resolveCredentialOverridePaths(credentialEnvValue(env, "DOCKER_CONFIG"), baseDirs)
if len(dockerConfigDirs) == 0 {
for _, home := range homes {
dockerConfigDirs = append(dockerConfigDirs, filepath.Join(home, ".docker"))
}
}
var kubeConfigs []string
if kubeConfig := strings.TrimSpace(credentialEnvValue(env, "KUBECONFIG")); kubeConfig != "" {
for _, entry := range filepath.SplitList(kubeConfig) {
kubeConfigs = append(kubeConfigs, resolveCredentialOverridePaths(entry, baseDirs)...)
}
} else {
for _, home := range homes {
kubeConfigs = append(kubeConfigs, filepath.Join(home, ".kube", "config"))
}
}
return credentialPathOptions{
Homes: homes,
ConfigDirs: dedupeStrings(configDirs),
CloudSDKConfigDirs: dedupeStrings(cloudSDKConfigDirs),
GoogleCredentials: resolveCredentialOverridePaths(credentialEnvValue(env, "GOOGLE_APPLICATION_CREDENTIALS"), baseDirs),
NPMUserConfigs: dedupeStrings(npmUserConfigs),
GHConfigDirs: dedupeStrings(ghConfigDirs),
Netrcs: dedupeStrings(netrcs),
DockerConfigDirs: dedupeStrings(dockerConfigDirs),
KubeConfigs: dedupeStrings(kubeConfigs),
OAuthTokens: resolveCredentialOverridePaths(credentialEnvValue(env, "ZERO_OAUTH_TOKENS_PATH"), baseDirs),
OAuthStorage: strings.TrimSpace(credentialEnvValue(env, "ZERO_OAUTH_STORAGE")),
MCPOAuthTokens: resolveCredentialOverridePaths(credentialEnvValue(env, "ZERO_MCP_OAUTH_TOKENS_PATH"), baseDirs),
}
}
func credentialEnvValue(env []string, key string) string {
value := ""
for _, entry := range env {
name, candidate, ok := strings.Cut(entry, "=")
if ok && name == key {
value = candidate
}
}
return value
}
type credentialPathOptions struct {
Homes []string
ConfigDirs []string
CloudSDKConfigDirs []string
GoogleCredentials []string
NPMUserConfigs []string
GHConfigDirs []string
Netrcs []string
DockerConfigDirs []string
KubeConfigs []string
OAuthTokens []string
OAuthStorage string
MCPOAuthTokens []string
}
// credentialFinalTokenFiles returns token pathnames whose selected backend
// atomically replaces files on disk. ZERO_OAUTH_TOKENS_PATH still contributes
// to the ordinary deny baseline when keyring storage is selected, but it must
// not make bubblewrap fail closed: the keyring backend never publishes the
// token blob or its encryption-key sibling at that path. The MCP override is a
// legacy migration input, not an output; the unified store writes through the
// OAuth path instead.
func credentialFinalTokenFiles(options credentialPathOptions) []string {
if options.OAuthStorage == "keyring" {
return nil
}
return append([]string{}, options.OAuthTokens...)
// A failed home lookup only drops the home-based candidates; the
// GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless.
home, _ := os.UserHomeDir()
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
Home: home,
ConfigHome: os.Getenv("XDG_CONFIG_HOME"),
CloudSDKConfig: os.Getenv("CLOUDSDK_CONFIG"),
GoogleCredentials: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
NPMUserConfig: firstNonEmpty(os.Getenv("NPM_CONFIG_USERCONFIG"), os.Getenv("npm_config_userconfig")),
GHConfigDir: os.Getenv("GH_CONFIG_DIR"),
Netrc: os.Getenv("NETRC"),
DockerConfigDir: os.Getenv("DOCKER_CONFIG"),
KubeConfig: os.Getenv("KUBECONFIG"),
}, policy.AllowRead)
}
// credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths,
// separated so tests can exercise it against a synthetic home directory.
func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string) credentialDenyPaths {
func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string {
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
Home: home,
GoogleCredentials: googleCredentials,
}, allowRead)
}
type credentialPathEnvironment struct {
Home string
ConfigHome string
CloudSDKConfig string
GoogleCredentials string
NPMUserConfig string
GHConfigDir string
Netrc string
DockerConfigDir string
KubeConfig string
}
func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowRead []string) []string {
var candidates []string
var carveouts []string
var ensureDirs []string
var dirs []string
for _, home := range options.Homes {
if strings.TrimSpace(home) == "" {
continue
}
homeDirs := []string{
home := strings.TrimSpace(env.Home)
configHome := strings.TrimSpace(env.ConfigHome)
if configHome == "" && home != "" {
configHome = filepath.Join(home, ".config")
}
if home != "" {
candidates = append(candidates,
filepath.Join(home, ".aws"),
filepath.Join(home, ".azure"),
}
candidates = append(candidates, homeDirs...)
dirs = append(dirs, homeDirs...)
// git's credential store backend, which holds host passwords and
// personal access tokens in cleartext. Denied rather than the whole
// of ~/.ssh, because these cost nothing functionally: git reads them
// through a credential helper for authentication, not for identity,
// so a sandboxed git still works and simply cannot authenticate as
// the user. SSH key material is a harder trade and is tracked
// separately (#815). A file, so it joins candidates only — dirs
// drives directory-shaped handling (bwrap binds, carveouts).
candidates = append(candidates, filepath.Join(home, ".git-credentials"))
// git's credential store backend, which holds host passwords and
// personal access tokens in cleartext. Denied rather than the whole
// of ~/.ssh, because these cost nothing functionally: git reads them
// through a credential helper for authentication, not for identity,
// so a sandboxed git still works and simply cannot authenticate as
// the user. SSH key material is a harder trade and is tracked
// separately (#815).
filepath.Join(home, ".git-credentials"),
)
}
candidates = append(candidates, options.GoogleCredentials...)
candidates = append(candidates, options.NPMUserConfigs...)
candidates = append(candidates, options.Netrcs...)
candidates = append(candidates, options.KubeConfigs...)
for _, dockerConfigDir := range options.DockerConfigDirs {
if strings.TrimSpace(dockerConfigDir) != "" {
candidates = append(candidates, filepath.Join(dockerConfigDir, "config.json"))
}
npmUserConfig := strings.TrimSpace(env.NPMUserConfig)
if npmUserConfig == "" && home != "" {
npmUserConfig = filepath.Join(home, ".npmrc")
}
for _, ghConfigDir := range options.GHConfigDirs {
if strings.TrimSpace(ghConfigDir) != "" {
candidates = append(candidates, filepath.Join(ghConfigDir, "hosts.yml"))
}
netrc := strings.TrimSpace(env.Netrc)
if netrc == "" && home != "" {
netrc = filepath.Join(home, ".netrc")
}
for _, cloudSDKConfigDir := range options.CloudSDKConfigDirs {
if strings.TrimSpace(cloudSDKConfigDir) == "" {
continue
}
candidates = append(candidates, cloudSDKConfigDir)
dirs = append(dirs, cloudSDKConfigDir)
dockerConfigDir := strings.TrimSpace(env.DockerConfigDir)
if dockerConfigDir == "" && home != "" {
dockerConfigDir = filepath.Join(home, ".docker")
}
for _, configDir := range options.ConfigDirs {
if strings.TrimSpace(configDir) == "" {
continue
}
// The XDG location of the same git credential store. Denying the file
// rather than the directory is deliberate: ~/.config/git also holds
// the global git config, which userGitConfigReadPaths grants on
// purpose so a sandboxed git can read user.name and aliases instead
// of failing outright. Added before the zero-directory handling below
// so a normalization failure there cannot drop this deny with it.
candidates = append(candidates, filepath.Join(configDir, "git", "credentials"))
// Deny the whole directory rather than an itemized file list: Zero's
// credential, token, and config stores publish through temporary siblings
// before an atomic rename, the legacy MCP store leaves a
// mcp-oauth-tokens.json.migrated backup behind, and a concurrent login can
// add a store that did not exist when this profile was built. Only a
// directory rule covers all three. Zero owns this directory, so it is also
// an EnsureDir: bubblewrap cannot mask a path that is absent when the
// namespace is assembled.
// Normalize the denied root first, then derive its fixed children
// lexically. This keeps nonexistent carveouts under the same canonical
// parent (for example macOS /var -> /private/var) without following a
// plugins/specialists/commands symlink into a credential file.
zeroDir := normalizeProfilePath(filepath.Join(configDir, "zero"))
if zeroDir == "" {
continue
}
candidates = append(candidates, zeroDir)
dirs = append(dirs, zeroDir)
ensureDirs = append(ensureDirs, zeroDir)
for _, name := range zeroConfigReadCarveoutNames {
carveouts = append(carveouts, filepath.Join(zeroDir, name))
}
candidates = append(candidates, npmUserConfig, netrc)
if dockerConfigDir != "" {
candidates = append(candidates, filepath.Join(dockerConfigDir, "config.json"))
}
for _, tokenPath := range options.OAuthTokens {
if options.OAuthStorage == "keyring" {
// The keyring backend does not open the override or any publication
// siblings. Keep the pathname as a conservative ordinary deny, but do
// not manufacture unused .publish directories on the host.
candidates = append(candidates, tokenPath)
} else {
candidates = append(candidates, credentialTokenStorePaths(tokenPath)...)
publicationDirs := credentialPublicationDirs(tokenPath)
dirs = append(dirs, publicationDirs...)
ensureDirs = append(ensureDirs, publicationDirs...)
}
if configHome != "" {
candidates = append(candidates,
filepath.Join(configHome, "zero"),
// The XDG location of the same git credential store. Denying the file
// rather than the directory is deliberate: ~/.config/git also holds
// the global git config, which userGitConfigReadPaths grants on
// purpose so a sandboxed git can read user.name and aliases instead
// of failing outright.
filepath.Join(configHome, "git", "credentials"),
)
}
for _, tokenPath := range options.MCPOAuthTokens {
// This is a legacy migration input. MCP reads it and renames it aside;
// normal writes go to the separately resolved unified OAuth store.
// Protect both token-bearing names, but do not classify the source as an
// atomic writer or create publication directories it never uses.
candidates = append(candidates, tokenPath, tokenPath+".migrated")
cloudSDKConfig := strings.TrimSpace(env.CloudSDKConfig)
if cloudSDKConfig == "" && configHome != "" {
cloudSDKConfig = filepath.Join(configHome, "gcloud")
}
if cloudSDKConfig != "" {
candidates = append(candidates, cloudSDKConfig)
}
ghConfigDir := strings.TrimSpace(env.GHConfigDir)
if ghConfigDir == "" && configHome != "" {
ghConfigDir = filepath.Join(configHome, "gh")
}
if ghConfigDir != "" {
candidates = append(candidates, filepath.Join(ghConfigDir, "hosts.yml"))
}
if target := strings.TrimSpace(env.GoogleCredentials); target != "" {
candidates = append(candidates, target)
}
kubeConfig := strings.TrimSpace(env.KubeConfig)
if kubeConfig == "" && home != "" {
kubeConfig = filepath.Join(home, ".kube", "config")
}
for _, path := range filepath.SplitList(kubeConfig) {
if path = strings.TrimSpace(path); path != "" {
candidates = append(candidates, path)
}
}
allowRoots := normalizeProfilePaths(allowRead)
out := make([]string, 0, len(candidates))
for _, path := range normalizeProfilePaths(candidates) {
if credentialPathReincluded(allowRoots, path) {
// Only stores that actually exist on this host need a deny rule.
if _, err := os.Stat(path); err != nil {
continue
}
out = append(out, path)
}
return credentialDenyPaths{
Paths: out,
Carveouts: credentialCarveoutPaths(out, carveouts),
EnsureDirs: credentialRetainedDirs(out, normalizeProfilePaths(ensureDirs)),
Dirs: credentialRetainedDirs(out, normalizeProfilePaths(dirs)),
}
}
// credentialTokenStorePaths returns the deny entries for one token-store path:
// the store, its lock siblings, its encryption-key sibling, and the directory
// it publishes new contents through. The names are fixed so an override outside
// Zero's config directory is protected by exact rules instead of hiding an
// arbitrary parent such as the workspace or /tmp.
func credentialTokenStorePaths(tokenPath string) []string {
if strings.TrimSpace(tokenPath) == "" {
return nil
}
paths := []string{
tokenPath,
tokenPath + ".lockfile",
tokenPath + ".secret",
tokenPath + ".secret.lock",
// Left behind by a Zero older than the publication directories below.
tokenPath + ".tmp",
tokenPath + ".secret.tmp",
}
return append(paths, credentialPublicationDirs(tokenPath)...)
}
// credentialPublicationDirs are the per-store directories the OAuth stores
// create their randomly-named temporary file in (see oauth.PublicationDir) —
// one for the token blob, one for its encryption key. The directory NAME is
// derived from the store path so the profile can deny it up front, while the
// random name inside it is what keeps a sandboxed same-user process from
// opening, or renaming away, the file that briefly holds the plaintext.
func credentialPublicationDirs(tokenPath string) []string {
if strings.TrimSpace(tokenPath) == "" {
return nil
}
return []string{
tokenPath + credentialPublicationDirSuffix,
tokenPath + ".secret" + credentialPublicationDirSuffix,
}
}
// credentialPublicationDirSuffix mirrors oauth.PublicationDirSuffix, duplicated
// because internal/mcp depends on this package and internal/oauth must stay
// importable from both.
const credentialPublicationDirSuffix = ".publish"
// pathsOutsideRoots drops every path that lies within one of roots.
func pathsOutsideRoots(paths []string, roots []string) []string {
if len(paths) == 0 || len(roots) == 0 {
return paths
}
out := make([]string, 0, len(paths))
for _, path := range paths {
if credentialPathReincluded(roots, path) {
continue
}
out = append(out, path)
}
return out
}
// pathsOutsideOverlappingRoots drops paths that contain, or are contained by, a
// root. A credential carveout is an allow-back rule, so either overlap could
// weaken a user deny after Seatbelt's last-match-wins evaluation.
func pathsOutsideOverlappingRoots(paths []string, roots []string) []string {
if len(paths) == 0 || len(roots) == 0 {
return paths
}
out := make([]string, 0, len(paths))
for _, path := range paths {
overlaps := false
for _, root := range roots {
if pathWithinRoot(root, path) || pathWithinRoot(path, root) {
overlaps = true
reincluded := false
for _, allow := range allowRoots {
if pathWithinRoot(allow, path) {
reincluded = true
break
}
}
if !overlaps {
if !reincluded {
out = append(out, path)
}
}
return out
}
func credentialPathReincluded(allowRoots []string, path string) bool {
for _, allow := range allowRoots {
if pathWithinRoot(allow, path) {
return true
}
}
return false
}
// credentialCarveoutPaths keeps only the carveouts that sit inside a path that
// is actually denied, so an AllowRead opt-out that removed the deny does not
// leave a stray allow-back rule behind.
func credentialCarveoutPaths(denied []string, carveouts []string) []string {
if len(carveouts) == 0 {
return nil
}
denied = normalizeProfilePaths(denied)
out := make([]string, 0, len(carveouts))
for _, entry := range carveouts {
carveout := normalizeCredentialCarveoutPath(entry)
if carveout == "" {
continue
}
for _, deny := range denied {
if carveout != deny && pathWithinRoot(deny, carveout) {
out = append(out, carveout)
break
}
}
}
return dedupeStrings(out)
}
// normalizeCredentialCarveoutPath canonicalizes the parent while preserving
// the fixed terminal name. That keeps the allow under the same canonical root
// as its deny (for example macOS /var -> /private/var) without ever following a
// plugins/specialists/commands symlink to a credential target.
func normalizeCredentialCarveoutPath(entry string) string {
carveout := normalizeProfilePathLexically(entry)
if carveout == "" {
return ""
}
// A missing fixed subtree may be installed later by trusted host code, but
// an existing entry must be a real directory. In particular, never turn a
// plugins symlink into an allow rule for its credential-file target.
if info, err := os.Lstat(carveout); err == nil {
if !info.IsDir() {
return ""
}
} else if !os.IsNotExist(err) {
return ""
}
parent := normalizeProfilePath(filepath.Dir(carveout))
if parent == "" {
return ""
}
return filepath.Join(parent, filepath.Base(carveout))
}
// credentialRetainedDirs keeps only directories that remain exact deny entries.
func credentialRetainedDirs(denied []string, dirs []string) []string {
if len(dirs) == 0 {
return nil
}
out := make([]string, 0, len(dirs))
for _, dir := range dirs {
for _, deny := range denied {
if dir == deny {
out = append(out, dir)
break
}
}
}
return dedupeStrings(out)
}
// finalizeCredentialDenyPaths gives user denies precedence and removes nested
// automatic mounts that an outer automatic directory mask already covers.
// Children inside a retained carveout stay explicit denies, because the
// carveout re-allows that subtree.
func finalizeCredentialDenyPaths(credentials credentialDenyPaths, userDenyRead []string) credentialDenyPaths {
credentials.Paths = pathsOutsideRoots(credentials.Paths, userDenyRead)
credentials.Carveouts = pathsOutsideOverlappingRoots(credentials.Carveouts, userDenyRead)
credentials.Dirs = credentialRetainedDirs(credentials.Paths, credentials.Dirs)
credentials.Carveouts = credentialCarveoutPaths(credentials.Paths, credentials.Carveouts)
var paths []string
for _, path := range credentials.Paths {
covered := false
for _, dir := range credentials.Dirs {
if path != dir && pathWithinRoot(dir, path) && !credentialPathReincluded(credentials.Carveouts, path) {
covered = true
break
}
}
if !covered {
paths = append(paths, path)
}
}
credentials.Paths = dedupeStrings(paths)
credentials.Dirs = credentialRetainedDirs(credentials.Paths, credentials.Dirs)
credentials.CommandDirs = credentialRetainedDirs(credentials.Paths, credentials.CommandDirs)
credentials.Carveouts = credentialCarveoutPaths(credentials.Paths, credentials.Carveouts)
credentials.EnsureDirs = credentialRetainedDirs(credentials.Paths, credentials.EnsureDirs)
credentials.ProcessTrustedFinalFiles = credentialRetainedFiles(
credentials.ProcessTrustedFinalFiles,
userDenyRead,
credentials.EnsureDirs,
credentials.Carveouts,
)
// Same retention for the command-derived finals: a file already inside a
// user deny or a directory Zero will actually mask is durably covered, so it
// is not a fail-closed reason. What survives is an override pointing OUTSIDE
// every masked directory — the case a bubblewrap bind cannot hold.
credentials.CommandFinalFiles = credentialRetainedFiles(
credentials.CommandFinalFiles,
userDenyRead,
credentials.EnsureDirs,
credentials.Carveouts,
)
// A command environment is also resolved against the process base dir, so an
// absolute override in Zero's own environment lands in both lists. Report it
// once, as the process-trusted store it is.
credentials.CommandFinalFiles = pathsExcluding(credentials.CommandFinalFiles, credentials.ProcessTrustedFinalFiles)
return credentials
}
// pathsExcluding returns paths with every exact member of excluded removed.
func pathsExcluding(paths, excluded []string) []string {
if len(paths) == 0 || len(excluded) == 0 {
return paths
}
skip := make(map[string]struct{}, len(excluded))
for _, path := range excluded {
skip[path] = struct{}{}
}
out := make([]string, 0, len(paths))
for _, path := range paths {
if _, dup := skip[path]; dup {
continue
}
out = append(out, path)
}
return out
}
// credentialRetainedFiles drops fail-closed file markers only when a durable
// directory mask strictly contains the file. Exact file mounts are vulnerable
// to atomic replacement, and Dirs may include command-controlled candidates;
// only user directory ancestors and retained trusted EnsureDirs qualify. A
// trusted directory does not cover a final file that is re-exposed through a
// readable carveout below it.
func credentialRetainedFiles(files, userDenied, trustedDeniedDirs, carveouts []string) []string {
var out []string
for _, file := range files {
covered := false
for _, dir := range userDenied {
if file != dir && pathWithinRoot(dir, file) {
covered = true
break
}
}
if !covered {
for _, dir := range trustedDeniedDirs {
if file != dir && pathWithinRoot(dir, file) && !credentialFileReincludedByCarveout(dir, file, carveouts) {
covered = true
break
}
}
}
if !covered {
out = append(out, file)
}
}
return dedupeStrings(out)
}
func credentialFileReincludedByCarveout(deniedDir, file string, carveouts []string) bool {
for _, carveout := range carveouts {
if pathWithinRoot(deniedDir, carveout) && pathWithinRoot(carveout, file) {
return true
}
}
return false
}
// resolveCredentialOverridePaths mirrors the token stores' own override
// resolution (oauth.ResolveStorePath, mcp.ResolveTokenStorePath — reimplemented
// here rather than imported because internal/mcp depends on this package): the
// value is used literally, NOT tilde-expanded the way normalizeProfilePath
// expands other candidates. Using normalizeProfilePath here would derive a deny
// path that doesn't match where the store actually writes — e.g.
// ZERO_OAUTH_TOKENS_PATH=~/x resolves to <cwd>/~/x on disk (the store never
// expands "~"), but normalizeProfilePath would deny $HOME/x instead, leaving
// the real file unprotected.
//
// A relative value yields one candidate per supplied base dir because the
// process that resolves it is not necessarily the one that writes the store.
func resolveCredentialOverridePaths(override string, baseDirs []string) []string {
override = strings.TrimSpace(override)
if override == "" {
return nil
}
if filepath.IsAbs(override) {
return []string{filepath.Clean(override)}
}
out := make([]string, 0, len(baseDirs))
for _, baseDir := range baseDirs {
if strings.TrimSpace(baseDir) == "" {
continue
}
out = append(out, filepath.Clean(filepath.Join(baseDir, override)))
}
return dedupeStrings(out)
}
// userGitConfigReadPaths returns the user's global git config FILES so a
// sandboxed git can read identity and config (user.name/email, aliases) instead
// of failing with "unable to access ~/.gitconfig". On macOS, where reads are
@@ -965,51 +354,6 @@ func normalizeProfilePaths(entries []string) []string {
}
func normalizeProfilePath(entry string) string {
absolute := normalizeProfilePathLexically(entry)
if absolute == "" {
return ""
}
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
return resolved
}
// EvalSymlinks requires the whole path to exist. Resolve the deepest existing
// ancestor and append the missing tail lexically so future deny paths still
// match canonical aliases such as macOS /var -> /private/var.
current := absolute
var tail []string
for {
parent := filepath.Dir(current)
if parent == current {
return filepath.Clean(absolute)
}
tail = append([]string{filepath.Base(current)}, tail...)
current = parent
if resolved, err := filepath.EvalSymlinks(current); err == nil {
return filepath.Join(append([]string{resolved}, tail...)...)
}
}
}
// normalizeCredentialFinalPath canonicalizes only the parent of an absolute,
// already-resolved credential override and rejoins its terminal lexical name.
// normalizeProfilePath supplies deepest-existing-ancestor handling for a
// missing parent without following a terminal symlink that os.Rename replaces.
func normalizeCredentialFinalPath(path string) string {
if strings.TrimSpace(path) == "" || !filepath.IsAbs(path) {
return ""
}
parent := normalizeProfilePath(filepath.Dir(filepath.Clean(path)))
if parent == "" {
return ""
}
return filepath.Join(parent, filepath.Base(filepath.Clean(path)))
}
// normalizeProfilePathLexically expands and absolutizes a profile path without
// resolving symlinks. Credential carveouts use it so their fixed lexical name
// can never become an allow rule for a symlink target.
func normalizeProfilePathLexically(entry string) string {
trimmed := strings.TrimSpace(entry)
if trimmed == "" {
return ""
@@ -1025,5 +369,8 @@ func normalizeProfilePathLexically(entry string) string {
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
return resolved
}
return filepath.Clean(absolute)
}
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff. Show more