mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73e07cd3ea | ||
|
|
7d16bda4da | ||
|
|
74c06a1358 | ||
|
|
2ddb134071 | ||
|
|
1c1cb94245 | ||
|
|
b4bea8c604 | ||
|
|
9aac8237e3 | ||
|
|
77462b0b31 | ||
|
|
a1a9503b62 | ||
|
|
6474b144ec | ||
|
|
689591d47b | ||
|
|
400e285249 | ||
|
|
6ce0931a63 | ||
|
|
68a60f14b4 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.7.0"}
|
||||
{".":"0.6.0"}
|
||||
@@ -67,47 +67,3 @@ requirements. If a related check fails, fix it. If a required check cannot run
|
||||
because of the environment or fails for an unrelated external reason, report
|
||||
the exact failure and obtain maintainer direction rather than silently ignoring
|
||||
it.
|
||||
|
||||
## 4. Common Review Blockers
|
||||
|
||||
These classes drive multi-round reviews. Fix them before requesting review:
|
||||
|
||||
- **Fresh base:** Rebase onto the current PR base (`main` or stacked target)
|
||||
before review. A stale head that rolls back mainline commits is a hard
|
||||
blocker, not a merge-time detail. Resolve conflicts by keeping upstream
|
||||
behavior unless the PR intentionally changes it.
|
||||
- **Platform truth:** Code and tests must pass on Linux, macOS, and Windows.
|
||||
Compare paths after canonicalization (physical/`EvalSymlinks` form); never
|
||||
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
|
||||
paths on Windows). Prefer one cross-platform function with small `GOOS`
|
||||
checks over duplicated helpers that drift.
|
||||
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
|
||||
success and error paths (including stderr). Fail closed on ownership, lease,
|
||||
and permission checks. Do not rely on pre-open path resolution
|
||||
(`EvalSymlinks` then open) for containment: that is a check-to-use race.
|
||||
Bind containment at open/use time with rooted or handle-relative,
|
||||
traversal-resistant APIs. If a no-follow API is used, apply it to every
|
||||
traversed component and enforce the platform's reparse-point protections;
|
||||
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
|
||||
the full read-modify-write sequence for lockfiles and shared stores.
|
||||
- **Tests match the claim:** Every behavior or security-boundary change needs a
|
||||
regression test, including the failure path. Path-sensitive logic needs at
|
||||
least one non-Linux path case (or a hermetic fake that exercises the same
|
||||
normalization).
|
||||
- **Honest scope:** PR description, help text, and comments must match what
|
||||
shipped. Wire advertised entry points or shrink the claim. Do not bundle
|
||||
unrelated fixes. Do not widen security allowlists by name alone; check
|
||||
classification and side effects too.
|
||||
@@ -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.
|
||||
@@ -6,22 +6,20 @@ require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
charm.land/bubbletea/v2 v2.0.8
|
||||
charm.land/lipgloss/v2 v2.0.5
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
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
|
||||
|
||||
@@ -4,8 +4,6 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
|
||||
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
@@ -64,8 +62,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,9 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "missing additional_permissions", result.Output)
|
||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed additional_permissions payload must still reach the normal
|
||||
@@ -163,123 +165,6 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// runShellCall drives one shell tool call through the real registry (and so
|
||||
// through Registry.RunWithOptions) exactly as the agent loop would.
|
||||
func runShellCall(t *testing.T, root string, arguments string) ToolResult {
|
||||
t.Helper()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedExecCommandTool(root, nil, nil))
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "exec_command", Arguments: arguments},
|
||||
PermissionModeUnsafe,
|
||||
Options{Cwd: root},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TestAdditionalPermissionsRejectionsConverge is the regression guard for
|
||||
// #845/#847. A model that attaches a permission object to a command needing
|
||||
// none used to be told only "set sandbox_permissions", which pushed it into the
|
||||
// OTHER invalid shape (flag set, payload empty) and its "include a permission"
|
||||
// error pushed it straight back — the reported loop that burned turns on
|
||||
// `git branch --show-current`.
|
||||
//
|
||||
// The contract this pins is convergence, not tolerance: both shapes are still
|
||||
// REJECTED (nothing here grants access), but each rejection must name the one
|
||||
// follow-up call that works, and that follow-up must actually succeed.
|
||||
func TestAdditionalPermissionsRejectionsConverge(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const readOnly = `"cmd":"git --version"`
|
||||
|
||||
// Step 1 of the reported loop: a permission object with no flag.
|
||||
first := runShellCall(t, root, `{`+readOnly+`,"additional_permissions":{"network":{"enabled":true}}}`)
|
||||
if first.Status != tools.StatusError {
|
||||
t.Fatalf("unflagged additional_permissions status = %q, want error (rejecting is correct)", first.Status)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "unflagged additional_permissions", first.Output)
|
||||
|
||||
// Step 2 of the reported loop: the model keeps the flag but empties the
|
||||
// payload. This must not be the dead end it was.
|
||||
second := runShellCall(t, root, `{`+readOnly+`,"sandbox_permissions":"with_additional_permissions"}`)
|
||||
if second.Status != tools.StatusError {
|
||||
t.Fatalf("flag without payload status = %q, want error", second.Status)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "flag without payload", second.Output)
|
||||
|
||||
// The call both errors point at must actually work.
|
||||
converged := runShellCall(t, root, `{`+readOnly+`}`)
|
||||
if converged.Status != tools.StatusOK {
|
||||
t.Fatalf("the call both errors instruct the model to make failed: status=%q output=%q", converged.Status, converged.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNamesOmitRemedy pins the actionable half of the error text: the message
|
||||
// must spell out that BOTH fields can simply be dropped. "Set the flag" or
|
||||
// "include a permission" alone is what made the two errors chase each other.
|
||||
func assertNamesOmitRemedy(t *testing.T, label string, output string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(output, "omit both sandbox_permissions and additional_permissions") {
|
||||
t.Fatalf("%s error = %q, want it to say to omit both fields entirely", label, output)
|
||||
}
|
||||
if !strings.Contains(output, "retry") {
|
||||
t.Fatalf("%s error = %q, want it to tell the model to retry", label, output)
|
||||
}
|
||||
}
|
||||
|
||||
// The empty-payload rejection carries the same remedy. It is pinned on the
|
||||
// validator directly because normalizeNoopAdditionalPermissions absorbs the
|
||||
// empty payload before executeToolCall reaches this branch;
|
||||
// inlineAdditionalPermissionsProfile is still the shared source of truth that
|
||||
// buildPermissionEvent and the grant path re-run, so its text must converge too.
|
||||
func TestEmptyAdditionalPermissionsErrorNamesOmitRemedy(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"cmd": "git --version",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": false},
|
||||
"file_system": map[string]any{"read": []any{}, "write": []any{}},
|
||||
},
|
||||
}
|
||||
_, requested, err := inlineAdditionalPermissionsProfile(args, t.TempDir())
|
||||
if !requested {
|
||||
t.Fatal("requested = false, want true for an explicit with_additional_permissions call")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("an empty additional_permissions payload was accepted; it must still be rejected")
|
||||
}
|
||||
assertNamesOmitRemedy(t, "empty payload", err.Error())
|
||||
}
|
||||
|
||||
// A provider that materializes every optional property may send
|
||||
// "additional_permissions": null. That grants nothing and carries no flag, so it
|
||||
// is the field omitted — rejecting it stalled an ordinary command behind an
|
||||
// error the model could not act on.
|
||||
func TestExecuteToolCallTreatsNullAdditionalPermissionsAsOmitted(t *testing.T) {
|
||||
result := runShellCall(t, t.TempDir(), `{"cmd":"git --version","additional_permissions":null}`)
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("null additional_permissions was rejected: status=%q output=%q", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A null payload alongside the flag must still be rejected, and must land on
|
||||
// the same error as an absent payload rather than being quietly downgraded.
|
||||
func TestExecuteToolCallRejectsFlaggedNullAdditionalPermissions(t *testing.T) {
|
||||
result := runShellCall(t, t.TempDir(),
|
||||
`{"cmd":"git --version","sandbox_permissions":"with_additional_permissions","additional_permissions":null}`)
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("flagged null status = %q, want error", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "no additional_permissions object was provided") {
|
||||
t.Fatalf("output = %q, want the missing-payload error", result.Output)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "flagged null", result.Output)
|
||||
}
|
||||
|
||||
func TestNormalizeProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
|
||||
for _, mode := range []string{
|
||||
string(tools.SandboxPermissionsUseDefault),
|
||||
|
||||
@@ -38,9 +38,8 @@ const compactionTriggerRatio = 0.7
|
||||
// transcript (and so tests can assert on it).
|
||||
const summaryLabel = "[Summary of earlier conversation]"
|
||||
|
||||
// CompactionSummaryInstructions is the system prompt handed to both automatic
|
||||
// and manually requested compaction summarizers.
|
||||
const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||
// summaryInstructions is the system prompt handed to the summarizer model.
|
||||
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
|
||||
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
|
||||
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
|
||||
@@ -76,12 +75,6 @@ type CompactionResult struct {
|
||||
// summary message also includes summaryLabel and any preserved structured
|
||||
// state needed by later compactions.
|
||||
SummaryText string
|
||||
// ProjectedChars is the text content sent to the summarizer after semantic
|
||||
// projection, excluding provider protocol framing.
|
||||
ProjectedChars int
|
||||
// Truncated reports whether projection dropped content to satisfy its
|
||||
// bounded brief or previous-summary limits.
|
||||
Truncated bool
|
||||
// Compacted reports whether Messages contains an injected summary.
|
||||
Compacted bool
|
||||
}
|
||||
@@ -223,11 +216,11 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
|
||||
summary, err := opts.Summarize(middle)
|
||||
if err != nil {
|
||||
return CompactionResult{}, err
|
||||
}
|
||||
summary := summaryResult.SummaryText
|
||||
summary = strings.TrimSpace(summary)
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
@@ -245,53 +238,10 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
RemovedCount: len(middle),
|
||||
PreservedCount: len(messages) - len(middle),
|
||||
SummaryText: summary,
|
||||
ProjectedChars: summaryResult.ProjectedChars,
|
||||
Truncated: summaryResult.Truncated,
|
||||
Compacted: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompactionSummaryResult describes the shared summarization input and output.
|
||||
type CompactionSummaryResult struct {
|
||||
SummaryText string
|
||||
ProjectedChars int
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// SummarizeCompactionMessages applies the shared semantic projection before
|
||||
// invoking a caller-supplied summarizer. Automatic context-pressure compaction
|
||||
// and manual session compaction both use this path.
|
||||
func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) {
|
||||
if summarize == nil {
|
||||
return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function")
|
||||
}
|
||||
projection := projectCompactionInput(messages)
|
||||
summaryInput := projection.messages
|
||||
if len(summaryInput) == 0 {
|
||||
summaryInput = messages
|
||||
}
|
||||
summary, err := summarize(summaryInput)
|
||||
if err != nil {
|
||||
return CompactionSummaryResult{}, err
|
||||
}
|
||||
return CompactionSummaryResult{
|
||||
SummaryText: strings.TrimSpace(summary),
|
||||
ProjectedChars: compactionMessageChars(summaryInput),
|
||||
Truncated: projection.truncated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compactionMessageChars(messages []zeroruntime.Message) int {
|
||||
total := 0
|
||||
for _, message := range messages {
|
||||
total += len(message.Content)
|
||||
for _, call := range message.ToolCalls {
|
||||
total += len(call.Name) + len(call.Arguments)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
|
||||
// the preserved suffix begins on a user or assistant message rather than a
|
||||
// tool/tool_result message. A tool result with no preceding assistant tool call
|
||||
@@ -381,8 +331,6 @@ type compactionState struct {
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
task *taskState
|
||||
planner *contextPlanner
|
||||
trace *trace.Recorder
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
@@ -429,8 +377,6 @@ func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -486,7 +432,7 @@ func (state *compactionState) maybeCompact(
|
||||
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -537,7 +483,7 @@ func (state *compactionState) recover(
|
||||
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
@@ -571,12 +517,9 @@ func (state *compactionState) recover(
|
||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,11 +531,7 @@ func (state *compactionState) summarizeClosure(ctx context.Context, provider Pro
|
||||
// Non-context-limit errors (and a single message that still won't fit) surface
|
||||
// to the caller unchanged.
|
||||
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
|
||||
}
|
||||
|
||||
func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage, planner, recorder)
|
||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
|
||||
if err == nil {
|
||||
return summary, nil
|
||||
}
|
||||
@@ -601,11 +540,11 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
||||
}
|
||||
|
||||
mid := len(messages) / 2
|
||||
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
|
||||
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
|
||||
if leftErr != nil {
|
||||
return "", leftErr
|
||||
}
|
||||
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
|
||||
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
|
||||
if rightErr != nil {
|
||||
return "", rightErr
|
||||
}
|
||||
@@ -618,7 +557,7 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
||||
combined := strings.TrimSpace(left + "\n\n" + right)
|
||||
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
||||
}, onUsage, planner, recorder)
|
||||
}, onUsage)
|
||||
if reduceErr != nil {
|
||||
if isContextLimitError(reduceErr.Error()) {
|
||||
// Even the two combined partials don't fit (extreme): fall back to the
|
||||
@@ -634,15 +573,15 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
||||
}
|
||||
|
||||
// summarizeMessagesOnce performs a single tool-less summarization call.
|
||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
||||
requestMessages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||
},
|
||||
// No tools: this is a plain text summarization call.
|
||||
}
|
||||
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
|
||||
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
|
||||
recordContextPlanTrace(recorder, plan)
|
||||
stream, err := provider.StreamCompletion(ctx, plan.Request)
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -43,14 +43,8 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
|
||||
if result.SummaryText != "manual summary" {
|
||||
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
||||
}
|
||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
|
||||
}
|
||||
if result.Truncated {
|
||||
t.Fatal("Truncated = true for a projection within its limits")
|
||||
}
|
||||
if len(captured) != 1 || !strings.Contains(captured[0].Content, "first question") || !strings.Contains(captured[0].Content, "second question") {
|
||||
t.Fatalf("summarized projection = %#v, want intent from the non-preserved middle", captured)
|
||||
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
|
||||
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
|
||||
}
|
||||
if len(result.Messages) != 4 {
|
||||
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
||||
@@ -102,46 +96,11 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
|
||||
if result.SummaryText != "" {
|
||||
t.Fatalf("SummaryText = %q, want empty", result.SummaryText)
|
||||
}
|
||||
if result.ProjectedChars != 0 || result.Truncated {
|
||||
t.Fatalf("no-op projection metadata = (%d, %t), want zero values", result.ProjectedChars, result.Truncated)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Messages, messages) {
|
||||
t.Fatalf("Messages changed on no-op: %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactMessagesReturnsTruncatedProjectionMetadata(t *testing.T) {
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}}
|
||||
for index := range 20 {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: strings.Repeat("contextword ", 256) + string(rune('a'+index)),
|
||||
})
|
||||
}
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "recent answer"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "latest question"},
|
||||
)
|
||||
|
||||
var captured []zeroruntime.Message
|
||||
result, err := CompactMessages(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
captured = append([]zeroruntime.Message(nil), toSummarize...)
|
||||
return "bounded summary", nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted || !result.Truncated {
|
||||
t.Fatalf("compaction metadata = compacted %t, truncated %t; want both true", result.Compacted, result.Truncated)
|
||||
}
|
||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
|
||||
@@ -102,7 +102,7 @@ type skillEntry struct {
|
||||
body string
|
||||
}
|
||||
|
||||
// recentEdits returns files mutated by editing calls in messages
|
||||
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||
// changed in each file (from the tool's result) so it need not re-read to
|
||||
@@ -113,7 +113,6 @@ type skillEntry struct {
|
||||
// rather than an earlier, now-stale entry.
|
||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
pathByID := map[string]string{}
|
||||
noteByPath := map[string]string{}
|
||||
sequence := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
for _, call := range message.ToolCalls {
|
||||
@@ -130,24 +129,12 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
sequence = append(sequence, path)
|
||||
}
|
||||
}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, path := range message.ChangedFiles {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
sequence = append(sequence, path)
|
||||
noteByPath[path] = editNote(message.Content)
|
||||
}
|
||||
}
|
||||
order := lastSeenOrder(sequence)
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
noteByPath := map[string]string{}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||
continue
|
||||
@@ -438,20 +425,15 @@ type preservedState struct {
|
||||
}
|
||||
|
||||
type preservedTaskState struct {
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
Constraints []string `json:"constraints,omitempty"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
||||
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
||||
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
@@ -486,12 +468,7 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints),
|
||||
ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries),
|
||||
UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys),
|
||||
Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries),
|
||||
Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries),
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
@@ -507,13 +484,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||
}
|
||||
}
|
||||
freshConstraints := explicitConstraintsFromMessages(middle)
|
||||
if len(freshConstraints) > 0 {
|
||||
if task == nil {
|
||||
task = &preservedTaskState{}
|
||||
}
|
||||
task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints)
|
||||
}
|
||||
|
||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||
// plan preserved by an earlier compaction.
|
||||
@@ -547,95 +517,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
return summary
|
||||
}
|
||||
|
||||
func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string {
|
||||
var constraints []string
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) {
|
||||
continue
|
||||
}
|
||||
if _, body := projectInstructionBlock(message.Content); body != "" {
|
||||
continue
|
||||
}
|
||||
constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints)
|
||||
}
|
||||
return constraints
|
||||
}
|
||||
|
||||
func priorTaskConstraints(task *preservedTaskState) []string {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Constraints
|
||||
}
|
||||
|
||||
func priorTaskChangedFiles(task *preservedTaskState) []string {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.ChangedFiles
|
||||
}
|
||||
|
||||
func priorTaskFailures(task *preservedTaskState) []taskFailureState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.UnresolvedFailures
|
||||
}
|
||||
|
||||
func priorTaskApprovals(task *preservedTaskState) []taskApprovalState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Approvals
|
||||
}
|
||||
|
||||
func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Artifacts
|
||||
}
|
||||
|
||||
func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState {
|
||||
resolvedSet := make(map[string]struct{}, len(resolved))
|
||||
for _, key := range resolved {
|
||||
resolvedSet[key] = struct{}{}
|
||||
}
|
||||
merged := make([]taskFailureState, 0, len(older)+len(newer))
|
||||
for _, failure := range append(append([]taskFailureState(nil), older...), newer...) {
|
||||
if _, ok := resolvedSet[failure.Key]; ok {
|
||||
continue
|
||||
}
|
||||
withoutSameKey := make([]taskFailureState, 0, len(merged))
|
||||
for _, existing := range merged {
|
||||
if existing.Key != failure.Key {
|
||||
withoutSameKey = append(withoutSameKey, existing)
|
||||
}
|
||||
}
|
||||
merged = append(withoutSameKey, failure)
|
||||
}
|
||||
if len(merged) > maxTaskEvidenceEntries {
|
||||
merged = merged[len(merged)-maxTaskEvidenceEntries:]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
|
||||
merged := make([]T, 0, len(older)+len(newer))
|
||||
seen := make(map[T]struct{}, len(older)+len(newer))
|
||||
for _, value := range append(append([]T(nil), older...), newer...) {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
merged = append(merged, value)
|
||||
}
|
||||
if len(merged) > limit {
|
||||
merged = merged[len(merged)-limit:]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func capTaskObjective(objective string) string {
|
||||
objective = strings.TrimSpace(objective)
|
||||
if len(objective) <= maxTaskObjectiveBytes {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -65,9 +64,6 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||
task := newTaskState(objective, nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{
|
||||
Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"},
|
||||
}})
|
||||
messages := stateConversation()
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
@@ -84,69 +80,6 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||
}
|
||||
if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" {
|
||||
t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) {
|
||||
task := newTaskState("Please keep the change focused.", nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
||||
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
||||
}})
|
||||
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
||||
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp",
|
||||
}})
|
||||
messages := stateConversation()
|
||||
|
||||
first, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
||||
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
||||
}})
|
||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
)
|
||||
second, err := Compact(secondInput, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||
taskState: task.snapshotForCompaction(secondInput),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := parsePreservedStateBlock(second[1].Content)
|
||||
if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 {
|
||||
t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task)
|
||||
}
|
||||
if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" {
|
||||
t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures)
|
||||
}
|
||||
for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} {
|
||||
if !containsString(state.Task.Constraints, want) {
|
||||
t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const (
|
||||
compactionUserWordBudget = 256
|
||||
compactionAssistantWordBudget = 200
|
||||
compactionToolCallsPerTurn = 8
|
||||
compactionToolArgumentBytes = 120
|
||||
compactionErrorBytes = 150
|
||||
compactionResultBytes = 1200
|
||||
compactionPreviousSummaryBytes = 16 * 1024
|
||||
compactionBriefMaxBytes = 24 * 1024
|
||||
)
|
||||
|
||||
var compactionWordPattern = regexp.MustCompile(`[\p{L}\p{N}]+`)
|
||||
|
||||
type compactionProjection struct {
|
||||
messages []zeroruntime.Message
|
||||
truncated bool
|
||||
}
|
||||
|
||||
// projectCompactionInput removes reconstructible tool output before the model
|
||||
// summary call. It retains a bounded transcript of user intent, assistant
|
||||
// decisions, tool actions, and concise errors. Exact durable state is appended
|
||||
// separately from the original messages after the summary returns.
|
||||
func projectCompactionInput(messages []zeroruntime.Message) compactionProjection {
|
||||
sections := make([]string, 0, len(messages))
|
||||
previousSummary := ""
|
||||
truncated := false
|
||||
for index, message := range messages {
|
||||
switch message.Role {
|
||||
case zeroruntime.MessageRoleUser:
|
||||
content := message.Content
|
||||
if marker := strings.Index(content, preservedStateLabel); marker >= 0 {
|
||||
content = content[:marker]
|
||||
}
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if strings.HasPrefix(trimmed, summaryLabel) {
|
||||
fullSummary := strings.TrimSpace(strings.TrimPrefix(trimmed, summaryLabel))
|
||||
previousSummary = clipHeadTailBytes(fullSummary, compactionPreviousSummaryBytes)
|
||||
truncated = truncated || len(fullSummary) > compactionPreviousSummaryBytes
|
||||
continue
|
||||
}
|
||||
if content = clipInformativeWords(content, compactionUserWordBudget); content != "" {
|
||||
sections = append(sections, fmt.Sprintf("[user #%d]\n%s", index, content))
|
||||
}
|
||||
case zeroruntime.MessageRoleAssistant:
|
||||
lines := make([]string, 0, 1+len(message.ToolCalls))
|
||||
if content := clipInformativeWords(message.Content, compactionAssistantWordBudget); content != "" {
|
||||
lines = append(lines, content)
|
||||
}
|
||||
calls := message.ToolCalls
|
||||
omitted := 0
|
||||
if len(calls) > compactionToolCallsPerTurn {
|
||||
omitted = len(calls) - compactionToolCallsPerTurn
|
||||
calls = calls[omitted:]
|
||||
}
|
||||
if omitted > 0 {
|
||||
lines = append(lines, fmt.Sprintf("* (%d earlier tool calls omitted)", omitted))
|
||||
}
|
||||
for _, call := range calls {
|
||||
lines = append(lines, compactionToolCallLine(call))
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n")))
|
||||
}
|
||||
case zeroruntime.MessageRoleTool:
|
||||
// New tool-result messages carry the execution status directly, as the
|
||||
// source ToolResult does. Keep the text check for older session history
|
||||
// created before Message exposed IsError.
|
||||
name := toolNameForResult(messages, index)
|
||||
mustPreserve := name == "ask_user" || len(message.ChangedFiles) > 0
|
||||
if !mustPreserve && !message.IsError && !isLikelyToolError(message.Content) {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = "tool"
|
||||
}
|
||||
switch {
|
||||
case message.IsError || isLikelyToolError(message.Content):
|
||||
sections = append(sections, fmt.Sprintf("[tool_error #%d] %s\n%s", index, name, clipBytes(firstLine(message.Content), compactionErrorBytes)))
|
||||
case name == "ask_user":
|
||||
sections = append(sections, fmt.Sprintf("[user_answer #%d] %s\n%s", index, name, clipBytes(message.Content, compactionResultBytes)))
|
||||
default:
|
||||
sections = append(sections, fmt.Sprintf("[tool_result #%d] %s changed %s\n%s", index, name,
|
||||
clipBytes(strings.Join(message.ChangedFiles, ", "), compactionToolArgumentBytes), clipBytes(firstLine(message.Content), compactionErrorBytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(sections) == 0 && previousSummary == "" {
|
||||
return compactionProjection{}
|
||||
}
|
||||
brief, briefTruncated := capCompactionBrief(strings.Join(sections, "\n\n"))
|
||||
truncated = truncated || briefTruncated
|
||||
if previousSummary != "" {
|
||||
brief = "[previous summary]\n" + previousSummary + "\n\n" + brief
|
||||
}
|
||||
return compactionProjection{
|
||||
messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: strings.TrimSpace(brief)}},
|
||||
truncated: truncated,
|
||||
}
|
||||
}
|
||||
|
||||
func capCompactionBrief(brief string) (string, bool) {
|
||||
if len(brief) <= compactionBriefMaxBytes {
|
||||
return brief, false
|
||||
}
|
||||
marker := "\n\n...[middle transcript omitted to fit compaction budget]...\n\n"
|
||||
available := compactionBriefMaxBytes - len(marker)
|
||||
// Keep both chronological edges: the oldest material has not necessarily
|
||||
// been summarized before, while the newest material is most actionable.
|
||||
headBytes := available * 2 / 5
|
||||
tailBytes := available - headBytes
|
||||
head := clipPrefixAtBoundary(brief, headBytes)
|
||||
tail := clipSuffixAtBoundary(brief, tailBytes)
|
||||
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail), true
|
||||
}
|
||||
|
||||
func compactionToolCallLine(call zeroruntime.ToolCall) string {
|
||||
detail := ""
|
||||
var arguments map[string]any
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
|
||||
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
detail = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if detail == "" && call.Name == "ask_user" {
|
||||
detail = askUserQuestionsDetail(arguments)
|
||||
}
|
||||
if detail == "" && call.Name == "apply_patch" {
|
||||
if patch, ok := arguments["patch"].(string); ok {
|
||||
detail = strings.Join(patchPaths(patch), ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
detail = commandFromArguments(call.Arguments)
|
||||
}
|
||||
if detail == "" {
|
||||
return "* " + call.Name
|
||||
}
|
||||
return fmt.Sprintf("* %s %q", call.Name, clipBytes(detail, compactionToolArgumentBytes))
|
||||
}
|
||||
|
||||
func isLikelyToolError(content string) bool {
|
||||
prefix := strings.TrimSpace(content)
|
||||
if len(prefix) > compactionErrorBytes {
|
||||
prefix = prefix[:compactionErrorBytes]
|
||||
}
|
||||
lower := strings.ToLower(prefix)
|
||||
for _, prefix := range []string{"error:", "tool error:", "failed:", "permission denied", "command failed"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clipInformativeWords(text string, limit int) string {
|
||||
flat := strings.Join(strings.Fields(text), " ")
|
||||
if flat == "" || limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
count := 0
|
||||
for _, location := range compactionWordPattern.FindAllStringIndex(flat, -1) {
|
||||
word := strings.ToLower(flat[location[0]:location[1]])
|
||||
if compactionStopWords[word] {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count > limit {
|
||||
end := location[0]
|
||||
return strings.TrimSpace(flat[:end]) + "...(truncated)"
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
func clipBytes(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit <= 3 {
|
||||
end := limit
|
||||
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
return text[:end]
|
||||
}
|
||||
end := limit - 3
|
||||
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
return strings.TrimSpace(text[:end]) + "..."
|
||||
}
|
||||
|
||||
func clipHeadTailBytes(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
marker := "\n...[middle omitted]...\n"
|
||||
if limit <= len(marker) {
|
||||
return clipBytes(text, limit)
|
||||
}
|
||||
available := limit - len(marker)
|
||||
head := clipPrefixAtBoundary(text, available/2)
|
||||
tail := clipSuffixAtBoundary(text, available-available/2)
|
||||
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail)
|
||||
}
|
||||
|
||||
func clipPrefixAtBoundary(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
end := limit
|
||||
for end > 0 && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
if boundary := strings.LastIndex(text[:end], "\n\n["); boundary > 0 {
|
||||
end = boundary
|
||||
}
|
||||
return text[:end]
|
||||
}
|
||||
|
||||
func clipSuffixAtBoundary(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
start := len(text) - limit
|
||||
for start < len(text) && text[start]&0xc0 == 0x80 {
|
||||
start++
|
||||
}
|
||||
if boundary := strings.Index(text[start:], "\n\n["); boundary >= 0 {
|
||||
start += boundary + 2
|
||||
}
|
||||
return text[start:]
|
||||
}
|
||||
|
||||
func askUserQuestionsDetail(arguments map[string]any) string {
|
||||
raw, ok := arguments["questions"].([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
questions := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if question, ok := object["question"].(string); ok && strings.TrimSpace(question) != "" {
|
||||
questions = append(questions, strings.TrimSpace(question))
|
||||
}
|
||||
}
|
||||
return strings.Join(questions, " | ")
|
||||
}
|
||||
|
||||
func patchPaths(patch string) []string {
|
||||
var paths []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(patch, "\n") {
|
||||
for _, prefix := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
if path != "" && !seen[path] {
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
var compactionStopWords = map[string]bool{
|
||||
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true,
|
||||
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
|
||||
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
|
||||
"should": true, "may": true, "might": true, "shall": true, "can": true, "need": true, "must": true,
|
||||
"to": true, "of": true, "in": true, "for": true, "on": true, "with": true, "at": true,
|
||||
"by": true, "from": true, "as": true, "into": true, "through": true, "during": true,
|
||||
"before": true, "after": true, "above": true, "below": true, "between": true, "under": true, "over": true,
|
||||
"and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true,
|
||||
"both": true, "either": true, "neither": true, "each": true, "every": true, "all": true,
|
||||
"any": true, "few": true, "more": true, "most": true, "other": true, "some": true, "such": true, "no": true,
|
||||
"that": true, "this": true, "these": true, "those": true, "it": true, "its": true,
|
||||
"i": true, "me": true, "my": true, "we": true, "our": true, "you": true, "your": true,
|
||||
"he": true, "him": true, "his": true, "she": true, "her": true, "they": true, "them": true, "their": true,
|
||||
"who": true, "which": true, "what": true, "if": true, "then": true, "than": true,
|
||||
"when": true, "where": true, "how": true, "just": true, "also": true,
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
|
||||
for _, limit := range []int{0, 1, 2, 3} {
|
||||
if clipped := clipBytes("世界", limit); len(clipped) > limit || !utf8.ValidString(clipped) {
|
||||
t.Fatalf("clipBytes limit %d returned invalid result %q", limit, clipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundaryClippersHandleNonPositiveLimits(t *testing.T) {
|
||||
for _, clip := range []func(string, int) string{clipPrefixAtBoundary, clipSuffixAtBoundary} {
|
||||
for _, limit := range []int{-1, 0} {
|
||||
if got := clip("世界", limit); got != "" {
|
||||
t.Fatalf("clipper limit %d = %q, want empty", limit, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) {
|
||||
largeBody := strings.Repeat("recoverable source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Inspect the parser and preserve its current behavior."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "I will inspect before editing.", ToolCalls: []zeroruntime.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"internal/parser.go"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "read", Content: largeBody},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "test", Name: "exec_command", Arguments: `{"cmd":"go test ./internal/parser"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "test", Content: "Error: parser regression\nlong diagnostic details"},
|
||||
}
|
||||
|
||||
projection := projectCompactionInput(messages)
|
||||
projected := projection.messages
|
||||
if len(projected) != 1 {
|
||||
t.Fatalf("projection = %#v, want one compact message", projected)
|
||||
}
|
||||
content := projected[0].Content
|
||||
for _, want := range []string{"Inspect the parser", `read_file "internal/parser.go"`, `exec_command "go test ./internal/parser"`, "Error: parser regression"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "recoverable source line") || strings.Contains(content, "long diagnostic details") {
|
||||
t.Fatalf("projection retained reconstructible or overlong tool output: %q", content)
|
||||
}
|
||||
if estimateTokens(projected)*20 >= estimateTokens(messages) {
|
||||
t.Fatalf("projection did not materially reduce tokens: before=%d after=%d", estimateTokens(messages), estimateTokens(projected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true},
|
||||
}
|
||||
|
||||
projected := projectCompactionInput(messages).messages
|
||||
if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") {
|
||||
t.Fatalf("structured tool error was dropped from projection: %#v", projected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "ask", Name: "ask_user", Arguments: `{"questions":[{"question":"Which database should remain?"}]}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
|
||||
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
|
||||
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "patch", Content: "Done!", ChangedFiles: []string{"internal/db.go"}},
|
||||
}
|
||||
|
||||
content := projectCompactionInput(messages).messages[0].Content
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) {
|
||||
calls := make([]zeroruntime.ToolCall, 12)
|
||||
for index := range calls {
|
||||
calls[index] = zeroruntime.ToolCall{Name: "read_file", Arguments: `{"path":"file.go"}`}
|
||||
}
|
||||
projected := projectCompactionInput([]zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: calls}}).messages
|
||||
content := projected[0].Content
|
||||
if strings.Count(content, "* read_file") != compactionToolCallsPerTurn || !strings.Contains(content, "4 earlier tool calls omitted") {
|
||||
t.Fatalf("tool-call tail was not bounded: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputCarriesPreviousSummaryOutsideBriefCap(t *testing.T) {
|
||||
previous := "keep the earlier architecture decision\n" + strings.Repeat("prior detail ", 2000) + "\nkeep the newest prior decision"
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\n" + previous + "\n\n" + preservedStateLabel + "\n{}"}}
|
||||
for index := range 500 {
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("later transcript detail ", 20)})
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "later request " + strings.Repeat("context ", 20) + string(rune('a'+index%26))})
|
||||
}
|
||||
|
||||
projection := projectCompactionInput(messages)
|
||||
content := projection.messages[0].Content
|
||||
if !strings.Contains(content, "[previous summary]\nkeep the earlier architecture decision") || !strings.Contains(content, "keep the newest prior decision") {
|
||||
t.Fatalf("previous summary was lost when the brief tail was capped: %q", content)
|
||||
}
|
||||
if !strings.Contains(content, "middle transcript omitted to fit compaction budget") {
|
||||
t.Fatalf("oversized brief was not head/tail capped: %q", content)
|
||||
}
|
||||
if !projection.truncated {
|
||||
t.Fatal("projection did not structurally report truncation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputDoesNotInferTruncationFromUserText(t *testing.T) {
|
||||
projection := projectCompactionInput([]zeroruntime.Message{{
|
||||
Role: zeroruntime.MessageRoleUser, Content: "Keep this literal marker: [middle omitted]",
|
||||
}})
|
||||
if projection.truncated {
|
||||
t.Fatal("literal user text produced a false truncation signal")
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,11 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e3", Name: "apply_patch", Arguments: `{"patch":"*** Update File: internal/baz.go"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e3", Content: "Done!", ChangedFiles: []string{"internal/baz.go"}},
|
||||
}
|
||||
|
||||
edits := recentEdits(messages)
|
||||
if len(edits) != 3 {
|
||||
t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits)
|
||||
if len(edits) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
}
|
||||
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||
@@ -36,9 +32,6 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||
}
|
||||
if edits[2].name != "internal/baz.go" || edits[2].body != "Done!" {
|
||||
t.Fatalf("third edit = %#v, want apply_patch changed file with its note", edits[2])
|
||||
}
|
||||
}
|
||||
|
||||
// After compaction elides the editing turns, the preserved-state block still
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -59,15 +58,12 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
|
||||
if last.Content != "most recent question" {
|
||||
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
||||
}
|
||||
// The summarizer receives a compact semantic projection of the middle rather
|
||||
// than reconstructible raw tool output.
|
||||
if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser {
|
||||
t.Fatalf("expected one projected summary input, got %#v", captured)
|
||||
// The summarized middle excludes system and preserved suffix.
|
||||
if len(captured) != 3 {
|
||||
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
|
||||
}
|
||||
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
|
||||
if !strings.Contains(captured[0].Content, want) {
|
||||
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
|
||||
}
|
||||
if captured[0].Content != "first question" {
|
||||
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
|
||||
}
|
||||
// Compaction must shrink the conversation.
|
||||
if estimateTokens(result) >= estimateTokens(messages) {
|
||||
@@ -429,7 +425,6 @@ type reactiveProvider struct {
|
||||
summarizeCalls int
|
||||
turnRequests int
|
||||
failedOnce bool
|
||||
connectError bool
|
||||
bigText string
|
||||
finalText string
|
||||
}
|
||||
@@ -451,9 +446,6 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
||||
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
|
||||
case provider.turnRequests == 2 && !provider.failedOnce:
|
||||
provider.failedOnce = true
|
||||
if provider.connectError {
|
||||
return nil, errors.New("context_length_exceeded")
|
||||
}
|
||||
return streamEvents([]zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
|
||||
}), nil
|
||||
@@ -462,39 +454,6 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectErrorCompactionRecordsReplacementPlan(t *testing.T) {
|
||||
provider := &reactiveProvider{
|
||||
connectError: true,
|
||||
bigText: strings.Repeat("b", 6000),
|
||||
finalText: "recovered",
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
recorder := trace.NewRecorder("connect-compaction", "run-1", "test")
|
||||
var contextPlans []ContextBreakdown
|
||||
|
||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
ContextWindow: 10_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
Trace: recorder,
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
contextPlans = append(contextPlans, breakdown)
|
||||
},
|
||||
})
|
||||
if err != nil || result.FinalAnswer != "recovered" {
|
||||
t.Fatalf("connect-error compaction failed: result=%#v err=%v", result, err)
|
||||
}
|
||||
if len(contextPlans) != 3 || contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
|
||||
t.Fatalf("replacement request context not reported: %#v", contextPlans)
|
||||
}
|
||||
gotTrace := recorder.Finish()
|
||||
if len(gotTrace.PrefixHashes) != 4 || gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
|
||||
t.Fatalf("replacement request trace not recorded: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
provider := &reactiveProvider{
|
||||
bigText: strings.Repeat("b", 6000),
|
||||
@@ -506,8 +465,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
|
||||
var contextPlans []ContextBreakdown
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -516,10 +473,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
ContextWindow: 10_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
Trace: recorder,
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
contextPlans = append(contextPlans, breakdown)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
|
||||
@@ -530,25 +483,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
if provider.summarizeCalls == 0 {
|
||||
t.Fatal("expected reactive compaction to invoke the summarizer")
|
||||
}
|
||||
if len(contextPlans) != 3 {
|
||||
t.Fatalf("main request context callbacks = %d, want initial turns plus compacted retry", len(contextPlans))
|
||||
}
|
||||
if contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
|
||||
t.Fatalf("retry context was not updated after compaction: before=%d after=%d", contextPlans[1].MessageTokens, contextPlans[2].MessageTokens)
|
||||
}
|
||||
gotTrace := recorder.Finish()
|
||||
if len(gotTrace.PrefixHashes) != 4 {
|
||||
t.Fatalf("prefix evidence count = %d, want two turns, compaction, and retry: %#v", len(gotTrace.PrefixHashes), gotTrace.PrefixHashes)
|
||||
}
|
||||
if gotTrace.PrefixHashes[0].InvalidationReason != "initial" ||
|
||||
gotTrace.PrefixHashes[1].InvalidationReason != "unchanged" ||
|
||||
gotTrace.PrefixHashes[2].InvalidationReason != "initial" ||
|
||||
gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
|
||||
t.Fatalf("unexpected request-specific invalidation evidence: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
if gotTrace.PrefixHashes[2].CompletePrefixHash == gotTrace.PrefixHashes[1].CompletePrefixHash {
|
||||
t.Fatalf("compaction fingerprint reused the main request prefix: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
}
|
||||
|
||||
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
|
||||
|
||||
@@ -23,24 +23,6 @@ type ContextBreakdown struct {
|
||||
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
|
||||
ContextWindow int // model context window; 0 when unknown
|
||||
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
|
||||
// Blocks explain why each model-visible category is present without exposing
|
||||
// its content. The planner currently preserves all three categories.
|
||||
Blocks []ContextBlock
|
||||
// CompletePrefixHash identifies the provider-visible system+tool prefix.
|
||||
CompletePrefixHash string
|
||||
// PrefixInvalidationReason is "initial", "unchanged", or a stable comma-
|
||||
// separated list of changed prefix categories.
|
||||
PrefixInvalidationReason string
|
||||
}
|
||||
|
||||
// ContextBlock is content-free evidence for one model-visible context category.
|
||||
type ContextBlock struct {
|
||||
Kind string
|
||||
Tokens int
|
||||
CacheClass string
|
||||
Authority string
|
||||
Recoverable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// MeasureContext estimates the per-category token footprint of a request: the
|
||||
@@ -59,24 +41,6 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
||||
ContextWindow: contextWindow,
|
||||
}
|
||||
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
|
||||
if breakdown.SystemTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "system", Tokens: breakdown.SystemTokens, CacheClass: "stable_prefix",
|
||||
Authority: "required", Reason: "instructions, workspace policy, and safety policy",
|
||||
})
|
||||
}
|
||||
if breakdown.ToolTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "tools", Tokens: breakdown.ToolTokens, CacheClass: "stable_prefix",
|
||||
Authority: "capability", Reason: "currently permitted and selected tool definitions",
|
||||
})
|
||||
}
|
||||
if breakdown.MessageTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "conversation", Tokens: breakdown.MessageTokens, CacheClass: "dynamic_tail",
|
||||
Authority: "transcript", Reason: "valid provider replay history and current task context",
|
||||
})
|
||||
}
|
||||
if contextWindow > 0 {
|
||||
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
|
||||
if breakdown.UsedFraction != wantFraction {
|
||||
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
|
||||
}
|
||||
if len(breakdown.Blocks) != 3 {
|
||||
t.Fatalf("Blocks = %#v, want system/tools/conversation", breakdown.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type contextPlannerConfig struct {
|
||||
contextWindow int
|
||||
promptCacheKey string
|
||||
serviceTier string
|
||||
promptParts systemPromptParts
|
||||
}
|
||||
|
||||
// contextPlanner is the single seam for constructing model-visible requests.
|
||||
// Planning is deterministic and does not retrieve content, execute tools, or
|
||||
// change permissions. The initial implementation deliberately preserves every
|
||||
// message and tool definition while making composition and cache drift
|
||||
// inspectable; later selection policies must continue to cross this seam.
|
||||
type contextPlanner struct {
|
||||
config contextPlannerConfig
|
||||
previousPrefix prefixFingerprint
|
||||
hasPrevious bool
|
||||
toolSnapshotKey string
|
||||
toolSnapshot []zeroruntime.ToolDefinition
|
||||
}
|
||||
|
||||
type contextPlan struct {
|
||||
Request zeroruntime.CompletionRequest
|
||||
Breakdown ContextBreakdown
|
||||
PrefixFingerprint prefixFingerprint
|
||||
}
|
||||
|
||||
func newContextPlanner(config contextPlannerConfig) *contextPlanner {
|
||||
return &contextPlanner{config: config}
|
||||
}
|
||||
|
||||
// Plan returns a provider request snapshot plus content-free accounting.
|
||||
// It intentionally performs no relevance filtering: preserving current model
|
||||
// capability is the baseline contract for future planner policies.
|
||||
func (planner *contextPlanner) Plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string) contextPlan {
|
||||
return planner.plan(messages, toolDefs, reasoningEffort, planner.config.promptParts)
|
||||
}
|
||||
|
||||
// planWithPromptParts plans a request whose stable system sections differ from
|
||||
// the planner's configured main-run prompt, such as a compaction summary call.
|
||||
func (planner *contextPlanner) planWithPromptParts(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
|
||||
return planner.plan(messages, toolDefs, reasoningEffort, parts)
|
||||
}
|
||||
|
||||
func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
|
||||
requestMessages := copyMessages(messages)
|
||||
requestSystemPrompt := leadingSystemContent(requestMessages)
|
||||
if parts.prompt != requestSystemPrompt {
|
||||
// Component boundaries from a different request would make the detailed
|
||||
// invalidation reason misleading. Retain the exact request prefix and
|
||||
// classify it conservatively as an unstructured system prompt instead.
|
||||
parts = systemPromptParts{prompt: requestSystemPrompt}
|
||||
}
|
||||
fingerprint := computePrefixFingerprint(buildPromptSubstringsFromParts(parts, toolDefs))
|
||||
request := zeroruntime.CompletionRequest{
|
||||
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)
|
||||
breakdown.CompletePrefixHash = fingerprint.CompletePrefixHash
|
||||
var previous *prefixFingerprint
|
||||
if planner.hasPrevious {
|
||||
previous = &planner.previousPrefix
|
||||
}
|
||||
breakdown.PrefixInvalidationReason = explainPrefixChange(previous, fingerprint)
|
||||
planner.previousPrefix = fingerprint
|
||||
planner.hasPrevious = true
|
||||
return contextPlan{
|
||||
Request: request,
|
||||
Breakdown: breakdown,
|
||||
PrefixFingerprint: fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotTools freezes provider-visible tool definitions when their semantic
|
||||
// fingerprint changes. Stable turns reuse the frozen schemas instead of
|
||||
// recursively cloning every map and slice for every request.
|
||||
func (planner *contextPlanner) snapshotTools(toolDefs []zeroruntime.ToolDefinition, fingerprint prefixFingerprint) []zeroruntime.ToolDefinition {
|
||||
key := fingerprint.ToolsHash + "\x00" + fingerprint.SchemaHash
|
||||
if planner.toolSnapshotKey != key {
|
||||
planner.toolSnapshot = copyToolDefinitions(toolDefs)
|
||||
planner.toolSnapshotKey = key
|
||||
}
|
||||
return planner.toolSnapshot
|
||||
}
|
||||
|
||||
func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.ToolDefinition {
|
||||
if toolDefs == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make([]zeroruntime.ToolDefinition, len(toolDefs))
|
||||
for index, definition := range toolDefs {
|
||||
copied[index] = definition
|
||||
copied[index].Parameters = copySchemaMap(definition.Parameters)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func copySchemaMap(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]any, len(schema))
|
||||
for key, value := range schema {
|
||||
copied[key] = copySchemaValue(value)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func copySchemaValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return copySchemaMap(typed)
|
||||
case []any:
|
||||
copied := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
copied[index] = copySchemaValue(item)
|
||||
}
|
||||
return copied
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func leadingSystemContent(messages []zeroruntime.Message) string {
|
||||
contents := make([]string, 0, 1)
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleSystem {
|
||||
break
|
||||
}
|
||||
contents = append(contents, message.Content)
|
||||
}
|
||||
return strings.Join(contents, "\n\n")
|
||||
}
|
||||
|
||||
func explainPrefixChange(previous *prefixFingerprint, current prefixFingerprint) string {
|
||||
if previous == nil {
|
||||
return "initial"
|
||||
}
|
||||
if previous.CompletePrefixHash == current.CompletePrefixHash {
|
||||
return "unchanged"
|
||||
}
|
||||
reasons := make([]string, 0, 3)
|
||||
if previous.SystemPromptHash != current.SystemPromptHash {
|
||||
parts := make([]string, 0, 4)
|
||||
if previous.BaseInstructionsHash != current.BaseInstructionsHash {
|
||||
parts = append(parts, "base_instructions")
|
||||
}
|
||||
if previous.ConfirmationPolicyHash != current.ConfirmationPolicyHash {
|
||||
parts = append(parts, "confirmation_policy")
|
||||
}
|
||||
if previous.ProjectContextHash != current.ProjectContextHash {
|
||||
parts = append(parts, "project_context")
|
||||
}
|
||||
if previous.SkillsHash != current.SkillsHash {
|
||||
parts = append(parts, "skills")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "system_prompt")
|
||||
}
|
||||
reasons = append(reasons, parts...)
|
||||
}
|
||||
if previous.ToolsHash != current.ToolsHash {
|
||||
reasons = append(reasons, "tools")
|
||||
}
|
||||
if previous.SchemaHash != current.SchemaHash {
|
||||
reasons = append(reasons, "schema")
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return "prefix_changed"
|
||||
}
|
||||
return strings.Join(reasons, ",")
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "inspect this", Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{1, 2, 3}}}},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working", ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}, Reasoning: []zeroruntime.ReasoningBlock{{Provider: "test", Type: "thinking", Signature: "sig"}}},
|
||||
}
|
||||
toolDefs := []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file", Description: "Read a file",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"path"},
|
||||
"properties": map[string]any{"path": map[string]any{"type": "string"}},
|
||||
"anyOf": []any{map[string]any{"type": "object"}},
|
||||
},
|
||||
}}
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: 128_000,
|
||||
promptCacheKey: "session-1",
|
||||
serviceTier: "priority",
|
||||
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
|
||||
})
|
||||
|
||||
plan := planner.Plan(messages, toolDefs, "medium")
|
||||
want := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: "medium",
|
||||
ServiceTier: "priority",
|
||||
PromptCacheKey: "session-1",
|
||||
}
|
||||
if !reflect.DeepEqual(plan.Request, want) {
|
||||
t.Fatalf("planned request changed provider input:\n got: %#v\nwant: %#v", plan.Request, want)
|
||||
}
|
||||
stable := planner.Plan(messages, toolDefs, "medium")
|
||||
if &stable.Request.Tools[0] != &plan.Request.Tools[0] {
|
||||
t.Fatal("stable tool fingerprint rebuilt the immutable schema snapshot")
|
||||
}
|
||||
|
||||
// A plan owns its request snapshot; later caller mutation cannot change its
|
||||
// messages or its recursively nested tool schema.
|
||||
messages[1].Content = "changed"
|
||||
messages[1].Images[0].Data[0] = 9
|
||||
toolDefs[0].Name = "changed_tool"
|
||||
toolDefs[0].Parameters["required"].([]string)[0] = "other"
|
||||
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
|
||||
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
|
||||
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
|
||||
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" ||
|
||||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
|
||||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
|
||||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
|
||||
t.Fatalf("planned tools alias caller state: %#v", plan.Request.Tools[0])
|
||||
}
|
||||
|
||||
next := planner.Plan(messages, toolDefs, "medium")
|
||||
if next.PrefixFingerprint == plan.PrefixFingerprint || next.Request.Tools[0].Name != "changed_tool" {
|
||||
t.Fatalf("changed tool schema did not refresh snapshot: %#v", next)
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" {
|
||||
t.Fatalf("refresh mutated earlier request: %#v", plan.Request.Tools[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerReportsInspectableBlocks(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{contextWindow: 100_000})
|
||||
plan := planner.Plan([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: filler(400)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: filler(200)},
|
||||
}, []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read a file"}}, "")
|
||||
|
||||
if plan.Breakdown.PrefixInvalidationReason != "initial" || plan.Breakdown.CompletePrefixHash == "" {
|
||||
t.Fatalf("prefix evidence = %#v", plan.Breakdown)
|
||||
}
|
||||
if len(plan.Breakdown.Blocks) != 3 {
|
||||
t.Fatalf("blocks = %#v, want system/tools/conversation", plan.Breakdown.Blocks)
|
||||
}
|
||||
wantKinds := []string{"system", "tools", "conversation"}
|
||||
total := 0
|
||||
for index, block := range plan.Breakdown.Blocks {
|
||||
if block.Kind != wantKinds[index] || block.Tokens <= 0 || block.Reason == "" || block.Authority == "" || block.CacheClass == "" {
|
||||
t.Fatalf("block %d = %#v", index, block)
|
||||
}
|
||||
total += block.Tokens
|
||||
}
|
||||
if total != plan.Breakdown.TotalTokens {
|
||||
t.Fatalf("block tokens = %d, want total %d", total, plan.Breakdown.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerFingerprintsActualRequestSystemPrompt(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
|
||||
prompt: "configured system", baseInstructions: "configured system",
|
||||
}})
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "request-specific system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "task"},
|
||||
}
|
||||
|
||||
plan := planner.Plan(messages, nil, "")
|
||||
want := computePrefixFingerprint(buildPromptSubstringsFromParts(
|
||||
systemPromptParts{prompt: "request-specific system"}, nil,
|
||||
))
|
||||
if plan.PrefixFingerprint != want {
|
||||
t.Fatalf("fingerprint describes configured prompt instead of request:\n got: %#v\nwant: %#v", plan.PrefixFingerprint, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerExplainsPrefixInvalidation(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
|
||||
prompt: "system", baseInstructions: "base", projectContext: "project",
|
||||
}})
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}, {Role: zeroruntime.MessageRoleUser, Content: "task"}}
|
||||
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read", Parameters: map[string]any{"type": "object"}}}
|
||||
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "initial" {
|
||||
t.Fatalf("first reason = %q, want initial", got)
|
||||
}
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "unchanged" {
|
||||
t.Fatalf("stable reason = %q, want unchanged", got)
|
||||
}
|
||||
tools[0].Description = "Read an exact file"
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "tools" {
|
||||
t.Fatalf("tool reason = %q, want tools", got)
|
||||
}
|
||||
tools[0].Parameters["required"] = []any{"path"}
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "schema" {
|
||||
t.Fatalf("schema reason = %q, want schema", got)
|
||||
}
|
||||
planner.config.promptParts.prompt = "system with changed project"
|
||||
planner.config.promptParts.projectContext = "changed project"
|
||||
messages[0].Content = "system with changed project"
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "project_context" {
|
||||
t.Fatalf("project reason = %q, want project_context", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainPrefixChangeNamesEveryChangedComponent(t *testing.T) {
|
||||
baseline := prefixFingerprint{
|
||||
SystemPromptHash: "system",
|
||||
BaseInstructionsHash: "base",
|
||||
ConfirmationPolicyHash: "policy",
|
||||
ProjectContextHash: "project",
|
||||
SkillsHash: "skills",
|
||||
ToolsHash: "tools",
|
||||
SchemaHash: "schema",
|
||||
CompletePrefixHash: "complete",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*prefixFingerprint)
|
||||
want string
|
||||
}{
|
||||
{name: "base instructions", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.BaseInstructionsHash = "changed", "changed"
|
||||
}, want: "base_instructions"},
|
||||
{name: "confirmation policy", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.ConfirmationPolicyHash = "changed", "changed"
|
||||
}, want: "confirmation_policy"},
|
||||
{name: "project context", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.ProjectContextHash = "changed", "changed"
|
||||
}, want: "project_context"},
|
||||
{name: "skills", mutate: func(value *prefixFingerprint) { value.SystemPromptHash, value.SkillsHash = "changed", "changed" }, want: "skills"},
|
||||
{name: "unclassified system prompt", mutate: func(value *prefixFingerprint) { value.SystemPromptHash = "changed" }, want: "system_prompt"},
|
||||
{name: "tools and schema", mutate: func(value *prefixFingerprint) { value.ToolsHash, value.SchemaHash = "changed", "changed" }, want: "tools,schema"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := baseline
|
||||
test.mutate(¤t)
|
||||
current.CompletePrefixHash = "changed"
|
||||
if got := explainPrefixChange(&baseline, current); got != test.want {
|
||||
t.Fatalf("reason = %q, want %q", got, test.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)
|
||||
}
|
||||
|
||||
+95
-271
@@ -183,23 +183,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
promptParts := buildSystemPromptParts(options)
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: options.ContextWindow,
|
||||
promptCacheKey: options.SessionID,
|
||||
serviceTier: options.ServiceTier,
|
||||
promptParts: promptParts,
|
||||
})
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
|
||||
guards := newGuardState()
|
||||
task := newTaskState(prompt, options.Trace)
|
||||
onPermission := options.OnPermission
|
||||
options.OnPermission = func(event PermissionEvent) {
|
||||
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: event})
|
||||
if onPermission != nil {
|
||||
onPermission(event)
|
||||
}
|
||||
}
|
||||
compactor := newCompactionState(options, task)
|
||||
defer func() {
|
||||
// A final transcript comparison is observational. It records drift but
|
||||
@@ -288,15 +275,42 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Fingerprint the exact system prompt built at run start plus this turn's
|
||||
// provider-visible tool definitions in their emitted order. Reusing the
|
||||
// retained prompt parts avoids rebuilding workspace context while ensuring
|
||||
// the trace describes the same bytes carried by the request.
|
||||
if options.Trace != nil {
|
||||
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fp.SystemPromptHash,
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
SkillsHash: fp.SkillsHash,
|
||||
ToolsHash: fp.ToolsHash,
|
||||
SchemaHash: fp.SchemaHash,
|
||||
CompletePrefixHash: fp.CompletePrefixHash,
|
||||
})
|
||||
}
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
// request. A no-op when ContextWindow == 0 (compaction disabled).
|
||||
compactionSpan := options.Trace.Span(trace.SpanCompaction)
|
||||
messages = compactor.maybeCompact(ctx, provider, messages, exposed)
|
||||
compactionSpan.End()
|
||||
plan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request := plan.Request
|
||||
recordContextPlan(options, plan)
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
|
||||
// Report the per-category context budget for this turn so a surface can
|
||||
// show utilization. Opt-in: a no-op when OnContext is unset.
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
|
||||
}
|
||||
|
||||
// A transient upstream disconnect on the initial connect is retried with
|
||||
// backoff (before any content is forwarded, so no OnText is duplicated);
|
||||
@@ -321,9 +335,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// on registry+loaded, not on the messages, so they stay valid after
|
||||
// compaction. Using the bare toolDefinitions here would route through an
|
||||
// empty-loaded partition, re-hiding every already-loaded deferred tool.
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request = retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
request = zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
// Pre-content connect after a context-limit compaction: route through the
|
||||
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
||||
// whole run and re-burn every token (AUDIT-L1).
|
||||
@@ -407,9 +424,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// Reuse the SAME active-mode partition (exposed) from this turn rather
|
||||
// than the bare toolDefinitions: exposed depends on registry+loaded (not
|
||||
// the messages), so it stays valid after compaction.
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryStreamErr != nil {
|
||||
return collected, retryStreamErr
|
||||
@@ -467,9 +487,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -633,13 +656,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// between tool_results breaks strict provider replay) — same after-batch
|
||||
// rationale as turnRequestedModel above.
|
||||
var changedFilesThisBatch []string
|
||||
// Images produced by tools this turn, held until every tool_result is
|
||||
// recorded. They travel as user messages, and a user message between two
|
||||
// tool_results breaks strict provider replay — Anthropic coalesces them
|
||||
// into one user block list and requires the tool_result blocks first, so
|
||||
// interleaving yields [tool_result, text, image, tool_result] and a 400.
|
||||
// Same reason the self-correction feedback below is deferred.
|
||||
var toolImageMessages []zeroruntime.Message
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
@@ -675,7 +691,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterToolCalls, 1)
|
||||
recordOutputBudgetTrace(options.Trace, toolResult)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -689,22 +705,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.Output,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate.
|
||||
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
|
||||
toolImageMessages = append(toolImageMessages, imageMessage)
|
||||
}
|
||||
|
||||
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
|
||||
// returns context.Canceled rather than fabricating a headless answer. Stop
|
||||
@@ -715,14 +719,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
if abortErr != nil {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, abortErr
|
||||
}
|
||||
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 +737,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
|
||||
@@ -745,13 +747,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// messages stay valid for a strict provider replay (Anthropic
|
||||
// rejects a tool_use with no answering tool_result).
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
||||
result.Messages = copyMessages(messages)
|
||||
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
|
||||
@@ -764,10 +765,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
// Every tool_result for this turn is now recorded, including aborted
|
||||
// placeholders, so the images can follow without splitting them.
|
||||
messages = append(messages, toolImageMessages...)
|
||||
toolImageMessages = nil
|
||||
|
||||
// Run post-edit self-correction once over the union of files this turn
|
||||
// changed, then append any feedback after every tool_result is recorded so
|
||||
@@ -830,11 +827,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
|
||||
@@ -904,7 +896,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -928,21 +920,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
|
||||
@@ -961,32 +938,7 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
|
||||
})
|
||||
}
|
||||
|
||||
func recordContextPlan(options Options, plan contextPlan) {
|
||||
recordContextPlanTrace(options.Trace, plan)
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(plan.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
fingerprint := plan.PrefixFingerprint
|
||||
recorder.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fingerprint.SystemPromptHash,
|
||||
BaseInstructionsHash: fingerprint.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fingerprint.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fingerprint.ProjectContextHash,
|
||||
SkillsHash: fingerprint.SkillsHash,
|
||||
ToolsHash: fingerprint.ToolsHash,
|
||||
SchemaHash: fingerprint.SchemaHash,
|
||||
CompletePrefixHash: fingerprint.CompletePrefixHash,
|
||||
InvalidationReason: plan.Breakdown.PrefixInvalidationReason,
|
||||
})
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -995,9 +947,12 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *c
|
||||
// The max-turns final-answer call is a pre-content connect, often after a long
|
||||
// autonomous/cron run — route it through the reconnect helper so a single
|
||||
// transient hiccup doesn't drop the final summary (AUDIT-L1).
|
||||
plan := planner.Plan(finalMessages, toolDefs, options.ReasoningEffort)
|
||||
recordContextPlan(options, plan)
|
||||
stream, err := streamWithReconnect(ctx, provider, plan.Request, reconnectNoticeFor(options))
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
if err != nil {
|
||||
return "", messages, ""
|
||||
}
|
||||
@@ -1140,13 +1095,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 +1433,13 @@ 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 +1777,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 +1804,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 +1828,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 +1849,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 +2036,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 +2148,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 +2693,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
|
||||
@@ -2875,16 +2763,7 @@ func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
|
||||
// access and malformed payloads are left untouched so validation stays closed.
|
||||
func normalizeNoopAdditionalPermissions(args map[string]any, basePath string, engine *sandbox.Engine) {
|
||||
raw, exists := args["additional_permissions"]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
// An explicit null is the field omitted, not a request: drop the key so the
|
||||
// rest of the pipeline sees the same args a model that omitted it would send.
|
||||
// sandbox_permissions is deliberately left alone — a flagged null must reach
|
||||
// the same "no additional_permissions object was provided" error as a flagged
|
||||
// absent field rather than being silently downgraded to a different outcome.
|
||||
if raw == nil {
|
||||
delete(args, "additional_permissions")
|
||||
if !exists || raw == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
@@ -2915,41 +2794,21 @@ func normalizeNoopAdditionalPermissions(args map[string]any, basePath string, en
|
||||
}
|
||||
}
|
||||
|
||||
// omitAdditionalPermissionsHint is the one instruction that ends the retry loop
|
||||
// in #845/#847. Every additional_permissions rejection must carry it: the model
|
||||
// reaches these errors by attaching a permission object to a command that needs
|
||||
// none, so "omit it and retry" is the answer in practice, and an error that
|
||||
// only explains how to build a VALID permission object sends the model round
|
||||
// the loop again with a different invalid shape.
|
||||
const omitAdditionalPermissionsHint = "If this command does not need extra sandbox access — ordinary " +
|
||||
"read-only commands such as `git branch --show-current` do not — omit both sandbox_permissions and " +
|
||||
"additional_permissions entirely and retry; that is the fix in almost every case."
|
||||
|
||||
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
|
||||
if !shellCommandAdditionalPermissionsRequested(args) {
|
||||
// An explicit null grants nothing and carries no flag, so it is
|
||||
// indistinguishable from an omitted field; rejecting it would be a pure
|
||||
// false positive against providers that materialize every optional
|
||||
// property.
|
||||
if raw, exists := args["additional_permissions"]; exists && raw != nil {
|
||||
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf(
|
||||
"additional_permissions was provided without sandbox_permissions set to %q, so it cannot be applied. %s "+
|
||||
"Only if the command genuinely needs file or network access the sandbox denies, resend with "+
|
||||
`sandbox_permissions: %q AND a non-empty additional_permissions, for example {"network": {"enabled": true}}`,
|
||||
tools.SandboxPermissionsWithAdditionalPermissions,
|
||||
omitAdditionalPermissionsHint,
|
||||
tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
return sandbox.RequestPermissionProfile{}, false, nil
|
||||
}
|
||||
raw, ok := args["additional_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"sandbox_permissions was set to %q but no additional_permissions object was provided. %s "+
|
||||
`Otherwise include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}`,
|
||||
tools.SandboxPermissionsWithAdditionalPermissions,
|
||||
omitAdditionalPermissionsHint)
|
||||
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
|
||||
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
|
||||
"omit sandbox_permissions entirely and retry",
|
||||
tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -2965,10 +2824,8 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"additional_permissions granted nothing, so it cannot be applied. %s "+
|
||||
`Otherwise include a real permission, for example {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}`,
|
||||
omitAdditionalPermissionsHint)
|
||||
"additional_permissions must include at least one of network or file_system, for example " +
|
||||
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
@@ -3289,17 +3146,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 +3173,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 ""
|
||||
@@ -3360,7 +3222,6 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
@@ -3391,46 +3252,9 @@ func copyMessages(messages []Message) []Message {
|
||||
if message.Reasoning != nil {
|
||||
copied[index].Reasoning = append([]zeroruntime.ReasoningBlock{}, message.Reasoning...)
|
||||
}
|
||||
if message.ChangedFiles != nil {
|
||||
copied[index].ChangedFiles = append([]string(nil), message.ChangedFiles...)
|
||||
}
|
||||
// Deep-copy image attachments (slice AND each Data byte slice) so the
|
||||
// raw image bytes are never aliased across history/request/result copies.
|
||||
copied[index].Images = zeroruntime.CloneImageBlocks(message.Images)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
// toolResultImageMessage builds the user message that carries a tool's images
|
||||
// to the model, or reports false when the tool produced none.
|
||||
//
|
||||
// The text names the tool so the model can tell which call an image came from
|
||||
// when several ran in one turn — the images arrive detached from their tool
|
||||
// result, so nothing else associates them.
|
||||
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
|
||||
for _, image := range result.Images {
|
||||
if len(image.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
mediaType := zeroruntime.NormalizeImageMediaType(image.MediaType)
|
||||
if mediaType == "" {
|
||||
// Outside the provider allow-list. Dropping it beats sending bytes a
|
||||
// provider will reject and failing the whole turn.
|
||||
continue
|
||||
}
|
||||
images = append(images, zeroruntime.ImageBlock{MediaType: mediaType, Data: image.Data})
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return zeroruntime.Message{}, false
|
||||
}
|
||||
label := result.Name
|
||||
if label == "" {
|
||||
label = "tool"
|
||||
}
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + ":",
|
||||
Images: images,
|
||||
}, true
|
||||
}
|
||||
@@ -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()
|
||||
@@ -3964,11 +3592,6 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
|
||||
if !strings.Contains(strings.ToLower(placeholder), "aborted") {
|
||||
t.Fatalf("expected the placeholder result to mark the call as aborted, got %q", placeholder)
|
||||
}
|
||||
for _, message := range result.Messages {
|
||||
if message.ToolCallID == "flaky-2" && !message.IsError {
|
||||
t.Fatalf("aborted placeholder must carry error status: %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool_use in the final assistant message must have a matching result.
|
||||
for _, message := range result.Messages {
|
||||
@@ -3983,33 +3606,6 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCarriesToolErrorStatusIntoMessageHistory(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(alwaysFailingTool{})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "failed-call", ToolName: "flaky"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "failed-call"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, message := range result.Messages {
|
||||
if message.ToolCallID == "failed-call" {
|
||||
if !message.IsError {
|
||||
t.Fatalf("failed tool result lost its structured status: %#v", message)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("failed tool result missing from message history: %#v", result.Messages)
|
||||
}
|
||||
|
||||
type secretEmittingTool struct{ output string }
|
||||
|
||||
func (t secretEmittingTool) Name() string { return "leak" }
|
||||
@@ -4101,8 +3697,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
onUsageCalls := 0
|
||||
onContextCalls := 0
|
||||
var contextPlan ContextBreakdown
|
||||
rec := trace.NewRecorder("tracing-session", "run-1", "test")
|
||||
if _, err := Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "tracing-session",
|
||||
@@ -4111,10 +3705,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
Model: "test-model",
|
||||
Trace: rec,
|
||||
OnUsage: func(Usage) { onUsageCalls++ },
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
onContextCalls++
|
||||
contextPlan = breakdown
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -4141,12 +3731,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
if onUsageCalls == 0 {
|
||||
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
|
||||
}
|
||||
if onContextCalls != 1 || len(contextPlan.Blocks) != 2 || contextPlan.PrefixInvalidationReason != "initial" {
|
||||
t.Fatalf("context plan callback = calls %d, plan %#v", onContextCalls, contextPlan)
|
||||
}
|
||||
if len(tr.PrefixHashes) != 1 || tr.PrefixHashes[0].InvalidationReason != "initial" || tr.PrefixHashes[0].CompletePrefixHash != contextPlan.CompletePrefixHash {
|
||||
t.Fatalf("trace context evidence = %#v, plan %#v", tr.PrefixHashes, contextPlan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
|
||||
@@ -4172,171 +3756,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)
|
||||
}
|
||||
}
|
||||
@@ -123,21 +123,6 @@ func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintCoversTransientSystemPrompt(t *testing.T) {
|
||||
base := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
changed := base
|
||||
changed.TransientSystemPrompt = "turn-only guidance"
|
||||
|
||||
fpBase := ComputePrefixFingerprint(base, nil)
|
||||
fpChanged := ComputePrefixFingerprint(changed, nil)
|
||||
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
||||
t.Fatal("SystemPromptHash must change for transient system guidance")
|
||||
}
|
||||
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
||||
t.Fatal("CompletePrefixHash must cover transient system guidance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
|
||||
// substrings helper for default Options: which substrings are non-empty,
|
||||
// which are empty, and that the substring-to-hash round-trip is lossless.
|
||||
|
||||
@@ -95,9 +95,6 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
core = fallbackSystemPrompt
|
||||
}
|
||||
sections := []string{core}
|
||||
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
|
||||
sections = append(sections, transient)
|
||||
}
|
||||
if addendum := modelPromptAddendum(options.Model); addendum != "" {
|
||||
sections = append(sections, addendum)
|
||||
}
|
||||
@@ -121,9 +118,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 +679,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 ""
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,11 @@ work.
|
||||
read-before-edit discipline: inspect the target file and nearby callers,
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** Use update_plan for implementation or investigation that has at
|
||||
least three meaningful dependent steps, then keep it current at milestones.
|
||||
Mark completed work and the next in-progress step together when practical;
|
||||
do not spend separate turns updating it after every file or command. Keep at
|
||||
most one item in_progress. Skip plans for simple lookups, explanations, code
|
||||
navigation, and other short tasks. Never create a plan after the work is
|
||||
already complete merely to describe what you did.
|
||||
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
||||
keep it current at meaningful milestones. Mark completed work and the next
|
||||
in-progress step together when practical; do not spend separate model turns
|
||||
updating the plan after every file or command. Keep at most one item
|
||||
in_progress. Skip the plan for trivial one-step tasks.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
@@ -51,13 +49,9 @@ work.
|
||||
## Editing discipline
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
write_file, edit_file, apply_patch - over shelling out to
|
||||
cat/sed/awk/python for file operations.
|
||||
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
|
||||
apply_patch - over shelling out to cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Prefer read_minified_file when initially exploring source code; it preserves
|
||||
code while removing comments and redundant whitespace. Use read_file when
|
||||
exact text, comments, or line numbers are needed.
|
||||
- Keep edits focused and reviewable. A single patch may update several related
|
||||
files when they form one coherent change; do not hide unrelated edits in a
|
||||
bulk shell or script rewrite.
|
||||
|
||||
@@ -71,6 +71,5 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||
- Be concise and concrete. When you run a shell command with side effects, state
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan only for work with at least three meaningful dependent steps;
|
||||
skip it for simple lookups and never create one after the work is complete.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
</model_guidance>`
|
||||
@@ -51,16 +51,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransientSystemPromptIsIncludedOnlyWhenProvided(t *testing.T) {
|
||||
const transient = "Handle this peer request under the current session policy."
|
||||
if prompt := buildSystemPrompt(Options{}); strings.Contains(prompt, transient) {
|
||||
t.Fatal("ordinary system prompt unexpectedly contains transient instructions")
|
||||
}
|
||||
if prompt := buildSystemPrompt(Options{TransientSystemPrompt: transient}); !strings.Contains(prompt, transient) {
|
||||
t.Fatalf("transient instructions missing from system prompt:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
for _, want := range []string{
|
||||
|
||||
+10
-217
@@ -1,14 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
@@ -56,41 +52,17 @@ type taskVerificationState struct {
|
||||
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type taskFailureState struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
type taskApprovalState struct {
|
||||
Tool string `json:"tool"`
|
||||
Decision PermissionDecisionAction `json:"decision,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type taskArtifactState struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type taskStateSnapshot struct {
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
Constraints []string `json:"constraints,omitempty"`
|
||||
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
||||
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
||||
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
||||
ResolvedFailureKeys []string `json:"-"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
}
|
||||
|
||||
type taskStateEventKind int
|
||||
@@ -100,7 +72,6 @@ const (
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
taskStateEventPermission
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
@@ -109,7 +80,6 @@ type taskStateEvent struct {
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
permission PermissionEvent
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
@@ -131,7 +101,6 @@ func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
@@ -165,7 +134,6 @@ func (state *taskState) observe(event taskStateEvent) bool {
|
||||
}
|
||||
}
|
||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||
state.observeToolEvidence(event.arguments, event.toolResult)
|
||||
changed = true
|
||||
case taskStateEventVerification:
|
||||
if event.verification == OutcomeDisabled {
|
||||
@@ -192,10 +160,6 @@ func (state *taskState) observe(event taskStateEvent) bool {
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
}
|
||||
changed = true
|
||||
case taskStateEventPermission:
|
||||
state.markActive()
|
||||
state.observePermission(event.permission)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
state.snapshotValue.Revision++
|
||||
@@ -219,180 +183,9 @@ func (state *taskState) snapshot() taskStateSnapshot {
|
||||
snapshot := state.snapshotValue
|
||||
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
||||
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
||||
snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...)
|
||||
snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...)
|
||||
snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...)
|
||||
snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...)
|
||||
snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const (
|
||||
maxTaskEvidenceEntries = 8
|
||||
maxTaskEvidenceBytes = 320
|
||||
maxTaskConstraints = 10
|
||||
)
|
||||
|
||||
func (state *taskState) observeToolEvidence(arguments string, result ToolResult) {
|
||||
command := capTaskEvidence(commandFromArguments(arguments))
|
||||
key := taskFailureKey(result.Name, command, arguments)
|
||||
if result.Status == tools.StatusOK {
|
||||
if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) {
|
||||
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
||||
state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries)
|
||||
}
|
||||
} else {
|
||||
state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key)
|
||||
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
||||
state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{
|
||||
Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)),
|
||||
}, maxTaskEvidenceEntries)
|
||||
}
|
||||
for _, metaKey := range []string{"spill_path", "artifact_path"} {
|
||||
if path := strings.TrimSpace(result.Meta[metaKey]); path != "" {
|
||||
state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts,
|
||||
taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func taskFailureKey(toolName, command, arguments string) string {
|
||||
identity := command
|
||||
if identity == "" {
|
||||
canonical := []byte(strings.TrimSpace(arguments))
|
||||
var value any
|
||||
if json.Unmarshal(canonical, &value) == nil {
|
||||
if encoded, err := json.Marshal(value); err == nil {
|
||||
canonical = encoded
|
||||
}
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
identity = fmt.Sprintf("args:%x", digest[:8])
|
||||
}
|
||||
return strings.TrimSpace(toolName) + "\x00" + identity
|
||||
}
|
||||
|
||||
func (state *taskState) observePermission(event PermissionEvent) {
|
||||
decision := event.DecisionAction
|
||||
if decision == "" {
|
||||
decision = PermissionDecisionAction(event.Action)
|
||||
}
|
||||
state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{
|
||||
Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope),
|
||||
Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)),
|
||||
}, maxTaskEvidenceEntries)
|
||||
}
|
||||
|
||||
func commandFromArguments(arguments string) string {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"cmd", "command", "script", "shell"} {
|
||||
var command string
|
||||
if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil {
|
||||
return strings.TrimSpace(command)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstLine(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if index := strings.IndexByte(value, '\n'); index >= 0 {
|
||||
value = value[:index]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func capTaskEvidence(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= maxTaskEvidenceBytes {
|
||||
return value
|
||||
}
|
||||
limit := maxTaskEvidenceBytes - 3
|
||||
for limit > 0 && !utf8.RuneStart(value[limit]) {
|
||||
limit--
|
||||
}
|
||||
return strings.TrimSpace(value[:limit]) + "..."
|
||||
}
|
||||
|
||||
var explicitConstraintPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`),
|
||||
regexp.MustCompile(`(?i)\bdon'?t want\b`),
|
||||
regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`),
|
||||
regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`),
|
||||
regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`),
|
||||
regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`),
|
||||
}
|
||||
|
||||
func extractExplicitConstraints(text string) []string {
|
||||
var constraints []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") {
|
||||
continue
|
||||
}
|
||||
for _, pattern := range explicitConstraintPatterns {
|
||||
if pattern.MatchString(line) {
|
||||
constraints = appendBoundedUnique(constraints, line, maxTaskConstraints)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(constraints) == maxTaskConstraints {
|
||||
break
|
||||
}
|
||||
}
|
||||
return constraints
|
||||
}
|
||||
|
||||
func appendBounded[T any](values []T, value T, limit int) []T {
|
||||
values = append(values, value)
|
||||
if len(values) > limit {
|
||||
values = values[len(values)-limit:]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T {
|
||||
filtered := values[:0]
|
||||
for _, existing := range values {
|
||||
if existing != value {
|
||||
filtered = append(filtered, existing)
|
||||
}
|
||||
}
|
||||
return appendBounded(filtered, value, limit)
|
||||
}
|
||||
|
||||
func hasTaskFailure(values []taskFailureState, key string) bool {
|
||||
for _, value := range values {
|
||||
if value.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeTaskFailure(values []taskFailureState, key string) []taskFailureState {
|
||||
out := values[:0]
|
||||
for _, value := range values {
|
||||
if value.Key != key {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeString(values []string, target string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value != target {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// observePlanParity compares only the latest plan projection with the plan tool
|
||||
// calls still present in messages. It mutates the snapshot and emits when the
|
||||
// parity value changes; objective, tool, and verification fields are not part of
|
||||
|
||||
@@ -115,68 +115,6 @@ func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
|
||||
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
|
||||
arguments := `{"cmd":"go test ./internal/agent"}`
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
||||
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
|
||||
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
|
||||
}})
|
||||
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
||||
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
|
||||
Scope: "/tmp", DecisionReason: "write a temporary fixture",
|
||||
}})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
|
||||
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
|
||||
}
|
||||
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
|
||||
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
|
||||
}
|
||||
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
|
||||
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
|
||||
}
|
||||
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
|
||||
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
|
||||
}})
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
|
||||
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
|
||||
}
|
||||
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
||||
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
|
||||
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
|
||||
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
|
||||
}})
|
||||
snapshot = state.snapshot()
|
||||
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
|
||||
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
|
||||
}
|
||||
for _, key := range snapshot.ResolvedFailureKeys {
|
||||
if key == snapshot.UnresolvedFailures[0].Key {
|
||||
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// imageTool returns a tool result carrying an image, the way a screenshot tool
|
||||
// should be able to.
|
||||
type imageTool struct {
|
||||
media string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (imageTool) Name() string { return "capture" }
|
||||
func (imageTool) Description() string { return "Captures an image." }
|
||||
func (imageTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (imageTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t imageTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "Captured a screenshot.",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: t.media, Data: t.data}},
|
||||
}
|
||||
}
|
||||
|
||||
// A tool that produces an image must get that image in front of the model.
|
||||
//
|
||||
// It cannot ride the tool-result message: every provider drops images there.
|
||||
// Anthropic maps a tool result to a tool_result block with string content,
|
||||
// Gemini to a functionResponse, and OpenAI guards its image parts to the user
|
||||
// role explicitly. So the image has to arrive as a following user message,
|
||||
// which is also the only shape that keeps one tool result per tool call.
|
||||
func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "I can see it."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
var carrier *zeroruntime.Message
|
||||
for index := range result.Messages {
|
||||
if len(result.Messages[index].Images) > 0 {
|
||||
carrier = &result.Messages[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if carrier == nil {
|
||||
t.Fatal("no message carried the tool's image; the model never saw it")
|
||||
}
|
||||
if carrier.Role != zeroruntime.MessageRoleUser {
|
||||
t.Errorf("image rode a %q message; every provider only serializes images on the user role", carrier.Role)
|
||||
}
|
||||
if got := carrier.Images[0].MediaType; got != "image/png" {
|
||||
t.Errorf("media type = %q, want image/png", got)
|
||||
}
|
||||
|
||||
// The tool-result pairing must be untouched: one tool result per tool call.
|
||||
toolResults := 0
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResults++
|
||||
}
|
||||
}
|
||||
if toolResults != 1 {
|
||||
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
|
||||
}
|
||||
}
|
||||
|
||||
// textTool is a second, image-free tool so a turn can carry two calls.
|
||||
type textTool struct{}
|
||||
|
||||
func (textTool) Name() string { return "note" }
|
||||
func (textTool) Description() string { return "Notes something." }
|
||||
func (textTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (textTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
|
||||
func (textTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "noted"}
|
||||
}
|
||||
|
||||
// Tool results for one assistant turn must stay contiguous. The image messages
|
||||
// are user-role, and a user message between two tool_results breaks strict
|
||||
// provider replay: Anthropic coalesces consecutive user content into one block
|
||||
// list and requires tool_result blocks first, so interleaving produces
|
||||
// [tool_result, text, image, tool_result] and the request is rejected.
|
||||
//
|
||||
// The first version of this feature appended each image immediately after its
|
||||
// own tool result, which is exactly that shape. A single-tool-call test passed
|
||||
// and said nothing about it.
|
||||
func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
||||
registry.Register(textTool{})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "note"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c2", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
// Two separate ifs rather than a switch: a tool-role message that also
|
||||
// carried images would match only the first arm of a switch, so a regression
|
||||
// that put the image back on the tool result would read as "no image at all"
|
||||
// instead of naming what actually happened.
|
||||
type toolResult struct {
|
||||
index int
|
||||
id string
|
||||
}
|
||||
var toolResults []toolResult
|
||||
imageIndex := -1
|
||||
for index, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResults = append(toolResults, toolResult{index: index, id: message.ToolCallID})
|
||||
}
|
||||
if len(message.Images) > 0 {
|
||||
if imageIndex >= 0 {
|
||||
t.Fatalf("image delivered twice, at %d and %d; recorded %s", imageIndex, index, messageShape(result.Messages))
|
||||
}
|
||||
imageIndex = index
|
||||
}
|
||||
}
|
||||
if imageIndex < 0 {
|
||||
t.Fatalf("the image never reached the model; recorded %s", messageShape(result.Messages))
|
||||
}
|
||||
if len(toolResults) != 2 {
|
||||
t.Fatalf("got %d tool-result messages for 2 tool calls; recorded %s", len(toolResults), messageShape(result.Messages))
|
||||
}
|
||||
if toolResults[0].id != "c1" || toolResults[1].id != "c2" {
|
||||
t.Errorf("tool results answered %q then %q, want c1 then c2", toolResults[0].id, toolResults[1].id)
|
||||
}
|
||||
// The contract Anthropic enforces: every tool_result block has to come before
|
||||
// any other block in the user message it lands in. Anything wedged between
|
||||
// the two results breaks that, which is the 400 this PR exists to avoid.
|
||||
if toolResults[1].index != toolResults[0].index+1 {
|
||||
t.Errorf("tool results at %d and %d are not adjacent; %q message at %d splits them; recorded %s",
|
||||
toolResults[0].index, toolResults[1].index, result.Messages[toolResults[0].index+1].Role,
|
||||
toolResults[0].index+1, messageShape(result.Messages))
|
||||
}
|
||||
// Follows the last tool result, deliberately NOT pinned to exactly last+1.
|
||||
// appendUserBlocks coalesces consecutive user-role content, so
|
||||
// [tool_result, tool_result, text, image] still maps to tool_result blocks
|
||||
// first and replays fine; pinning would redden that valid shape for nothing.
|
||||
if imageIndex <= toolResults[1].index {
|
||||
t.Errorf("image message at %d does not follow the last tool result at %d; recorded %s",
|
||||
imageIndex, toolResults[1].index, messageShape(result.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// messageShape renders the conversation as a compact role list so a failure says
|
||||
// what the ordering actually was instead of dumping whole messages.
|
||||
func messageShape(messages []zeroruntime.Message) string {
|
||||
parts := make([]string, 0, len(messages))
|
||||
for index, message := range messages {
|
||||
part := fmt.Sprintf("%d:%s", index, message.Role)
|
||||
if message.ToolCallID != "" {
|
||||
part += "(" + message.ToolCallID + ")"
|
||||
}
|
||||
if len(message.Images) > 0 {
|
||||
part += "+image"
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return "[" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
+5
-58
@@ -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
|
||||
@@ -77,18 +68,14 @@ type ToolResult struct {
|
||||
Output string
|
||||
// Truncated reports that the tool's model-visible output omitted content.
|
||||
// The full result may be recoverable through Meta["spill_path"].
|
||||
Truncated bool
|
||||
Meta map[string]string
|
||||
// Images the tool produced, delivered to the model as a following user
|
||||
// message rather than on this result. See tools.Result.Images.
|
||||
Images []zeroruntime.ImageBlock
|
||||
Truncated bool
|
||||
Meta map[string]string
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// 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 +96,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 +173,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,12 +278,8 @@ type Options struct {
|
||||
ProviderName string
|
||||
Model string
|
||||
ReasoningEffort string
|
||||
ServiceTier string
|
||||
Cwd string
|
||||
SystemPrompt string
|
||||
// TransientSystemPrompt adds trusted runtime guidance for this run only.
|
||||
// Empty preserves the ordinary system prompt byte-for-byte.
|
||||
TransientSystemPrompt string
|
||||
// ResponseStyle is the operator-selected reply style from the TUI /style
|
||||
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
||||
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
||||
@@ -381,11 +330,9 @@ type Options struct {
|
||||
// specialist child process emits while running. The toolCallID identifies
|
||||
// which Task tool call the progress belongs to. nil is a no-op.
|
||||
OnToolProgress func(toolCallID string, event streamjson.Event)
|
||||
// OnContext, when set, is called for each main agent request with its
|
||||
// per-category context budget, including a replacement request after
|
||||
// compaction or a stall retry. Internal summarizer requests are excluded so
|
||||
// surfaces keep showing the active conversation budget. Opt-in like the other
|
||||
// callbacks; nil is a no-op.
|
||||
// OnContext, when set, is called once per turn with the per-category context
|
||||
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
||||
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
||||
OnContext func(ContextBreakdown)
|
||||
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
||||
// mid-run: the loop calls it with the requested model id and, on success,
|
||||
|
||||
+2
-35
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/peermsg"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -82,7 +81,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 +187,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 +532,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
|
||||
}
|
||||
@@ -792,26 +786,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if permissionMode == "" {
|
||||
permissionMode = agent.PermissionModeAsk
|
||||
}
|
||||
sessionStore := deps.newSessionStore()
|
||||
peerClass := peermsg.PermissionPrompting
|
||||
if permissionMode == agent.PermissionModeUnsafe {
|
||||
peerClass = peermsg.PermissionBypass
|
||||
}
|
||||
peerService, peerErr := peermsg.New(peermsg.Options{
|
||||
Identity: peermsg.Identity{
|
||||
Cwd: workspaceRoot,
|
||||
PermissionClass: peerClass,
|
||||
},
|
||||
InboundPolicy: peermsg.InboundPolicy(resolved.CrossSessionInbound),
|
||||
})
|
||||
if peerErr != nil {
|
||||
fmt.Fprintf(stderr, "warning: cross-session messaging unavailable: %s\n", peerErr)
|
||||
peerService = nil
|
||||
} else {
|
||||
for _, tool := range tools.NewPeerSessionTools(peerService) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
}
|
||||
// Activate deferred MCP-tool loading for the interactive run only when the
|
||||
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
||||
// The registry is complete (core + specialist + MCP + plugins) here, so the
|
||||
@@ -842,7 +816,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,
|
||||
@@ -864,8 +837,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return scratchFileWarning(workspaceRoot, scratchBaseline)
|
||||
},
|
||||
Registry: registry,
|
||||
SessionStore: sessionStore,
|
||||
PeerService: peerService,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
@@ -1227,8 +1199,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 +1361,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")
|
||||
|
||||
@@ -79,8 +79,8 @@ func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
|
||||
|
||||
func TestDeferredEligibleCountIncludesOptionalBuiltins(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 2 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want at least 2 optional built-ins", got)
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 3 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want at least 3 optional built-ins", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -308,34 +308,6 @@ func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
|
||||
// A connect failure does not come back as err. RegisterTools is deliberately
|
||||
// best-effort so one unreachable server cannot stop Zero launching: it records
|
||||
// the failure on the runtime and returns nil. That is right for startup and
|
||||
// wrong to ignore here, because this is the command a user runs precisely when
|
||||
// a server is misbehaving, and reporting "is reachable" for a server that never
|
||||
// started sends them looking somewhere else.
|
||||
for _, skipped := range mcpRuntime.Skipped() {
|
||||
if skipped.Name != serverName {
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf("MCP server %s is not reachable", serverName)
|
||||
if skipped.Err != nil {
|
||||
message = fmt.Sprintf("%s: %s", message, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
||||
}
|
||||
if options.json {
|
||||
payload := struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}{ServerName: serverName, Status: "unreachable", Error: message}
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitCrash
|
||||
}
|
||||
return writeAppError(stderr, message, exitCrash)
|
||||
}
|
||||
|
||||
items := mcpToolList(registry)
|
||||
if options.json {
|
||||
payload := struct {
|
||||
|
||||
@@ -6,7 +6,6 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -220,81 +219,3 @@ func TestRunMCPCheckNormalizeError(t *testing.T) {
|
||||
t.Fatalf("want normalize error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPCheckReportsUnreachableServer covers the case the command exists
|
||||
// for: a server that fails to start.
|
||||
//
|
||||
// RegisterTools is best-effort by design, so a connect failure is recorded on
|
||||
// the runtime and registration returns nil. runMCPCheck used to inspect only
|
||||
// that nil error and then print "is reachable", which told the user their
|
||||
// broken server was fine. The command is spawned for real here rather than
|
||||
// stubbed, because the whole failure mode was a mismatch between what
|
||||
// registration returns and what it records.
|
||||
func TestRunMCPCheckReportsUnreachableServer(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "broken"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatalf("mcp check must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
|
||||
}
|
||||
if got := out.String(); strings.Contains(got, "is reachable") {
|
||||
t.Fatalf("mcp check reported a failed server as reachable: %q", got)
|
||||
}
|
||||
if got := errBuf.String(); !strings.Contains(got, "not reachable") {
|
||||
t.Fatalf("want an unreachable error naming the server, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The --json contract for an unreachable server, which the text case above does
|
||||
// not touch.
|
||||
//
|
||||
// The JSON branch is a separate return path with its own payload, so a caller
|
||||
// parsing it would otherwise be relying on a shape nothing pins: reverting the
|
||||
// check leaves it emitting status "ok" with a tool count, which is valid JSON
|
||||
// and completely wrong.
|
||||
func TestRunMCPCheckReportsUnreachableServerJSON(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "broken", "--json"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatalf("mcp check --json must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
|
||||
}
|
||||
var payload struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
ToolCount int `json:"toolCount"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode mcp check --json output %q: %v", out.String(), err)
|
||||
}
|
||||
if payload.ServerName != "broken" {
|
||||
t.Fatalf("serverName = %q, want the server that was checked", payload.ServerName)
|
||||
}
|
||||
if payload.Status != "unreachable" {
|
||||
t.Fatalf("status = %q, want %q", payload.Status, "unreachable")
|
||||
}
|
||||
if !strings.Contains(payload.Error, "not reachable") {
|
||||
t.Fatalf("error = %q, want it to say the server is not reachable", payload.Error)
|
||||
}
|
||||
// A reachable payload carries a tool count; an unreachable one must not
|
||||
// imply zero tools were found, which reads as a server that simply exposes
|
||||
// none.
|
||||
if payload.ToolCount != 0 {
|
||||
t.Fatalf("toolCount = %d, want it absent from an unreachable payload", payload.ToolCount)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-47
@@ -103,7 +103,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// here rather than in mergeConfig because that helper is shared with the
|
||||
// trusted global-config merge, which must keep honouring the setting.
|
||||
commandConfig.Sandbox.Enabled = nil
|
||||
commandConfig.CrossSessionInbound = ""
|
||||
mergeConfig(&cfg, commandConfig)
|
||||
}
|
||||
|
||||
@@ -141,13 +140,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
|
||||
}
|
||||
}
|
||||
if inbound := strings.TrimSpace(cfg.CrossSessionInbound); inbound != "" {
|
||||
switch inbound {
|
||||
case "accept", "hold", "refuse":
|
||||
default:
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid crossSessionInbound %q: expected accept, hold, or refuse", inbound)
|
||||
}
|
||||
}
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
@@ -162,20 +154,19 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
ActiveProvider: active.Name,
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
STT: cfg.STT,
|
||||
CrossSessionInbound: cfg.CrossSessionInbound,
|
||||
ActiveProvider: active.Name,
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
STT: cfg.STT,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -273,15 +264,9 @@ 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)
|
||||
if inbound := strings.TrimSpace(src.CrossSessionInbound); inbound != "" {
|
||||
dst.CrossSessionInbound = inbound
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -340,9 +325,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
if inbound := strings.TrimSpace(src.CrossSessionInbound); inboundPolicyRank(inbound) > inboundPolicyRank(dst.CrossSessionInbound) {
|
||||
dst.CrossSessionInbound = inbound
|
||||
}
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
// Local control is intentionally user-config/override only. A cloned project
|
||||
// must not be able to make browser, desktop, or terminal automation tools
|
||||
@@ -745,9 +727,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
|
||||
mergeSTTConfig(&cfg.STT, overrides.STT)
|
||||
if inbound := strings.TrimSpace(overrides.CrossSessionInbound); inbound != "" {
|
||||
cfg.CrossSessionInbound = inbound
|
||||
}
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
@@ -757,19 +736,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
mergeMCPConfig(&cfg.MCP, overrides.MCP, true)
|
||||
}
|
||||
|
||||
func inboundPolicyRank(policy string) int {
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "refuse":
|
||||
return 3
|
||||
case "hold":
|
||||
return 2
|
||||
case "accept":
|
||||
return 0
|
||||
default:
|
||||
return 1 // permission-mode parity
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
|
||||
if src.enabledSet {
|
||||
dst.Enabled = src.Enabled
|
||||
|
||||
@@ -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.
|
||||
@@ -2215,40 +2199,3 @@ func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
|
||||
t.Fatalf("error leaked API key: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossSessionInboundProjectConfigCanOnlyTighten(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user string
|
||||
project string
|
||||
want string
|
||||
}{
|
||||
{name: "hold tightens accept", user: "accept", project: "hold", want: "hold"},
|
||||
{name: "refuse stays refuse", user: "refuse", project: "accept", want: "refuse"},
|
||||
{name: "accept cannot weaken parity", project: "accept", want: ""},
|
||||
{name: "refuse tightens parity", project: "refuse", want: "refuse"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := FileConfig{CrossSessionInbound: test.user}
|
||||
if err := mergeProjectConfig(&cfg, FileConfig{CrossSessionInbound: test.project}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.CrossSessionInbound != test.want {
|
||||
t.Fatalf("crossSessionInbound = %q, want %q", cfg.CrossSessionInbound, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{"name":"openai","provider_kind":"openai","apiKey":"sk-x","model":"gpt-5"}],
|
||||
"crossSessionInbound": "sometimes"
|
||||
}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "expected accept, hold, or refuse") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
+72
-82
@@ -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).
|
||||
@@ -343,49 +340,46 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawConfig struct {
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT *STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT *STTConfig `json:"stt,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
Providers: cfg.Providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
CrossSessionInbound: cfg.CrossSessionInbound,
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
Providers: cfg.Providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
}
|
||||
if !cfg.LocalControl.Empty() {
|
||||
raw.LocalControl = &cfg.LocalControl
|
||||
@@ -410,35 +404,33 @@ type ResolveOptions struct {
|
||||
}
|
||||
|
||||
type Overrides struct {
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
STT STTConfig
|
||||
CrossSessionInbound string
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
STT STTConfig
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
STT STTConfig
|
||||
CrossSessionInbound string
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
STT STTConfig
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
@@ -488,21 +480,20 @@ type MCPOAuthConfig struct {
|
||||
|
||||
func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawConfig struct {
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
Providers []ProviderProfile `json:"providers"`
|
||||
MaxTurns int `json:"maxTurns"`
|
||||
MCP MCPConfig `json:"mcp"`
|
||||
Sandbox SandboxConfig `json:"sandbox"`
|
||||
Notify NotifyConfig `json:"notify"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
STT STTConfig `json:"stt"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
Providers []ProviderProfile `json:"providers"`
|
||||
MaxTurns int `json:"maxTurns"`
|
||||
MCP MCPConfig `json:"mcp"`
|
||||
Sandbox SandboxConfig `json:"sandbox"`
|
||||
Notify NotifyConfig `json:"notify"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
STT STTConfig `json:"stt"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
}
|
||||
|
||||
var raw rawConfig
|
||||
@@ -529,7 +520,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.KeyBindings = raw.KeyBindings
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
cfg.STT = raw.STT
|
||||
cfg.CrossSessionInbound = raw.CrossSessionInbound
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
+2
-134
@@ -13,10 +13,8 @@ package minify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/scanner"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -36,11 +34,8 @@ func File(path string, content []byte) Result {
|
||||
if out, ok := minifyGo(content); ok {
|
||||
return Result{Content: out, Language: "go", Applied: true}
|
||||
}
|
||||
if out, ok := minifyGoFragment(content); ok {
|
||||
return Result{Content: out, Language: "go-fragment", Applied: true}
|
||||
}
|
||||
// An incomplete fragment that cannot be wrapped safely falls through to
|
||||
// whitespace-only normalization; no source text is guessed or discarded.
|
||||
// Unparsable Go (a snippet, generics edge, or syntax error): fall through
|
||||
// to the safe generic path rather than risk a partial AST reprint.
|
||||
} else if style, ok := commentStyles[ext]; ok {
|
||||
// Strip comments with a string-aware lexer, then collapse the whitespace the
|
||||
// removed comments left behind. The stripper only handles languages whose
|
||||
@@ -51,133 +46,6 @@ func File(path string, content []byte) Result {
|
||||
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
|
||||
}
|
||||
|
||||
// Fragment minifies a bounded source range without assuming lexical state from
|
||||
// text outside the range. Go remains parser-validated; other languages use the
|
||||
// conservative whitespace-only transform because a range may begin inside a
|
||||
// multiline string or block comment.
|
||||
func Fragment(path string, content []byte) Result {
|
||||
if strings.EqualFold(filepath.Ext(path), ".go") {
|
||||
if out, ok := minifyGo(content); ok {
|
||||
return Result{Content: out, Language: "go", Applied: true}
|
||||
}
|
||||
if out, ok := minifyGoFragment(content); ok {
|
||||
return Result{Content: out, Language: "go-fragment", Applied: true}
|
||||
}
|
||||
}
|
||||
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
|
||||
}
|
||||
|
||||
// ContextualFragment minifies a bounded fragment after checking its starting
|
||||
// position against the complete source. A Go range that begins inside a string
|
||||
// or comment must remain verbatim apart from whitespace normalization: parsing
|
||||
// that fragment by itself would mistake literal text for Go syntax.
|
||||
func ContextualFragment(path string, source, content []byte, startOffset int) Result {
|
||||
if strings.EqualFold(filepath.Ext(path), ".go") && goFragmentStartsInsideToken(source, startOffset) {
|
||||
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
|
||||
}
|
||||
return Fragment(path, content)
|
||||
}
|
||||
|
||||
func goFragmentStartsInsideToken(source []byte, startOffset int) bool {
|
||||
if startOffset <= 0 || startOffset >= len(source) {
|
||||
return false
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file := fset.AddFile("", fset.Base(), len(source))
|
||||
var lexer scanner.Scanner
|
||||
lexer.Init(file, source, nil, scanner.ScanComments)
|
||||
for {
|
||||
position, kind, literal := lexer.Scan()
|
||||
if kind == token.EOF {
|
||||
return false
|
||||
}
|
||||
if kind != token.STRING && kind != token.COMMENT {
|
||||
continue
|
||||
}
|
||||
start := file.Offset(position)
|
||||
if startOffset > start && startOffset < start+len(literal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// minifyGoFragment handles bounded reads that do not contain a package clause.
|
||||
// It finds the longest complete declaration or statement prefix that the real
|
||||
// Go parser accepts, prints that prefix without comments, and preserves any
|
||||
// incomplete tail conservatively. Ranges are intentionally capped: repeatedly
|
||||
// parsing a large broken file would otherwise turn a cheap read into O(n²) work.
|
||||
func minifyGoFragment(content []byte) (string, bool) {
|
||||
lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n")
|
||||
if len(lines) > 1 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
if len(lines) > 512 {
|
||||
return "", false
|
||||
}
|
||||
for end := len(lines); end > 0; end-- {
|
||||
prefix := strings.Join(lines[:end], "\n")
|
||||
if compact, ok := parseGoDeclarations(prefix); ok {
|
||||
return joinCompactPrefix(compact, lines[end:]), true
|
||||
}
|
||||
if compact, ok := parseGoStatements(prefix); ok {
|
||||
return joinCompactPrefix(compact, lines[end:]), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseGoDeclarations(fragment string) (string, bool) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "", "package compact\n"+fragment, parser.SkipObjectResolution)
|
||||
if err != nil || len(file.Decls) == 0 {
|
||||
return "", false
|
||||
}
|
||||
nodes := make([]ast.Node, len(file.Decls))
|
||||
for i, declaration := range file.Decls {
|
||||
nodes[i] = declaration
|
||||
}
|
||||
return printGoNodes(fset, nodes)
|
||||
}
|
||||
|
||||
func parseGoStatements(fragment string) (string, bool) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "", "package compact\nfunc _(){\n"+fragment+"\n}", parser.SkipObjectResolution)
|
||||
if err != nil || len(file.Decls) != 1 {
|
||||
return "", false
|
||||
}
|
||||
function, ok := file.Decls[0].(*ast.FuncDecl)
|
||||
if !ok || function.Body == nil || len(function.Body.List) == 0 {
|
||||
return "", false
|
||||
}
|
||||
nodes := make([]ast.Node, len(function.Body.List))
|
||||
for i, statement := range function.Body.List {
|
||||
nodes[i] = statement
|
||||
}
|
||||
return printGoNodes(fset, nodes)
|
||||
}
|
||||
|
||||
func printGoNodes(fset *token.FileSet, nodes []ast.Node) (string, bool) {
|
||||
var buf bytes.Buffer
|
||||
cfg := printer.Config{Mode: printer.TabIndent, Tabwidth: 1}
|
||||
for i, node := range nodes {
|
||||
if i > 0 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if err := cfg.Fprint(&buf, fset, node); err != nil {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), true
|
||||
}
|
||||
|
||||
func joinCompactPrefix(compact string, remainder []string) string {
|
||||
tail := minifyGeneric([]byte(strings.Join(remainder, "\n")))
|
||||
if tail == "" {
|
||||
return compact
|
||||
}
|
||||
return compact + "\n" + tail
|
||||
}
|
||||
|
||||
// minifyGo parses Go WITHOUT comments (omitting parser.ParseComments leaves them
|
||||
// unattached) and reprints the AST, yielding valid, comment-free, gofmt-shaped
|
||||
// source with tab indentation (1 char per level). It returns ok=false on any
|
||||
|
||||
@@ -39,58 +39,17 @@ func Greet(name string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyGoStatementFragment(t *testing.T) {
|
||||
func TestMinifyGoFallsBackOnUnparsableGo(t *testing.T) {
|
||||
// A snippet (no package clause) cannot parse as a file -> safe generic path.
|
||||
r := File("snippet.go", []byte("x := 1 \n\n\n// keep me\ny := 2\n"))
|
||||
if !r.Applied || r.Language != "go-fragment" {
|
||||
t.Fatalf("expected parsed Go fragment, got %+v", r)
|
||||
if r.Applied {
|
||||
t.Fatalf("expected fallback for unparsable Go, got %+v", r)
|
||||
}
|
||||
if strings.Contains(r.Content, "\n\n\n") {
|
||||
t.Errorf("fragment should collapse blank runs:\n%q", r.Content)
|
||||
t.Errorf("generic should collapse blank runs:\n%q", r.Content)
|
||||
}
|
||||
if strings.Contains(r.Content, "keep me") || !strings.Contains(r.Content, "y := 2") {
|
||||
t.Errorf("fragment was not compacted safely: %q", r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyGoFragmentAccepts512LinesWithFinalNewline(t *testing.T) {
|
||||
src := strings.Repeat("x++\n", 512)
|
||||
r := File("snippet.go", []byte(src))
|
||||
if !r.Applied || r.Language != "go-fragment" {
|
||||
t.Fatalf("expected 512-line fragment to remain eligible, got %+v", r)
|
||||
}
|
||||
if got := strings.Count(r.Content, "x++"); got != 512 {
|
||||
t.Fatalf("compacted statement count = %d, want 512", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyGoDeclarationPrefixPreservesIncompleteTail(t *testing.T) {
|
||||
src := `func Handle737(n int) (int, error) {
|
||||
if n%2 == 1 {
|
||||
return 0, errBad
|
||||
}
|
||||
return n + 737, nil
|
||||
}
|
||||
|
||||
// Handle738 begins outside the requested range.
|
||||
func Handle738(n int) (int, error) {
|
||||
if n%2 == 1 {`
|
||||
r := File("range.go", []byte(src))
|
||||
if !r.Applied || r.Language != "go-fragment" {
|
||||
t.Fatalf("expected declaration-prefix compaction, got %+v", r)
|
||||
}
|
||||
if strings.Contains(r.Content, "Handle737 returns") || !strings.Contains(r.Content, "return n + 737") {
|
||||
t.Fatalf("complete declaration was not compacted correctly:\n%s", r.Content)
|
||||
}
|
||||
if !strings.Contains(r.Content, "func Handle738") || !strings.Contains(r.Content, "if n%2 == 1 {") {
|
||||
t.Fatalf("incomplete tail was not preserved:\n%s", r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyGoLargeInvalidInputFallsBackConservatively(t *testing.T) {
|
||||
src := strings.Repeat("x := 1 // keep\n", 513)
|
||||
r := File("broken.go", []byte(src))
|
||||
if r.Applied || !strings.Contains(r.Content, "// keep") {
|
||||
t.Fatalf("large invalid input must retain conservative fallback: %+v", r)
|
||||
if !strings.Contains(r.Content, "// keep me") {
|
||||
t.Errorf("generic must NOT strip comments (unsafe without a parser): %q", r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
//go:build !unix && !windows
|
||||
|
||||
package peermsg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) error {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
volume := filepath.VolumeName(abs)
|
||||
current := volume + string(filepath.Separator)
|
||||
for _, component := range strings.Split(strings.TrimPrefix(abs[len(volume):], string(filepath.Separator)), string(filepath.Separator)) {
|
||||
if component == "" {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, component)
|
||||
info, statErr := os.Lstat(current)
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
if err := os.Mkdir(current, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return err
|
||||
}
|
||||
info, statErr = os.Lstat(current)
|
||||
}
|
||||
if statErr != nil {
|
||||
return statErr
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("refusing non-directory or symlink runtime path %q", current)
|
||||
}
|
||||
}
|
||||
return os.Chmod(abs, 0o700)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//go:build unix
|
||||
|
||||
package peermsg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) error {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(filepath.Clean(abs), string(filepath.Separator)), string(filepath.Separator))
|
||||
parentFD, err := unix.Open(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(parentFD)
|
||||
for index, component := range components {
|
||||
if component == "" {
|
||||
continue
|
||||
}
|
||||
var stat unix.Stat_t
|
||||
err = unix.Fstatat(parentFD, component, &stat, unix.AT_SYMLINK_NOFOLLOW)
|
||||
if errors.Is(err, unix.ENOENT) {
|
||||
if err = unix.Mkdirat(parentFD, component, 0o700); err != nil && !errors.Is(err, unix.EEXIST) {
|
||||
return err
|
||||
}
|
||||
err = unix.Fstatat(parentFD, component, &stat, unix.AT_SYMLINK_NOFOLLOW)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stat.Mode&unix.S_IFMT != unix.S_IFDIR {
|
||||
return fmt.Errorf("refusing non-directory or symlink runtime path component %q", component)
|
||||
}
|
||||
nextFD, openErr := unix.Openat(parentFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if openErr != nil {
|
||||
return openErr
|
||||
}
|
||||
if index == len(components)-1 {
|
||||
if int(stat.Uid) != os.Getuid() {
|
||||
unix.Close(nextFD)
|
||||
return fmt.Errorf("refusing runtime directory not owned by the current user: %q", path)
|
||||
}
|
||||
if err = unix.Fchmod(nextFD, 0o700); err != nil {
|
||||
unix.Close(nextFD)
|
||||
return err
|
||||
}
|
||||
}
|
||||
unix.Close(parentFD)
|
||||
parentFD = nextFD
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//go:build unix
|
||||
|
||||
package peermsg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "target")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(link); err == nil {
|
||||
t.Fatal("expected symlink runtime directory to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePrivateDirRejectsSymlinkedParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "target")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
t.Fatal("expected symlinked parent to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package peermsg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) (resultErr error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
descriptor, userSID, tokenOwnerSID, err := privateDirectoryDescriptor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
volume := filepath.VolumeName(abs)
|
||||
current := volume + string(filepath.Separator)
|
||||
parent, err := openWindowsDirectory(current, windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parentPath := current
|
||||
defer func() {
|
||||
if parent != 0 {
|
||||
resultErr = errors.Join(resultErr, closePrivateWindowsHandle(parent, parentPath))
|
||||
}
|
||||
}()
|
||||
components := strings.Split(strings.TrimPrefix(abs[len(volume):], string(filepath.Separator)), string(filepath.Separator))
|
||||
for index, component := range components {
|
||||
if component == "" {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, component)
|
||||
access := uint32(windows.FILE_READ_ATTRIBUTES | windows.FILE_TRAVERSE | windows.SYNCHRONIZE)
|
||||
if index == len(components)-1 {
|
||||
access |= windows.READ_CONTROL | windows.WRITE_DAC
|
||||
}
|
||||
next, openErr := openOrCreatePrivateWindowsDirectory(parent, component, access, descriptor)
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("open private runtime path component %q: %w", current, openErr)
|
||||
}
|
||||
if closeErr := closePrivateWindowsHandle(parent, parentPath); closeErr != nil {
|
||||
return errors.Join(closeErr, closePrivateWindowsHandle(next, current))
|
||||
}
|
||||
parent = next
|
||||
parentPath = current
|
||||
}
|
||||
if err := securePrivateDirectory(parent, abs, descriptor, userSID, tokenOwnerSID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := closePrivateWindowsHandle(parent, abs); err != nil {
|
||||
return err
|
||||
}
|
||||
parent = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func closePrivateWindowsHandle(handle windows.Handle, path string) error {
|
||||
if err := windows.CloseHandle(handle); err != nil {
|
||||
return fmt.Errorf("close private runtime directory %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func privateDirectoryDescriptor() (*windows.SECURITY_DESCRIPTOR, *windows.SID, *windows.SID, error) {
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("resolve current Windows user: %w", err)
|
||||
}
|
||||
sid := user.User.Sid
|
||||
tokenOwnerSID, err := windowsTokenOwner(token)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("resolve current Windows token owner: %w", err)
|
||||
}
|
||||
descriptor, err := windows.SecurityDescriptorFromString(fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", sid.String(), sid.String()))
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("build private Windows directory ACL: %w", err)
|
||||
}
|
||||
return descriptor, sid, tokenOwnerSID, nil
|
||||
}
|
||||
|
||||
type windowsTokenOwnerInfo struct {
|
||||
owner *windows.SID
|
||||
}
|
||||
|
||||
func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
|
||||
if err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, err
|
||||
}
|
||||
buffer := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
|
||||
if owner == nil {
|
||||
return nil, errors.New("Windows access token has no default owner")
|
||||
}
|
||||
return owner.Copy()
|
||||
}
|
||||
|
||||
func openWindowsDirectory(path string, access uint32) (windows.Handle, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathPtr,
|
||||
access,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open private runtime directory %q: %w", path, err)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func openOrCreatePrivateWindowsDirectory(parent windows.Handle, name string, access uint32, descriptor *windows.SECURITY_DESCRIPTOR) (windows.Handle, error) {
|
||||
handle, err := openPrivateWindowsDirectoryAt(parent, name, access, windows.FILE_OPEN, nil)
|
||||
if err == nil {
|
||||
return handle, nil
|
||||
}
|
||||
if !windowsDirectoryMissing(err) {
|
||||
return 0, err
|
||||
}
|
||||
handle, err = openPrivateWindowsDirectoryAt(parent, name, access, windows.FILE_CREATE, descriptor)
|
||||
if err == windows.STATUS_OBJECT_NAME_COLLISION || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
|
||||
return openPrivateWindowsDirectoryAt(parent, name, access, windows.FILE_OPEN, nil)
|
||||
}
|
||||
return handle, err
|
||||
}
|
||||
|
||||
func openPrivateWindowsDirectoryAt(parent windows.Handle, name string, access, disposition uint32, descriptor *windows.SECURITY_DESCRIPTOR) (windows.Handle, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
objectAttributes := &windows.OBJECT_ATTRIBUTES{
|
||||
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
|
||||
SecurityDescriptor: descriptor,
|
||||
}
|
||||
var handle windows.Handle
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
access,
|
||||
objectAttributes,
|
||||
&windows.IO_STATUS_BLOCK{},
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_DIRECTORY,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
disposition,
|
||||
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return 0, errors.Join(err, closePrivateWindowsHandle(handle, name))
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 {
|
||||
return 0, errors.Join(
|
||||
fmt.Errorf("refusing non-directory or reparse-point component %q", name),
|
||||
closePrivateWindowsHandle(handle, name),
|
||||
)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func windowsDirectoryMissing(err error) bool {
|
||||
return err == windows.STATUS_NO_SUCH_FILE ||
|
||||
err == windows.STATUS_OBJECT_NAME_NOT_FOUND ||
|
||||
err == windows.STATUS_OBJECT_PATH_NOT_FOUND ||
|
||||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
|
||||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND)
|
||||
}
|
||||
|
||||
func securePrivateDirectory(handle windows.Handle, path string, desired *windows.SECURITY_DESCRIPTOR, allowedOwners ...*windows.SID) error {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect private runtime directory %q: %w", path, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 {
|
||||
return fmt.Errorf("refusing non-directory or reparse-point runtime path %q", path)
|
||||
}
|
||||
current, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read owner of private runtime directory %q: %w", path, err)
|
||||
}
|
||||
owner, _, err := current.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read owner of private runtime directory %q: %w", path, err)
|
||||
}
|
||||
ownedByCurrentToken := false
|
||||
for _, allowedOwner := range allowedOwners {
|
||||
if allowedOwner != nil && owner.Equals(allowedOwner) {
|
||||
ownedByCurrentToken = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ownedByCurrentToken {
|
||||
return fmt.Errorf("refusing runtime directory %q not owned by the current user", path)
|
||||
}
|
||||
dacl, _, err := desired.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private Windows directory DACL: %w", err)
|
||||
}
|
||||
if err := windows.SetSecurityInfo(
|
||||
handle,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
dacl,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("secure private runtime directory %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package peermsg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestEnsurePrivateDirAppliesOwnerOnlyProtectedDACL(t *testing.T) {
|
||||
const directoryAllAccess = windows.STANDARD_RIGHTS_REQUIRED | windows.SYNCHRONIZE | 0x1ff
|
||||
|
||||
path := filepath.Join(t.TempDir(), "zero", "peers", "registry")
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pinner runtime.Pinner
|
||||
pinner.Pin(worldSID)
|
||||
defer pinner.Unpin()
|
||||
broadDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(worldSID),
|
||||
},
|
||||
}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
path,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
broadDACL,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !windowsDirectoryDACLContains(t, path, worldSID) {
|
||||
t.Fatal("test setup did not grant the broad Everyone ACE")
|
||||
}
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
descriptor, err := windows.GetNamedSecurityInfo(
|
||||
path,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
control, _, err := descriptor.Control()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if control&windows.SE_DACL_PROTECTED == 0 {
|
||||
t.Fatal("runtime directory DACL inherits access from its parent")
|
||||
}
|
||||
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _, err := descriptor.Owner()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenOwner, err := windowsTokenOwner(windows.GetCurrentProcessToken())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner) {
|
||||
t.Fatalf("runtime directory owner = %s, want user %s or token owner %s", owner.String(), user.User.Sid.String(), tokenOwner.String())
|
||||
}
|
||||
systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTrustees := []*windows.SID{user.User.Sid, systemSID}
|
||||
seen := make([]bool, len(wantTrustees))
|
||||
for index := uint32(0); ; index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
break
|
||||
}
|
||||
hasFullAccess := ace.Mask == windows.GENERIC_ALL || ace.Mask&directoryAllAccess == directoryAllAccess
|
||||
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || !hasFullAccess {
|
||||
t.Fatalf("DACL ACE %d has type %d and mask %#x", index, ace.Header.AceType, ace.Mask)
|
||||
}
|
||||
trustee := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
matched := false
|
||||
for trusteeIndex, wanted := range wantTrustees {
|
||||
if trustee.Equals(wanted) {
|
||||
seen[trusteeIndex] = true
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("DACL ACE %d grants access to unexpected trustee %s", index, trustee.String())
|
||||
}
|
||||
}
|
||||
for index, found := range seen {
|
||||
if !found {
|
||||
t.Fatalf("DACL does not grant access to required trustee %s", wantTrustees[index].String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func windowsDirectoryDACLContains(t *testing.T, path string, wanted *windows.SID) bool {
|
||||
t.Helper()
|
||||
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := uint32(0); ; index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, index, &ace); err != nil {
|
||||
return false
|
||||
}
|
||||
if (*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private")
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL|windows.WRITE_DAC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
descriptor, _, _, err := privateDirectoryDescriptor()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unexpectedOwner, err := windows.CreateWellKnownSid(windows.WinWorldSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := securePrivateDirectory(handle, path, descriptor, unexpectedOwner); err == nil || !strings.Contains(err.Error(), "not owned") {
|
||||
t.Fatalf("owner mismatch error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurePrivateDirectoryReportsDACLWriteFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private")
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
descriptor, userSID, tokenOwnerSID, err := privateDirectoryDescriptor()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := securePrivateDirectory(handle, path, descriptor, userSID, tokenOwnerSID); err == nil || !strings.Contains(err.Error(), "secure private runtime directory") {
|
||||
t.Fatalf("DACL write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosePrivateWindowsHandleReportsFailure(t *testing.T) {
|
||||
err := closePrivateWindowsHandle(windows.Handle(0), `C:\private`)
|
||||
if err == nil || !strings.Contains(err.Error(), "close private runtime directory") {
|
||||
t.Fatalf("close handle error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePrivateDirRejectsWindowsReparseParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "target")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("cannot create Windows directory symlink: %v", err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
t.Fatal("expected reparse-point parent to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePrivateDirRejectsWindowsFileComponent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
file := filepath.Join(root, "file")
|
||||
if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(file, "peers")); err == nil {
|
||||
t.Fatal("expected file path component to be rejected")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user