mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e387deb516 | ||
|
|
7288f4e383 | ||
|
|
3d7f0bd7dd | ||
|
|
2622f6ec67 | ||
|
|
854e0dc8f4 | ||
|
|
9d24a22465 | ||
|
|
243fee66a8 | ||
|
|
7bba7f6ec0 | ||
|
|
25d7247a84 | ||
|
|
7b58cf2d97 | ||
|
|
9067aa3b79 | ||
|
|
e4f8b43f26 | ||
|
|
82e4c8f482 | ||
|
|
ad17751c12 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.7.0"}
|
||||
{".":"0.6.0"}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
(¬ifier{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)
|
||||
(¬ifier{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."},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")},
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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."}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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{}) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
+36
-1206
File diff suppressed because it is too large.
Load diff
+126
-779
@@ -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
Reference in new issue
Block a user