mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d9a68b666 | ||
|
|
ad152e0049 | ||
|
|
b4b0418daf |
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.
|
||||
@@ -1,27 +1,25 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.6
|
||||
go 1.26.5
|
||||
|
||||
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.45.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,10 +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/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
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)
|
||||
}
|
||||
|
||||
+98
-278
@@ -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 {
|
||||
@@ -3087,15 +2944,13 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
// Deferral may activate only when tool_search is actually runnable; otherwise
|
||||
// the loop would hide deferred tools behind a loader the dispatch gate rejects
|
||||
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
|
||||
// registered, model-visible, not in DisabledTools, and advertised in the
|
||||
// current permission mode (e.g. not spec-draft, where tool_search is not
|
||||
// advertised). The
|
||||
// registered, not in DisabledTools, and advertised in the current permission
|
||||
// mode (e.g. not spec-draft, where tool_search is not advertised). The
|
||||
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
|
||||
// exempt from the allowlist at dispatch, so an allowlist that omits it must
|
||||
// not disable deferral.
|
||||
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
|
||||
loaderUsable := loaderFound &&
|
||||
tools.ModelVisible(loader) &&
|
||||
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
|
||||
ToolAdvertised(loader, permissionMode)
|
||||
|
||||
@@ -3210,9 +3065,7 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return tools.ModelVisible(tool) &&
|
||||
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
|
||||
ToolAdvertised(tool, permissionMode)
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
|
||||
@@ -3293,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
|
||||
@@ -3326,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 ""
|
||||
@@ -3364,7 +3222,6 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
@@ -3395,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,29 +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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
|
||||
names := make(map[string]bool, len(exposed))
|
||||
for _, definition := range exposed {
|
||||
names[definition.Name] = true
|
||||
}
|
||||
if !names["apply_patch"] {
|
||||
t.Fatal("agent must retain apply_patch for existing-file changes")
|
||||
}
|
||||
if names["edit_file"] {
|
||||
t.Fatal("agent must not receive the ambiguous string-replacement tool")
|
||||
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,21 +49,16 @@ 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, 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.
|
||||
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
|
||||
and enough unchanged context to identify the intended location. Match the
|
||||
existing indentation, imports, and idioms. Match the file's comment density:
|
||||
do not add explanatory comments unless the user asks or the code is already
|
||||
comment-dense.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
@@ -105,7 +98,7 @@ work.
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/apply_patch for file changes.
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
|
||||
@@ -60,17 +60,16 @@ const openAIPromptAddendum = `<model_guidance>
|
||||
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||
commands, and symbols.
|
||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
||||
write_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
file work. Make one tool call per file; do not batch file writes into a script.
|
||||
- Persist until the task is fully handled this turn: gather context, implement,
|
||||
run the validators, and report — do not stop at a partial result.
|
||||
</model_guidance>`
|
||||
|
||||
const geminiPromptAddendum = `<model_guidance>
|
||||
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
|
||||
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
|
||||
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>`
|
||||
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"inspect the target file",
|
||||
"plan then act",
|
||||
"choose the narrowest tool",
|
||||
"for edits to existing files, prefer apply_patch",
|
||||
"prefer edit_file or apply_patch",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,10 @@ func ParseTraceEventKeys(stdout string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func TraceEventKeys(stdout string) []string {
|
||||
return ParseTraceEventKeys(stdout)
|
||||
}
|
||||
|
||||
func MissingTraceEvents(required []string, stdout string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, key := range ParseTraceEventKeys(stdout) {
|
||||
|
||||
@@ -453,12 +453,7 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
|
||||
}
|
||||
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
||||
if err != nil {
|
||||
var migrated bool
|
||||
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
|
||||
if !migrated {
|
||||
return Task{}, false, err
|
||||
}
|
||||
changed = true
|
||||
return Task{}, false, err
|
||||
}
|
||||
if outputFile != task.OutputFile {
|
||||
changed = true
|
||||
@@ -567,32 +562,6 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// migrateLegacyOutputFile recovers task metadata written under a previous home
|
||||
// or XDG data root. It accepts only the canonical per-task output
|
||||
// name in the historic zero/background layout, and only when the corresponding
|
||||
// regular file already exists inside the active manager root.
|
||||
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if !filepath.IsAbs(requested) {
|
||||
return "", false
|
||||
}
|
||||
requested = filepath.Clean(requested)
|
||||
expectedName := taskID + ".ndjson"
|
||||
if filepath.Base(requested) != expectedName {
|
||||
return "", false
|
||||
}
|
||||
legacyRoot := filepath.Dir(requested)
|
||||
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
|
||||
return "", false
|
||||
}
|
||||
canonical := filepath.Join(manager.rootDir, expectedName)
|
||||
info, err := os.Lstat(canonical)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return "", false
|
||||
}
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
func validTaskID(taskID string) bool {
|
||||
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -192,88 +191,6 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const taskID = "legacy_task"
|
||||
canonicalOutput := filepath.Join(root, taskID+".ndjson")
|
||||
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
|
||||
t.Fatalf("write canonical output: %v", err)
|
||||
}
|
||||
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
|
||||
metadata, err := json.Marshal(Task{
|
||||
ID: taskID,
|
||||
Type: "specialist",
|
||||
Status: StatusCompleted,
|
||||
OutputFile: legacyOutput,
|
||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal legacy metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write legacy metadata: %v", err)
|
||||
}
|
||||
|
||||
manager, err := NewManager(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
task, ok := manager.Get(taskID)
|
||||
if !ok {
|
||||
t.Fatal("manager did not load migrated task")
|
||||
}
|
||||
if task.OutputFile != canonicalOutput {
|
||||
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
|
||||
}
|
||||
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
|
||||
t.Fatalf("migration produced load warnings: %#v", warnings)
|
||||
}
|
||||
|
||||
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated metadata: %v", err)
|
||||
}
|
||||
var saved Task
|
||||
if err := json.Unmarshal(persisted, &saved); err != nil {
|
||||
t.Fatalf("decode migrated metadata: %v", err)
|
||||
}
|
||||
if saved.OutputFile != canonicalOutput {
|
||||
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const taskID = "legacy_task"
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
|
||||
t.Fatalf("write canonical output: %v", err)
|
||||
}
|
||||
metadata, err := json.Marshal(Task{
|
||||
ID: taskID,
|
||||
Type: "specialist",
|
||||
Status: StatusCompleted,
|
||||
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
|
||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write metadata: %v", err)
|
||||
}
|
||||
|
||||
manager, err := NewManager(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
if _, ok := manager.Get(taskID); ok {
|
||||
t.Fatal("manager loaded task with an unrelated absolute output path")
|
||||
}
|
||||
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
|
||||
t.Fatalf("load warnings = %q, want containment rejection", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager, err := NewManager(root)
|
||||
|
||||
@@ -39,21 +39,3 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
|
||||
// terminateOwnedProcess terminates cmd's process group directly when its launch
|
||||
// configuration proves it was made a group leader. This avoids fragile Getpgid
|
||||
// rediscovery after an owned leader exits. Ordinary commands fall back to the
|
||||
// safe PID/tree path rather than assuming their PID is also a process-group ID.
|
||||
//
|
||||
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
|
||||
// own convention. A command made its own session leader via Setsid (as opposed
|
||||
// to Setpgid) is also its own process-group leader in practice, but takes the
|
||||
// slower rediscovery path here since Setsid isn't checked. Harmless today
|
||||
// because TerminateCommand has exactly one caller in this codebase; worth
|
||||
// covering explicitly if Setsid-configured commands start using this path too.
|
||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
||||
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
return false, terminateProcess(cmd.Process.Pid)
|
||||
}
|
||||
@@ -147,12 +147,11 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
t.Fatalf("terminateProcess: %v", err)
|
||||
}
|
||||
|
||||
// The forked child must no longer be running. An orphaned zombie is already
|
||||
// dead but may remain visible briefly until the platform's init reaps it.
|
||||
// The forked child must be gone too (poll until reaped by init).
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
// processStopped treats only ESRCH or an observed zombie state as stopped;
|
||||
// other errors (for example EPERM) remain live and keep polling.
|
||||
for !processStopped(childPID) {
|
||||
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
||||
// wrongly pass the test, so treat it as still-present and keep polling.
|
||||
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
||||
@@ -160,73 +159,3 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandStopsUnconfiguredCommand(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if cmd.ProcessState == nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("unconfigured command was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
|
||||
grace, poll := terminationGracePeriod, terminationPollInterval
|
||||
terminationGracePeriod, terminationPollInterval = 2*time.Second, 20*time.Millisecond
|
||||
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
|
||||
|
||||
// The leader exits immediately after launching the child. TerminateCommand
|
||||
// must capture and signal the process group before Wait reaps the leader and
|
||||
// makes its group identity unavailable.
|
||||
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read forked child pid: %v", err)
|
||||
}
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse forked child pid %q: %v", line, err)
|
||||
}
|
||||
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !processStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived TerminateCommand", childPID)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func processStopped(pid int) bool {
|
||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
||||
return true
|
||||
}
|
||||
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
|
||||
}
|
||||
@@ -3,20 +3,11 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// stillActiveExitCode is the documented GetExitCodeProcess value for a process
|
||||
// that has not terminated. x/sys/windows does not expose Windows' STILL_ACTIVE
|
||||
// constant.
|
||||
const stillActiveExitCode uint32 = 259
|
||||
|
||||
var terminateProcessForTest = terminateProcess
|
||||
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
|
||||
// delegated to execution.TerminateProcessTree, so no launch-time process-group
|
||||
// setup is required (the POSIX build sets Setpgid here instead).
|
||||
@@ -25,28 +16,3 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, 0, 0)
|
||||
}
|
||||
|
||||
// terminateOwnedProcess asks taskkill /T to terminate the tree rooted at cmd,
|
||||
// with KillProcessTree's direct Process.Kill fallback if taskkill fails. taskkill
|
||||
// can discover descendants only while the root PID still exists; unlike a POSIX
|
||||
// process group, a Windows tree has no independently addressable identity after
|
||||
// its root exits.
|
||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
var (
|
||||
alreadyExited bool
|
||||
terminateErr error
|
||||
)
|
||||
err := cmd.Process.WithHandle(func(handle uintptr) {
|
||||
var exitCode uint32
|
||||
if windows.GetExitCodeProcess(windows.Handle(handle), &exitCode) == nil {
|
||||
alreadyExited = exitCode != stillActiveExitCode
|
||||
}
|
||||
// Keep the exact process identity pinned while the PID-based taskkill
|
||||
// operation runs, preventing the PID from being recycled underneath it.
|
||||
terminateErr = terminateProcessForTest(cmd.Process.Pid)
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
|
||||
}
|
||||
return alreadyExited, terminateErr
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package background
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTerminateCommandReturnsTreeFailureAfterRootFallbackReaps(t *testing.T) {
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 300")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
treeErr := errors.New("taskkill /T failed")
|
||||
originalTerminateProcess := terminateProcessForTest
|
||||
terminateProcessForTest = func(pid int) error {
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
return errors.Join(treeErr, err)
|
||||
}
|
||||
return treeErr
|
||||
}
|
||||
t.Cleanup(func() { terminateProcessForTest = originalTerminateProcess })
|
||||
|
||||
err := TerminateCommand(cmd)
|
||||
if !errors.Is(err, treeErr) {
|
||||
t.Fatalf("TerminateCommand error = %v, want tree failure", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("root process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandReapsExitedLeader(t *testing.T) {
|
||||
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
// Let the child exit without calling Wait so cleanup exercises the Windows
|
||||
// taskkill/TerminateProcess race while it still owns the process handle.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// jatmn's #774 finding: taskkill /T racing an already-exited PID (and the
|
||||
// Process.Kill fallback) both fail here, but the reap below still succeeds —
|
||||
// the process-handle exit-code check proves the leader was already gone, so
|
||||
// TerminateCommand must not surface the expected kill-attempt error.
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v, want nil (leader was already gone and got reaped)", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("exited process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandDeadLeaderCannotIdentifyDescendantTree(t *testing.T) {
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
`$p = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 300' -PassThru; $p.Id`)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read descendant pid: %v", err)
|
||||
}
|
||||
descendantPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse descendant pid %q: %v", line, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = exec.Command("taskkill.exe", "/F", "/PID", strconv.Itoa(descendantPID)).Run()
|
||||
})
|
||||
|
||||
// Wait until the leader is observably exited but deliberately leave it
|
||||
// unreaped, matching the POSIX regression's ordering.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for processExistsOnWindows(cmd.Process.Pid) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("leader did not exit")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
|
||||
// Windows has no persistent group ID analogous to POSIX: once the leader is
|
||||
// gone, taskkill /T cannot rediscover this descendant. Assert that platform
|
||||
// distinction rather than claiming TerminateCommand can provide it here.
|
||||
if !processExistsOnWindows(descendantPID) {
|
||||
t.Fatal("descendant unexpectedly stopped after its root exited")
|
||||
}
|
||||
}
|
||||
|
||||
func processExistsOnWindows(pid int) bool {
|
||||
return exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
"Get-Process -Id "+strconv.Itoa(pid)+" -ErrorAction Stop | Out-Null").Run() == nil
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
const commandReapTimeout = 3 * time.Second
|
||||
|
||||
// TerminateProcess stops a background process by PID — on Windows its process
|
||||
// tree; on POSIX its whole process group when the PID leads its own group (the
|
||||
// invariant ConfigureChildProcessGroup establishes for processes started through
|
||||
@@ -21,69 +12,3 @@ const commandReapTimeout = 3 * time.Second
|
||||
func TerminateProcess(pid int) error {
|
||||
return terminateProcess(pid)
|
||||
}
|
||||
|
||||
// TerminateCommand stops a started command and reaps its leader. The caller must
|
||||
// have exclusive ownership of cmd: it must not have previously called Wait or
|
||||
// Process.Release, and no goroutine may call either concurrently. On POSIX it
|
||||
// stops the whole group when the command was configured as its leader; ordinary
|
||||
// commands safely fall back to PID/tree discovery. On Windows, success for a
|
||||
// leader that was already dead confirms only that the leader was reaped;
|
||||
// descendants may survive because Windows cannot rediscover a tree from a dead
|
||||
// root. `zero daemon start` needs this operation when readiness times out: it
|
||||
// launched the child, so it must both stop the tree and collect the leader.
|
||||
//
|
||||
// The order matters: the tree is signalled first, because Wait releases the
|
||||
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
|
||||
// to a recycled PID). Termination stays in the shared execution lifecycle
|
||||
// primitives rather than introducing a second platform-specific killer.
|
||||
//
|
||||
// If the bounded reap times out, the Wait call already started by this function
|
||||
// continues in its goroutine. From that point onward the caller must not access
|
||||
// cmd at all, including ProcessState, because that would race with Wait.
|
||||
func TerminateCommand(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return errors.New("terminate command: process was never started")
|
||||
}
|
||||
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
|
||||
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
|
||||
if reapErr != nil {
|
||||
if terminateErr != nil {
|
||||
return fmt.Errorf("%v (reap failed: %w)", terminateErr, reapErr)
|
||||
}
|
||||
return reapErr
|
||||
}
|
||||
if terminateErr != nil && !leaderAlreadyExited {
|
||||
return terminateErr
|
||||
}
|
||||
// Only discard a termination error when the platform demonstrated before
|
||||
// attempting termination that the leader had already exited. A successful
|
||||
// reap alone says nothing about whether a live descendant tree was stopped.
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyWaitError treats a non-zero exit status as success: a process being
|
||||
// terminated is expected to report one (or a signal), so the only interesting
|
||||
// failure is Wait itself not working.
|
||||
func classifyWaitError(waitErr error) error {
|
||||
var exitErr *exec.ExitError
|
||||
if waitErr != nil && !errors.As(waitErr, &exitErr) {
|
||||
return fmt.Errorf("reap process: %w", waitErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForTerminatedCommandWithin bounds the reap so a child that somehow survives
|
||||
// termination cannot hang the caller — the daemon-start cleanup path runs while a
|
||||
// user waits at the CLI. On timeout its Wait goroutine is intentionally left to
|
||||
// finish: exec.Cmd permits only one Wait call, and abandoning it without a reaper
|
||||
// would leak the child once it eventually exits.
|
||||
func waitForTerminatedCommandWithin(cmd *exec.Cmd, timeout time.Duration) error {
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
select {
|
||||
case waitErr := <-waitDone:
|
||||
return classifyWaitError(waitErr)
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("process %d did not reap after termination", cmd.Process.Pid)
|
||||
}
|
||||
}
|
||||
+38
-166
@@ -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"
|
||||
@@ -64,53 +63,37 @@ type appDeps struct {
|
||||
// getenv reads a process environment variable (production: os.Getenv, set in
|
||||
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
|
||||
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
|
||||
getenv func(string) string
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
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
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
createBranch func(context.Context, zerogit.BranchOptions) (zerogit.BranchResult, error)
|
||||
isDefaultBranch func(context.Context, zerogit.DefaultBranchOptions) (bool, string, string, error)
|
||||
currentGitUser func(context.Context, string) string
|
||||
headCommitSubject func(context.Context, string) string
|
||||
commitsAhead func(context.Context, string, string, string) (int, error)
|
||||
isUnbornRemote func(context.Context, string, string) (bool, error)
|
||||
refreshTrackingRef func(context.Context, string, string, string) error
|
||||
branchUpstreamRemote func(context.Context, string, string) string
|
||||
branchUpstreamRemoteAndMerge func(context.Context, string, string) (string, string)
|
||||
resolveRemoteBranchTip func(context.Context, string, string, string) (string, error)
|
||||
remoteHasBranch func(context.Context, string, string, string) (bool, error)
|
||||
currentGitBranch func(context.Context, string) string
|
||||
currentBranchTip func(context.Context, string) string
|
||||
deleteBranch func(context.Context, string, string, string) error
|
||||
resetBranchRef func(context.Context, string, string, string, string) error
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
getenv func(string) string
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
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)
|
||||
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
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
@@ -204,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,
|
||||
@@ -213,52 +195,11 @@ func defaultAppDeps() appDeps {
|
||||
commitChanges: zerogit.Commit,
|
||||
pushChanges: zerogit.Push,
|
||||
createPR: zerogit.CreatePR,
|
||||
createBranch: zerogit.CreateBranch,
|
||||
isDefaultBranch: zerogit.IsDefaultBranch,
|
||||
currentGitUser: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentGitUser(ctx, cwd, nil)
|
||||
},
|
||||
headCommitSubject: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.HeadCommitSubject(ctx, cwd, nil)
|
||||
},
|
||||
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
|
||||
return zerogit.CommitsAhead(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
|
||||
return zerogit.IsUnbornRemote(ctx, cwd, remote, nil)
|
||||
},
|
||||
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
|
||||
return zerogit.RefreshTrackingRef(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
branchUpstreamRemote: func(ctx context.Context, cwd, branch string) string {
|
||||
return zerogit.UpstreamRemote(ctx, cwd, branch, nil)
|
||||
},
|
||||
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
|
||||
return zerogit.UpstreamRemoteAndMergeBranch(ctx, cwd, branch, nil)
|
||||
},
|
||||
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
|
||||
return zerogit.ResolveRemoteBranchTip(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
|
||||
return zerogit.RemoteHasBranch(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
currentGitBranch: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentBranch(ctx, cwd, nil)
|
||||
},
|
||||
currentBranchTip: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentBranchTip(ctx, cwd, nil)
|
||||
},
|
||||
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
|
||||
return zerogit.DeleteBranch(ctx, cwd, fallbackBranch, branchToDelete, nil)
|
||||
},
|
||||
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
|
||||
return zerogit.ResetBranchRef(ctx, cwd, branch, newTip, nil, expectedOld)
|
||||
},
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,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
|
||||
}
|
||||
@@ -618,45 +556,6 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.createPR == nil {
|
||||
deps.createPR = defaults.createPR
|
||||
}
|
||||
if deps.createBranch == nil {
|
||||
deps.createBranch = defaults.createBranch
|
||||
}
|
||||
if deps.isDefaultBranch == nil {
|
||||
deps.isDefaultBranch = defaults.isDefaultBranch
|
||||
}
|
||||
if deps.currentGitUser == nil {
|
||||
deps.currentGitUser = defaults.currentGitUser
|
||||
}
|
||||
if deps.headCommitSubject == nil {
|
||||
deps.headCommitSubject = defaults.headCommitSubject
|
||||
}
|
||||
if deps.commitsAhead == nil {
|
||||
deps.commitsAhead = defaults.commitsAhead
|
||||
}
|
||||
if deps.isUnbornRemote == nil {
|
||||
deps.isUnbornRemote = defaults.isUnbornRemote
|
||||
}
|
||||
if deps.refreshTrackingRef == nil {
|
||||
deps.refreshTrackingRef = defaults.refreshTrackingRef
|
||||
}
|
||||
if deps.branchUpstreamRemote == nil {
|
||||
deps.branchUpstreamRemote = defaults.branchUpstreamRemote
|
||||
}
|
||||
if deps.branchUpstreamRemoteAndMerge == nil {
|
||||
deps.branchUpstreamRemoteAndMerge = defaults.branchUpstreamRemoteAndMerge
|
||||
}
|
||||
if deps.remoteHasBranch == nil {
|
||||
deps.remoteHasBranch = defaults.remoteHasBranch
|
||||
}
|
||||
if deps.currentGitBranch == nil {
|
||||
deps.currentGitBranch = defaults.currentGitBranch
|
||||
}
|
||||
if deps.currentBranchTip == nil {
|
||||
deps.currentBranchTip = defaults.currentBranchTip
|
||||
}
|
||||
// resolveRemoteBranchTip, deleteBranch, and resetBranchRef stay nil when
|
||||
// unset so unit tests that mock createBranch without a real git tree do not
|
||||
// hit real git restore/delete commands.
|
||||
if deps.runTUI == nil {
|
||||
deps.runTUI = defaults.runTUI
|
||||
}
|
||||
@@ -887,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
|
||||
@@ -937,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,
|
||||
@@ -959,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,
|
||||
@@ -1322,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
|
||||
@@ -1485,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")
|
||||
|
||||
+9
-51
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -161,61 +160,20 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return daemonReachable(paths) }, 5*time.Second, 25*time.Millisecond); err != nil {
|
||||
if errors.Is(err, errDaemonStartTimeout) {
|
||||
return writeAppError(stderr, err.Error()+"; see "+logPath, exitCrash)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
var errDaemonStartTimeout = errors.New("daemon did not come up within timeout")
|
||||
|
||||
func startAndAwaitDaemonProcess(cmd *exec.Cmd, reachable func() bool, timeout time.Duration, pollInterval time.Duration) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if waitForDaemonReadiness(reachable, timeout, pollInterval) {
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
cleanupErr := terminateAndReapDaemonProcess(cmd)
|
||||
if cleanupErr != nil {
|
||||
return fmt.Errorf("release daemon process: %w (cleanup failed: %v)", err, cleanupErr)
|
||||
}
|
||||
return fmt.Errorf("release daemon process: %w", err)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
return nil
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
if err := terminateAndReapDaemonProcess(cmd); err != nil {
|
||||
return fmt.Errorf("%w; cleanup failed: %v", errDaemonStartTimeout, err)
|
||||
}
|
||||
return errDaemonStartTimeout
|
||||
}
|
||||
|
||||
func waitForDaemonReadiness(reachable func() bool, timeout time.Duration, pollInterval time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if reachable() {
|
||||
return true
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(min(pollInterval, remaining))
|
||||
}
|
||||
// The loop's last reachable() call already ran immediately before this one
|
||||
// (right after the final Sleep, with the same deadline check separating
|
||||
// them), so this adds no extra waiting time and is not a grace window for a
|
||||
// slow-starting daemon — it only re-checks the same instant in case
|
||||
// reachable() itself is non-deterministic. To tolerate a genuinely slow
|
||||
// start, raise the timeout passed to startAndAwaitDaemonProcess instead.
|
||||
return reachable()
|
||||
}
|
||||
|
||||
func terminateAndReapDaemonProcess(cmd *exec.Cmd) error {
|
||||
return background.TerminateCommand(cmd)
|
||||
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
|
||||
}
|
||||
|
||||
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
|
||||
@@ -2,15 +2,8 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
)
|
||||
|
||||
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
||||
@@ -128,84 +121,3 @@ func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDaemonReadinessChecksTimeoutBoundary(t *testing.T) {
|
||||
checks := 0
|
||||
ready := waitForDaemonReadiness(func() bool {
|
||||
checks++
|
||||
return checks == 2
|
||||
}, 0, time.Millisecond)
|
||||
if !ready {
|
||||
t.Fatal("daemon that became reachable at the timeout boundary was not detected")
|
||||
}
|
||||
if checks != 2 {
|
||||
t.Fatalf("reachability checks = %d, want initial and final checks", checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateAndReapDaemonProcess(t *testing.T) {
|
||||
cmd := daemonDetachedChildCommand("hang")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start helper process: %v", err)
|
||||
}
|
||||
|
||||
if err := terminateAndReapDaemonProcess(cmd); err != nil {
|
||||
t.Fatalf("terminate and reap helper process: %v", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatalf("helper process was not reaped: state=%v", cmd.ProcessState)
|
||||
}
|
||||
if cmd.ProcessState.Success() {
|
||||
t.Fatalf("helper process unexpectedly exited successfully: %v", cmd.ProcessState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAndAwaitDaemonProcessTimeoutTerminatesAndReaps(t *testing.T) {
|
||||
cmd := daemonDetachedChildCommand("hang")
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return false }, 0, time.Millisecond); !errors.Is(err, errDaemonStartTimeout) {
|
||||
t.Fatalf("start and await error = %v, want timeout", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("timed-out helper process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAndAwaitDaemonProcessReleasesReadyChild(t *testing.T) {
|
||||
marker := filepath.Join(t.TempDir(), "child-finished")
|
||||
cmd := daemonDetachedChildCommand("mark", "ZERO_TEST_DAEMON_CHILD_MARKER="+marker)
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return true }, time.Second, time.Millisecond); err != nil {
|
||||
t.Fatalf("start and await ready helper: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("released helper process did not remain alive to write its marker")
|
||||
}
|
||||
|
||||
func daemonDetachedChildCommand(mode string, extraEnv ...string) *exec.Cmd {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestDaemonDetachedChildProcess")
|
||||
cmd.Env = append(os.Environ(), "ZERO_TEST_DAEMON_DETACHED_CHILD="+mode)
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestDaemonDetachedChildProcess(t *testing.T) {
|
||||
switch os.Getenv("ZERO_TEST_DAEMON_DETACHED_CHILD") {
|
||||
case "":
|
||||
return
|
||||
case "mark":
|
||||
if err := os.WriteFile(os.Getenv("ZERO_TEST_DAEMON_CHILD_MARKER"), []byte("ready"), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+19
-2403
File diff suppressed because it is too large.
Load diff
+9
-635
@@ -2,12 +2,8 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -67,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))
|
||||
}
|
||||
@@ -109,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 {
|
||||
@@ -605,20 +519,18 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && options.hasMessage {
|
||||
if command != "commit" && options.message != "" {
|
||||
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
|
||||
}
|
||||
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||
}
|
||||
if command == "commit" && options.hasMessage && options.auto {
|
||||
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||
}
|
||||
if command != "commit" && command != "push" && options.dryRun {
|
||||
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
|
||||
}
|
||||
// --auto on push/pr is the explicit opt-in for LLM branch naming (see
|
||||
// ensureFeatureBranch); on commit it opts into the LLM commit message.
|
||||
if command != "commit" && command != "push" && command != "pr" && options.auto {
|
||||
return options, false, execUsageError{"--auto is only valid with commit, push, or pr"}
|
||||
}
|
||||
if command != "inspect" && options.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
}
|
||||
@@ -874,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
|
||||
}
|
||||
@@ -939,7 +838,8 @@ Flags:
|
||||
--fill Automatically populate PR title and body from commits
|
||||
--draft Create PR as a draft
|
||||
--yes Confirm pushing to a default/protected branch
|
||||
-a, --auto Use the LLM: commit generates the message, push/pr name the auto-created branch (sends the diff to the provider)
|
||||
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
@@ -962,31 +862,11 @@ func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appD
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
|
||||
JSONMode: options.json,
|
||||
AllowDefaultBranch: options.yes,
|
||||
DryRun: options.dryRun,
|
||||
AutoNaming: options.auto,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
// context.Background() is deliberate: the 2-minute ctx above is the
|
||||
// feature-branch preflight (inspection, remote probes, branch creation).
|
||||
// The push itself must not be cancelled mid-transfer just because the
|
||||
// preflight budget elapsed; a large push can legitimately take longer.
|
||||
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: firstNonEmptyString(options.remote, remote),
|
||||
Branch: branch,
|
||||
Remote: options.remote,
|
||||
Force: options.force,
|
||||
DryRun: options.dryRun,
|
||||
RequireNewRemoteBranch: created,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1034,62 +914,15 @@ func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
|
||||
JSONMode: options.json,
|
||||
AllowDefaultBranch: options.yes,
|
||||
DryRun: false,
|
||||
AutoNaming: options.auto,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
// Resolve the target remote for the unborn-remote check. Explicit --remote wins,
|
||||
// followed by the remote resolved by ensureFeatureBranch. When --yes is set,
|
||||
// fall back to the branch upstream or "origin".
|
||||
targetRemote := strings.TrimSpace(options.remote)
|
||||
if targetRemote == "" {
|
||||
targetRemote = remote
|
||||
}
|
||||
if targetRemote == "" {
|
||||
currentBranch := ""
|
||||
if deps.currentGitBranch != nil {
|
||||
currentBranch = deps.currentGitBranch(context.Background(), workspaceRoot)
|
||||
}
|
||||
if deps.branchUpstreamRemote != nil && currentBranch != "" {
|
||||
targetRemote = deps.branchUpstreamRemote(context.Background(), workspaceRoot, currentBranch)
|
||||
}
|
||||
if targetRemote == "" {
|
||||
targetRemote = "origin"
|
||||
}
|
||||
}
|
||||
|
||||
// Probe errors are intentionally ignored so the flow can continue; if the
|
||||
// probe fails, the subsequent push or PR operation provides fail-closed behavior.
|
||||
if deps.isUnbornRemote != nil {
|
||||
if unborn, unbornErr := deps.isUnbornRemote(context.Background(), workspaceRoot, targetRemote); unbornErr == nil && unborn {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("cannot create pull request on unborn remote %s: push the initial default branch first", targetRemote))
|
||||
}
|
||||
}
|
||||
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
// context.Background() is deliberate here too: runChangesPR reuses the
|
||||
// 2-minute ctx for preflight (branch creation and remote probes), but the
|
||||
// push must not be cancelled mid-transfer if that budget elapsed.
|
||||
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: firstNonEmptyString(options.remote, remote),
|
||||
Branch: branch,
|
||||
Remote: options.remote,
|
||||
Force: options.force,
|
||||
RequireNewRemoteBranch: created,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1171,462 +1004,3 @@ func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provide
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
type featureBranchOptions struct {
|
||||
JSONMode bool
|
||||
AllowDefaultBranch bool
|
||||
DryRun bool
|
||||
AutoNaming bool
|
||||
MaxDiffBytes int
|
||||
}
|
||||
|
||||
// branchPushPlan holds the resolved parameters for a push or PR workflow
|
||||
// before any branches are created or refs mutated. Separating plan resolution
|
||||
// from mutation allows tests to assert the resolution matrix directly without
|
||||
// touching a git repository.
|
||||
type branchPushPlan struct {
|
||||
ShouldBranch bool
|
||||
CurrentBranch string
|
||||
PushRemote string
|
||||
RequireNewRemoteBranch bool
|
||||
TrackingRemote string
|
||||
TrackingBranch string
|
||||
RestoreTip string
|
||||
PublishabilityBase string
|
||||
Summary zerogit.ChangeSummary
|
||||
Slug string
|
||||
}
|
||||
|
||||
// resolveBranchPushPlan resolves the branch, remotes, restore tip, and naming
|
||||
// slug for changes push / pr workflows. It performs all checks (clean working
|
||||
// tree, unborn remote detection, ahead count, restore tip resolution) but
|
||||
// executes no mutation (no branch creation, ref resets, or deletions).
|
||||
func resolveBranchPushPlan(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (branchPushPlan, error) {
|
||||
if opts.AllowDefaultBranch || opts.DryRun {
|
||||
return branchPushPlan{
|
||||
ShouldBranch: false,
|
||||
PushRemote: strings.TrimSpace(requestedRemote),
|
||||
RequireNewRemoteBranch: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if deps.isDefaultBranch == nil {
|
||||
return branchPushPlan{}, fmt.Errorf("isDefaultBranch dependency missing")
|
||||
}
|
||||
isDefault, currentBranch, remote, err := deps.isDefaultBranch(ctx, zerogit.DefaultBranchOptions{Cwd: workspaceRoot, Remote: requestedRemote})
|
||||
if err != nil {
|
||||
return branchPushPlan{}, err
|
||||
}
|
||||
if !isDefault {
|
||||
// Whether this push needs the nonexistence lease is decided by one
|
||||
// live check against the destination remote, not by inference from
|
||||
// local git state (upstream config, a generated-branch marker):
|
||||
// every local signal that stood in for that check turned out to be
|
||||
// wrong in some ordering (inherited config from checkout -b, a
|
||||
// config write that failed after a successful push, a push aimed at
|
||||
// a different remote than the one local config describes). A branch
|
||||
// missing on the remote gets the nonexistence lease, protecting a
|
||||
// concurrent creator of the same name; a branch already there gets a
|
||||
// plain push, and git's own fast-forward check is the safety net —
|
||||
// if the remote history isn't ours, the push fails with a clear
|
||||
// non-fast-forward error instead of an attempted silent recovery.
|
||||
if deps.remoteHasBranch == nil {
|
||||
return branchPushPlan{
|
||||
ShouldBranch: false,
|
||||
CurrentBranch: currentBranch,
|
||||
PushRemote: remote,
|
||||
RequireNewRemoteBranch: true,
|
||||
}, nil
|
||||
}
|
||||
targetRemote := firstNonEmptyString(requestedRemote, remote)
|
||||
exists, existsErr := deps.remoteHasBranch(ctx, workspaceRoot, targetRemote, currentBranch)
|
||||
if existsErr != nil {
|
||||
return branchPushPlan{}, fmt.Errorf("cannot check whether %s already exists on remote %s: %w", currentBranch, targetRemote, existsErr)
|
||||
}
|
||||
return branchPushPlan{
|
||||
ShouldBranch: false,
|
||||
CurrentBranch: currentBranch,
|
||||
PushRemote: remote,
|
||||
RequireNewRemoteBranch: !exists,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateBranch and Push publish commits only. A dirty working tree would
|
||||
// leave uncommitted edits behind under a branch/PR that does not include
|
||||
// them, so refuse until the tree is clean (commit or stash first).
|
||||
workingTree, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot})
|
||||
if err != nil {
|
||||
return branchPushPlan{}, fmt.Errorf("failed to inspect working tree: %w", err)
|
||||
}
|
||||
if !workingTree.Clean {
|
||||
return branchPushPlan{}, fmt.Errorf("working tree has uncommitted changes; commit or stash them before pushing from the default branch")
|
||||
}
|
||||
|
||||
// Capture the source default branch's own upstream before refreshing or
|
||||
// leaving it. --remote selects the push destination; restore must not
|
||||
// rewrite local main to upstream/main when main still tracks origin/main
|
||||
// (fork setups), and refreshing must update the exact tracking ref that
|
||||
// will supply both the ahead count and the restore target.
|
||||
trackingRemote := remote
|
||||
trackingBranch := currentBranch
|
||||
if deps.branchUpstreamRemoteAndMerge != nil {
|
||||
if r, m := deps.branchUpstreamRemoteAndMerge(ctx, workspaceRoot, currentBranch); r != "" && m != "" {
|
||||
trackingRemote = r
|
||||
trackingBranch = m
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the local remote-tracking ref before trusting it: IsDefaultBranch
|
||||
// already contacted the remote for its symref check, but the tracking ref
|
||||
// commitsAhead reads from is only a local cache (written at clone or the
|
||||
// last fetch) and can sit behind the remote's live tip. Left stale, a real
|
||||
// publishable range could look like zero commits ahead, and the diff
|
||||
// derived below (which reuses this same ref as its base) would be stale
|
||||
// too. A failure here (offline, or a genuinely unborn remote with no such
|
||||
// ref to fetch yet) is folded into the same fail-closed path as an
|
||||
// unresolvable ahead count below.
|
||||
var fetchErr error
|
||||
if deps.refreshTrackingRef != nil {
|
||||
fetchErr = deps.refreshTrackingRef(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
||||
}
|
||||
|
||||
// The ahead check gates on the source upstream (trackingRemote), but the
|
||||
// feature branch is pushed to `pushRemote`, which can be a different
|
||||
// destination (--remote on a fork). Refuse to auto-branch onto an unborn
|
||||
// destination: without an initial default branch the new feature branch
|
||||
// becomes the destination's first ref and its HEAD is left pointing at it.
|
||||
// This is checked once, here, against the push destination, before resolving
|
||||
// commit tips or creating any branches.
|
||||
pushRemote := firstNonEmptyString(requestedRemote, remote)
|
||||
if deps.isUnbornRemote != nil {
|
||||
if unbornDest, unbornDestErr := deps.isUnbornRemote(ctx, workspaceRoot, pushRemote); unbornDestErr == nil && unbornDest {
|
||||
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", pushRemote)
|
||||
}
|
||||
}
|
||||
|
||||
// Branching off the default branch only makes sense when HEAD carries a
|
||||
// commit that is not already on the remote default branch. A clean,
|
||||
// up-to-date default branch would otherwise publish a feature branch at the
|
||||
// exact default tip. If the ahead count cannot be determined (for example
|
||||
// the remote-tracking ref was never fetched), fail rather than guess. When
|
||||
// the remote is confirmed unborn, refuse auto-branching entirely: the
|
||||
// initial default branch must be established first (changes push --yes)
|
||||
// so the remote has a real HEAD before any feature branch is published.
|
||||
ahead, aheadErr := deps.commitsAhead(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
||||
if fetchErr != nil || aheadErr != nil {
|
||||
var unbornRemote bool
|
||||
var unbornErr error
|
||||
if deps.isUnbornRemote != nil {
|
||||
unbornRemote, unbornErr = deps.isUnbornRemote(ctx, workspaceRoot, trackingRemote)
|
||||
}
|
||||
if unbornErr == nil && unbornRemote {
|
||||
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", trackingRemote)
|
||||
}
|
||||
cause := aheadErr
|
||||
if cause == nil {
|
||||
cause = fetchErr
|
||||
}
|
||||
return branchPushPlan{}, fmt.Errorf("cannot determine whether HEAD is ahead of %s/%s: %w; fetch the remote tracking branch first", trackingRemote, trackingBranch, cause)
|
||||
}
|
||||
if ahead == 0 {
|
||||
return branchPushPlan{}, fmt.Errorf("no changes to publish: HEAD is not ahead of %s/%s; commit your work before pushing", trackingRemote, trackingBranch)
|
||||
}
|
||||
|
||||
// Resolve the restore tip to a concrete commit before creating any branch.
|
||||
// It must never be a bare local branch name: resolving "main" yields the
|
||||
// very commit the feature branch is about to own, so restore becomes a
|
||||
// silent no-op and the publishable commits stay on the default branch. A
|
||||
// named remote resolves through its just-refreshed remote-tracking ref; a
|
||||
// direct URL or path asks the remote itself. Fail closed rather than
|
||||
// substitute a local branch name.
|
||||
restoreTip := ""
|
||||
if deps.resolveRemoteBranchTip != nil {
|
||||
tip, tipErr := deps.resolveRemoteBranchTip(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
||||
if tipErr != nil {
|
||||
return branchPushPlan{}, fmt.Errorf("cannot resolve restore tip for %s/%s: %w", trackingRemote, trackingBranch, tipErr)
|
||||
}
|
||||
restoreTip = tip
|
||||
}
|
||||
if restoreTip == "" {
|
||||
restoreTip = trackingRemote + "/" + trackingBranch
|
||||
}
|
||||
|
||||
// Name the branch (and, with --auto, send the provider) from what HEAD is
|
||||
// actually ahead of the resolved remote branch by, using the same ref
|
||||
// commitsAhead just checked. A working-tree snapshot can describe edits a
|
||||
// commit-only push will never include.
|
||||
baseRef := restoreTip
|
||||
summary, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot, BaseRef: baseRef, MaxDiffBytes: opts.MaxDiffBytes})
|
||||
if err != nil {
|
||||
return branchPushPlan{}, fmt.Errorf("failed to inspect changes: %w", err)
|
||||
}
|
||||
|
||||
slug := fallbackBranchSlug(summary)
|
||||
if len(summary.Files) == 0 {
|
||||
// An empty commit (or one whose only content the diff omits) leaves
|
||||
// nothing to derive a slug from; name the branch from the commit
|
||||
// subject instead.
|
||||
if subject := deps.headCommitSubject(ctx, workspaceRoot); subject != "" {
|
||||
slug = zerogit.SlugifyBranchComponent(subject)
|
||||
}
|
||||
}
|
||||
if opts.AutoNaming && strings.TrimSpace(summary.Diff) != "" {
|
||||
llmSlug := ""
|
||||
if resolved, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil && config.HasProviderProfile(resolved.Provider) {
|
||||
if provider, provErr := deps.newProvider(resolved.Provider); provErr == nil {
|
||||
if !opts.JSONMode {
|
||||
fmt.Fprintln(stdout, "Generating branch name using LLM...")
|
||||
}
|
||||
genCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
generated, genErr := generateAutoBranchSlug(genCtx, provider, resolved.Provider.Model, redactChangeSummary(summary))
|
||||
cancel()
|
||||
if genErr == nil && generated != "" {
|
||||
llmSlug = generated
|
||||
}
|
||||
}
|
||||
}
|
||||
if llmSlug != "" {
|
||||
slug = llmSlug
|
||||
} else if !opts.JSONMode {
|
||||
// Cover resolveConfig failures, missing provider profiles,
|
||||
// newProvider errors, generation errors, and empty results so the
|
||||
// user is not left guessing after "Generating..." or silence.
|
||||
fmt.Fprintln(stdout, "LLM branch naming unavailable; using deterministic name.")
|
||||
}
|
||||
}
|
||||
|
||||
return branchPushPlan{
|
||||
ShouldBranch: true,
|
||||
CurrentBranch: currentBranch,
|
||||
PushRemote: pushRemote,
|
||||
RequireNewRemoteBranch: true,
|
||||
TrackingRemote: trackingRemote,
|
||||
TrackingBranch: trackingBranch,
|
||||
RestoreTip: restoreTip,
|
||||
PublishabilityBase: baseRef,
|
||||
Summary: summary,
|
||||
Slug: slug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureFeatureBranch is the branch-naming step `zero changes push`/`pr` run
|
||||
// before pushing: pushing straight to the default branch is refused deeper in
|
||||
// zerogit.Push, so rather than surface that as a dead end, create and switch
|
||||
// to a conventionally named "<user>/<slug>" branch first. It returns the
|
||||
// branch push/pr should target, or "" to mean "current HEAD branch, unchanged"
|
||||
// (zerogit.Push already treats an empty Branch that way), plus the remote the
|
||||
// preflight resolved (requestedRemote, then the original branch's configured
|
||||
// upstream, then "origin"), plus whether Push should require that branch not
|
||||
// already exist on that remote. Callers must pass the remote to Push: a
|
||||
// freshly created branch has no tracking configuration, so Push's own
|
||||
// fallback would silently retarget "origin" even when the work came from a
|
||||
// branch tracking a different remote. Callers must also pass that third value
|
||||
// through as Push's RequireNewRemoteBranch: CreateBranch's own
|
||||
// remote-collision probe runs before this returns, and closing that race
|
||||
// requires Push's push itself to assert the destination is still new. That
|
||||
// requirement also carries across a retry: if the current branch is already
|
||||
// non-default (this call didn't create it) but has not been successfully
|
||||
// published to the exact target remote under the same branch name, keep the
|
||||
// nonexistence lease. branch.<name>.remote alone is not enough: with
|
||||
// branch.autoSetupMerge=inherit, checkout -b copies remote=origin from the
|
||||
// source branch before any push. After a successful create, the original
|
||||
// default branch ref is moved back to its own upstream tip (not necessarily
|
||||
// the push destination) so the feature branch exclusively owns the
|
||||
// publishable commits; a failed restore rolls the feature branch back.
|
||||
// allowDefaultBranch (the --yes flag) and dryRun both opt out via the ""
|
||||
// branch / false return, leaving Push's own guard/preview behavior on the
|
||||
// default branch unaffected.
|
||||
//
|
||||
// autoNaming gates the LLM naming path (--auto): these commands were
|
||||
// git-only, and sending the change diff to a configured provider on every
|
||||
// default-branch push would silently export source code nobody asked to
|
||||
// share. Without the opt-in the name comes from deterministic local
|
||||
// information only. maxDiffBytes caps the committed-range diff Inspect
|
||||
// returns, so a user who passed --diff-bytes to bound the proprietary source
|
||||
// sent for LLM naming has that cap honored here just as the commit path does.
|
||||
// The working tree must be clean and HEAD must be ahead of the resolved
|
||||
// remote branch; both are verified before any branch is created. The
|
||||
// inspectChanges, commitsAhead, headCommitSubject, currentGitUser, and
|
||||
// createBranch dependencies are required on the default-branch path and are
|
||||
// called without nil guards; fillAppDeps populates all of them. The
|
||||
// refreshTrackingRef, resolveRemoteBranchTip, isUnbornRemote, resetBranchRef,
|
||||
// and deleteBranch dependencies are optional and are skipped when nil.
|
||||
func ensureFeatureBranch(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (string, string, bool, error) {
|
||||
plan, err := resolveBranchPushPlan(ctx, stdout, workspaceRoot, requestedRemote, opts, deps)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
if !plan.ShouldBranch {
|
||||
return plan.CurrentBranch, plan.PushRemote, plan.RequireNewRemoteBranch, nil
|
||||
}
|
||||
|
||||
expectedRestoreTip := ""
|
||||
if deps.currentBranchTip != nil {
|
||||
expectedRestoreTip = deps.currentBranchTip(ctx, workspaceRoot)
|
||||
}
|
||||
|
||||
name := zerogit.BuildBranchName(deps.currentGitUser(ctx, workspaceRoot), plan.Slug)
|
||||
result, err := deps.createBranch(ctx, zerogit.BranchOptions{Cwd: workspaceRoot, Name: name, Remote: plan.PushRemote})
|
||||
if err != nil {
|
||||
return "", "", false, fmt.Errorf("failed to create branch: %w", err)
|
||||
}
|
||||
// The normal flow commits on the default branch first, then creates the
|
||||
// feature branch at the same HEAD. Without moving the original default
|
||||
// ref back, local main keeps the pre-squash commits and diverges from
|
||||
// its upstream after a squash-merge (and a later pull/push can re-publish
|
||||
// them). Once the feature branch owns those commits, restore the default
|
||||
// branch to the source branch's own upstream tip captured above.
|
||||
// Failure-safe: if the restore fails, delete the feature branch and
|
||||
// check the default branch back out so the tree is not left half-moved.
|
||||
if deps.resetBranchRef != nil {
|
||||
if err := deps.resetBranchRef(ctx, workspaceRoot, plan.CurrentBranch, plan.RestoreTip, expectedRestoreTip); err != nil {
|
||||
// A compare-and-swap conflict means the default branch moved
|
||||
// while we were branching, so restoreTip is no longer the tip we
|
||||
// meant to restore. The rollback below deletes the branch that
|
||||
// now exclusively owns the user's commits, which would destroy
|
||||
// work to recover from a race we can simply report. Keep the
|
||||
// branch and let the user reconcile the default branch.
|
||||
if errors.Is(err, zerogit.ErrCompareAndSwapConflict) {
|
||||
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; generated branch %s was preserved because the default branch changed concurrently", plan.CurrentBranch, plan.RestoreTip, err, result.Branch)
|
||||
}
|
||||
var rollbackErr error
|
||||
if deps.deleteBranch != nil {
|
||||
rollbackErr = deps.deleteBranch(ctx, workspaceRoot, plan.CurrentBranch, result.Branch)
|
||||
}
|
||||
if rollbackErr != nil {
|
||||
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; rollback of %s also failed: %v", plan.CurrentBranch, plan.RestoreTip, err, result.Branch, rollbackErr)
|
||||
}
|
||||
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w", plan.CurrentBranch, plan.RestoreTip, err)
|
||||
}
|
||||
}
|
||||
if !opts.JSONMode {
|
||||
fmt.Fprintf(stdout, "Created branch %s (was on %s)\n", result.Branch, plan.CurrentBranch)
|
||||
}
|
||||
return result.Branch, plan.PushRemote, true, nil
|
||||
}
|
||||
|
||||
// fallbackBranchSlug derives a deterministic branch-name slug from a change
|
||||
// summary without calling an LLM, so ensureFeatureBranch still works when no
|
||||
// provider is configured.
|
||||
func fallbackBranchSlug(summary zerogit.ChangeSummary) string {
|
||||
switch len(summary.Files) {
|
||||
case 0:
|
||||
return "changes"
|
||||
case 1:
|
||||
// Git paths are always slash-separated regardless of platform, so
|
||||
// path.Base (not filepath.Base) extracts the filename portably.
|
||||
return zerogit.SlugifyBranchComponent(path.Base(summary.Files[0].Path))
|
||||
default:
|
||||
return fmt.Sprintf("update-%d-files", len(summary.Files))
|
||||
}
|
||||
}
|
||||
|
||||
// generateAutoBranchSlug asks the model for a short kebab-case slug
|
||||
// describing the diff, mirroring generateAutoCommitMessage's prompt shape.
|
||||
func generateAutoBranchSlug(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||
var promptBuilder strings.Builder
|
||||
promptBuilder.WriteString("Analyze the following git diff and generate a short git branch name slug for it.\n")
|
||||
promptBuilder.WriteString("The slug must be 2 to 5 lowercase words separated by hyphens (kebab-case), using only letters, digits, and hyphens, with no prefix like \"feature/\" or \"fix/\" and no surrounding quotes.\n")
|
||||
promptBuilder.WriteString("Output ONLY the raw slug text, nothing else.\n\n")
|
||||
promptBuilder.WriteString("Git Diff:\n")
|
||||
promptBuilder.WriteString(summary.Diff)
|
||||
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
|
||||
},
|
||||
}
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
collected := zeroruntime.CollectStream(ctx, stream)
|
||||
if collected.Error != "" {
|
||||
return "", fmt.Errorf("%s", collected.Error)
|
||||
}
|
||||
|
||||
slug := zerogit.SlugifyBranchComponent(extractBranchSlug(collected.Text))
|
||||
if slug == "" {
|
||||
return "", fmt.Errorf("provider returned empty branch slug")
|
||||
}
|
||||
return slug, nil
|
||||
}
|
||||
|
||||
// slugLineRe matches a line that already reads as a kebab-case slug (letters,
|
||||
// digits, and internal single hyphens only). extractBranchSlug prefers such a
|
||||
// line so a preamble sentence is never mistaken for the slug.
|
||||
var slugLineRe = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
||||
|
||||
func isPreambleText(s string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(s))
|
||||
if strings.HasSuffix(lower, ":") || strings.HasSuffix(lower, ".") || strings.HasSuffix(lower, "!") || strings.HasSuffix(lower, "?") {
|
||||
return true
|
||||
}
|
||||
preambles := []string{
|
||||
"here is", "here's", "below is", "below are", "sure", "certainly",
|
||||
"i suggest", "suggested branch", "branch name", "recommended branch",
|
||||
"how about", "you could use",
|
||||
}
|
||||
for _, p := range preambles {
|
||||
if strings.HasPrefix(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractBranchSlug pulls the intended slug out of a model response that didn't
|
||||
// follow the "output only the raw slug" instruction exactly. It drops Markdown
|
||||
// code-fence lines, then prefers a line that already looks like a kebab-case
|
||||
// slug: that skips a leading preamble such as "Here is a suggested branch
|
||||
// name:" in favor of the "add-login-page" line that follows it. One-word
|
||||
// acknowledgements that happen to match the slug regex ("Sure", "Certainly")
|
||||
// are filtered as preamble before the early return, so "Sure\nadd login page"
|
||||
// yields the real suggestion. Inline labeled forms ("Branch name: add-login-page")
|
||||
// still work: the preamble check runs on the extracted value after the label
|
||||
// is stripped. If no line is strictly kebab-cased, it prefers a plausible
|
||||
// non-preamble line so plain multi-word replies still slugify correctly.
|
||||
func extractBranchSlug(text string) string {
|
||||
var firstLine string
|
||||
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "```") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(strings.Trim(line, `"'`))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
candidate := line
|
||||
if idx := strings.Index(line, ":"); idx != -1 {
|
||||
after := strings.TrimSpace(strings.Trim(line[idx+1:], `"'`))
|
||||
if after != "" {
|
||||
candidate = after
|
||||
}
|
||||
}
|
||||
// Models often terminate a suggestion with sentence punctuation;
|
||||
// strip it so "add login page." is recovered as the slug rather than
|
||||
// rejected as preamble (isPreambleText treats a trailing '.' as a
|
||||
// full sentence). True preamble lines still match the prefix list.
|
||||
candidate = strings.TrimSpace(strings.TrimRight(candidate, ".!?"))
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preamble classification runs before accepting a slug-shaped line so
|
||||
// "Sure" does not win over a later "add-login-page". The check is on
|
||||
// candidate (post label-strip) so "Branch name: add-login-page" still
|
||||
// returns the extracted slug.
|
||||
if slugLineRe.MatchString(candidate) && !isPreambleText(candidate) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
if firstLine == "" && !isPreambleText(candidate) {
|
||||
firstLine = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return firstLine
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -105,17 +105,7 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
//
|
||||
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
|
||||
// it, since both tests return about one WaitDelay after the fixture exits.
|
||||
const (
|
||||
providerCommandTestBudget = 90 * time.Second
|
||||
providerCommandReadyWaitAttempts = 45
|
||||
)
|
||||
|
||||
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
|
||||
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
|
||||
if providerCommandTestBudget < 2*readyWaitCeiling {
|
||||
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
|
||||
}
|
||||
}
|
||||
const providerCommandTestBudget = 60 * time.Second
|
||||
|
||||
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
|
||||
// dying of old age is unobservable: any run where runProviderCommand has not
|
||||
@@ -351,15 +341,15 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
// assertion fail is far more diagnosable than blocking until the
|
||||
// timeout fires and reporting an unrelated error. Each iteration is
|
||||
// about a second, since `ping -n 2` sleeps between its two echoes,
|
||||
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
|
||||
// runner while leaving the other half of providerCommandTestBudget for
|
||||
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
|
||||
// so the ceiling is roughly 15s: far longer than any cold PowerShell
|
||||
// start seen on a runner, and far shorter than
|
||||
// providerCommandTestBudget, so giving up surfaces a legible
|
||||
// PID-file failure rather than a timeout that hides the real cause.
|
||||
lines = append(lines,
|
||||
":zeroWaitReady",
|
||||
"if exist \""+readyFile+"\" goto zeroReady",
|
||||
"set /a ZERO_READY_TRIES+=1",
|
||||
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
|
||||
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
|
||||
"ping -n 2 127.0.0.1 >nul",
|
||||
"goto zeroWaitReady",
|
||||
":zeroReady",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
-84
@@ -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).
|
||||
@@ -151,8 +148,7 @@ type KeyBindingsConfig struct {
|
||||
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
|
||||
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
|
||||
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
|
||||
// TogglePlan is retained for configuration compatibility. Plan updates render
|
||||
// in the transcript and no longer have a persistent panel to toggle.
|
||||
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
|
||||
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
|
||||
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
@@ -344,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
|
||||
@@ -411,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 {
|
||||
@@ -489,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
|
||||
@@ -530,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")
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fileStore(t *testing.T, dir string) *Store {
|
||||
t.Helper()
|
||||
store, err := New(Options{Dir: dir, Storage: "file"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// THE REPRODUCTION, kept as the regression. Before the lock this reliably kept
|
||||
// 1 of 100: every writer read the same map, added its own provider, and the
|
||||
// last rename published a file missing all the others.
|
||||
func TestConcurrentSetKeepsEveryKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := fileStore(t, dir)
|
||||
|
||||
const n = 100
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if err := store.Set(fmt.Sprintf("p%03d", i), fmt.Sprintf("k%03d", i)); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Providers()
|
||||
if err != nil {
|
||||
t.Fatalf("Providers: %v", err)
|
||||
}
|
||||
if len(got) != n {
|
||||
t.Fatalf("kept %d of %d concurrent writes", len(got), n)
|
||||
}
|
||||
// ...and every value is the one its own writer wrote, not a neighbour's.
|
||||
for i := 0; i < n; i++ {
|
||||
key, ok, err := store.Get(fmt.Sprintf("p%03d", i))
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if !ok || key != fmt.Sprintf("k%03d", i) {
|
||||
t.Fatalf("p%03d = %q (present=%v); a writer's own value must survive", i, key, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE RACES SET, and an unlocked delete loses the other writer's key.
|
||||
//
|
||||
// A first version of this only checked that keys nobody deleted survived — and
|
||||
// every reader sees those, so it passed with the lock removed from Delete. The
|
||||
// case that actually breaks is a Delete whose stale read predates a concurrent
|
||||
// Set: it writes back a map that never contained the new key, and the Set is
|
||||
// gone. So the assertion is on the SETS surviving, not on the bystanders.
|
||||
func TestADeleteCannotClobberAConcurrentSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := fileStore(t, dir)
|
||||
|
||||
// Throwaway keys for the deleters to remove, written first so a delete
|
||||
// always has something to do.
|
||||
const churn = 40
|
||||
for i := 0; i < churn; i++ {
|
||||
if err := store.Set(fmt.Sprintf("churn%02d", i), "v"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const adds = 60
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < adds; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_ = store.Set(fmt.Sprintf("added%02d", i), "v")
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < churn; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, _ = store.Delete(fmt.Sprintf("churn%02d", i))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
got, err := store.Providers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
survivors := map[string]bool{}
|
||||
for _, name := range got {
|
||||
survivors[name] = true
|
||||
}
|
||||
missing := []string{}
|
||||
for i := 0; i < adds; i++ {
|
||||
name := fmt.Sprintf("added%02d", i)
|
||||
if !survivors[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("%d of %d concurrent Set calls were clobbered by a racing Delete: %v",
|
||||
len(missing), adds, missing)
|
||||
}
|
||||
// ...and every churn key really was removed, so the deletes were not
|
||||
// no-ops that would make the check above vacuous.
|
||||
for i := 0; i < churn; i++ {
|
||||
if survivors[fmt.Sprintf("churn%02d", i)] {
|
||||
t.Fatalf("churn%02d survived; the deletes did nothing and this test proves nothing", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ACROSS PROCESSES, which is the case that matters: a plan running with
|
||||
// max_workers > 1 spawns child PROCESSES, and an in-process mutex would not
|
||||
// have helped any of them. The race detector cannot see this one either — it is
|
||||
// two OS processes — so it is driven for real.
|
||||
func TestConcurrentSetAcrossProcesses(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("spawns processes")
|
||||
}
|
||||
helper := os.Getenv("ZERO_CREDSTORE_HELPER_DIR")
|
||||
if helper != "" {
|
||||
// Child mode: write our slice of the keys and exit.
|
||||
store, err := New(Options{Dir: helper, Storage: "file"})
|
||||
if err != nil {
|
||||
os.Exit(3)
|
||||
}
|
||||
prefix := os.Getenv("ZERO_CREDSTORE_HELPER_PREFIX")
|
||||
for i := 0; i < 25; i++ {
|
||||
if err := store.Set(fmt.Sprintf("%s%02d", prefix, i), "v"); err != nil {
|
||||
os.Exit(4)
|
||||
}
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
const children = 4
|
||||
var wg sync.WaitGroup
|
||||
failures := make(chan string, children)
|
||||
for c := 0; c < children; c++ {
|
||||
wg.Add(1)
|
||||
go func(c int) {
|
||||
defer wg.Done()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestConcurrentSetAcrossProcesses", "-test.v")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"ZERO_CREDSTORE_HELPER_DIR="+dir,
|
||||
fmt.Sprintf("ZERO_CREDSTORE_HELPER_PREFIX=c%d_", c),
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
failures <- fmt.Sprintf("child %d: %v\n%s", c, err, out)
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
close(failures)
|
||||
for failure := range failures {
|
||||
t.Fatal(failure)
|
||||
}
|
||||
|
||||
store := fileStore(t, dir)
|
||||
got, err := store.Providers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != children*25 {
|
||||
t.Fatalf("kept %d of %d keys written by %d concurrent processes", len(got), children*25, children)
|
||||
}
|
||||
}
|
||||
|
||||
// The lock lives BESIDE the data file, never on it. write publishes by rename,
|
||||
// so a lock taken on the data file would be attached to an inode the next
|
||||
// writer has already replaced — every writer would appear to hold it.
|
||||
func TestTheLockIsNotTheDataFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := fileStore(t, dir)
|
||||
if store.lockPath() == store.file {
|
||||
t.Fatal("the lock is the data file; rename would carry it away")
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Base(store.lockPath()), filepath.Base(store.file)) {
|
||||
t.Fatalf("lock %q is not beside the data file %q", store.lockPath(), store.file)
|
||||
}
|
||||
if err := store.Set("p", "k"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(store.lockPath()); err != nil {
|
||||
t.Fatalf("the lock file was not created: %v", err)
|
||||
}
|
||||
// ...and it survives a write, since the rename replaces only the data file.
|
||||
if err := store.Set("q", "k"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(store.lockPath()); err != nil {
|
||||
t.Fatalf("the lock file did not survive a write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The lock is a security boundary, so the path where it CANNOT be taken needs
|
||||
// coverage too: an operation that could not lock must report that, never proceed
|
||||
// unserialized and never report success. Replacing the credential directory with
|
||||
// a regular file makes the lock's MkdirAll fail on every platform.
|
||||
func TestOperationsFailWhenTheLockCannotBeAcquired(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
dir := filepath.Join(base, "creds")
|
||||
store := fileStore(t, dir)
|
||||
// Where the store expects its directory, put a regular file.
|
||||
if err := os.WriteFile(dir, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := store.Set("openai", "sk-test"); err == nil {
|
||||
t.Error("Set reported success although the lock could not be acquired")
|
||||
}
|
||||
if _, err := store.Delete("openai"); err == nil {
|
||||
t.Error("Delete reported success although the lock could not be acquired")
|
||||
}
|
||||
if _, _, err := store.Get("openai"); err == nil {
|
||||
t.Error("Get reported success although the lock could not be acquired")
|
||||
}
|
||||
if _, err := store.Providers(); err == nil {
|
||||
t.Error("Providers reported success although the lock could not be acquired")
|
||||
}
|
||||
// And nothing may have been written through the failed path.
|
||||
if raw, err := os.ReadFile(dir); err != nil || string(raw) != "not a directory" {
|
||||
t.Errorf("the blocking file was modified: %q (err %v)", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ package credstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -126,27 +125,14 @@ func (s *Store) Backend() string { return s.backend }
|
||||
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
|
||||
|
||||
// Set stores (or overwrites) the API key for provider.
|
||||
func (s *Store) Set(provider, key string) (err error) {
|
||||
func (s *Store) Set(provider, key string) error {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return fmt.Errorf("credstore: provider is required")
|
||||
}
|
||||
if s.backend == "keyring" {
|
||||
// The OS keyring serializes its own writes; the file lock guards the
|
||||
// file backends only.
|
||||
return s.kr.Set(keyringService, keyringPrefix+provider, key)
|
||||
}
|
||||
// READ AND WRITE UNDER ONE LOCK. Separately locking each half would still
|
||||
// lose keys: two writers would each read the same map, each add their own
|
||||
// provider, and the second rename would publish a file missing the first.
|
||||
// Measured before this existed: 1 of 100 concurrent Set calls survived.
|
||||
release, err := s.acquireFileLock(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result instead
|
||||
// of masking the write error that actually explains what went wrong.
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -156,7 +142,7 @@ func (s *Store) Set(provider, key string) (err error) {
|
||||
}
|
||||
|
||||
// Get returns the API key for provider and whether one is stored.
|
||||
func (s *Store) Get(provider string) (value string, found bool, err error) {
|
||||
func (s *Store) Get(provider string) (string, bool, error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return "", false, nil
|
||||
@@ -164,15 +150,6 @@ func (s *Store) Get(provider string) (value string, found bool, err error) {
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Get(keyringService, keyringPrefix+provider)
|
||||
}
|
||||
// A shared lock, so a read serializes against a writer's publish. On unix
|
||||
// the rename is atomic and this only prevents reading an empty file mid-swap;
|
||||
// on Windows an unsynchronized reader holding the file open would block the
|
||||
// writer's rename outright, so readers must coordinate through the same lock.
|
||||
release, err := s.acquireFileLock(false)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
@@ -182,7 +159,7 @@ func (s *Store) Get(provider string) (value string, found bool, err error) {
|
||||
}
|
||||
|
||||
// Delete removes the API key for provider, reporting whether one existed.
|
||||
func (s *Store) Delete(provider string) (existed bool, err error) {
|
||||
func (s *Store) Delete(provider string) (bool, error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return false, nil
|
||||
@@ -190,13 +167,6 @@ func (s *Store) Delete(provider string) (existed bool, err error) {
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Delete(keyringService, keyringPrefix+provider)
|
||||
}
|
||||
// The same lock as Set: a delete is a read-modify-write too, and one racing
|
||||
// a set would otherwise resurrect or erase an unrelated provider.
|
||||
release, err := s.acquireFileLock(true)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -209,22 +179,17 @@ func (s *Store) Delete(provider string) (existed bool, err error) {
|
||||
}
|
||||
|
||||
// Providers lists providers with a stored key (sorted), for display/audit.
|
||||
func (s *Store) Providers() (names []string, err error) {
|
||||
func (s *Store) Providers() ([]string, error) {
|
||||
if s.backend == "keyring" {
|
||||
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
|
||||
// that need a list pass known provider names to Get. Return empty here.
|
||||
return nil, nil
|
||||
}
|
||||
release, err := s.acquireFileLock(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = make([]string, 0, len(data))
|
||||
names := make([]string, 0, len(data))
|
||||
for name := range data {
|
||||
names = append(names, name)
|
||||
}
|
||||
@@ -299,15 +264,6 @@ func (s *Store) write(data map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockPath is the advisory lock guarding a read-modify-write of the credential
|
||||
// file. Beside the data file, never the data file itself — write publishes by
|
||||
// rename and would carry the lock away with the old inode.
|
||||
func (s *Store) lockPath() string { return s.file + ".lock" }
|
||||
|
||||
// filepathDir is filepath.Dir, named locally so the two platform lock files can
|
||||
// share it without either importing path/filepath for one call.
|
||||
func filepathDir(path string) string { return filepath.Dir(path) }
|
||||
|
||||
func normalizeProvider(provider string) string {
|
||||
return strings.ToLower(strings.TrimSpace(provider))
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// acquireFileLock takes an exclusive advisory lock (flock) so a read-modify-write
|
||||
// of the credential file is serialized against every other one — across
|
||||
// processes AND across goroutines, since flock is held per open file
|
||||
// description and two opens in one process contend exactly as two processes do.
|
||||
//
|
||||
// THE LOCK FILE IS SEPARATE FROM THE DATA FILE, and that is not tidiness. write
|
||||
// publishes by os.Rename, which replaces the inode; a lock taken on the data
|
||||
// file would be attached to an inode that the next writer has already replaced,
|
||||
// so every writer would appear to hold it. The lock lives on a file nothing
|
||||
// renames.
|
||||
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
|
||||
path := s.lockPath()
|
||||
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("credstore: lock dir: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credstore: open lock: %w", err)
|
||||
}
|
||||
// Writers take LOCK_EX; readers take LOCK_SH so they run concurrently with
|
||||
// each other but still serialize against a writer's publish (see Get).
|
||||
how := unix.LOCK_SH
|
||||
if exclusive {
|
||||
how = unix.LOCK_EX
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), how); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("credstore: lock: %w", err)
|
||||
}
|
||||
return func() error {
|
||||
// Close alone drops the flock, so the explicit unlock is belt-and-braces —
|
||||
// but its failure is reported rather than swallowed, because a cleanup that
|
||||
// did not complete must not be indistinguishable from one that did. The
|
||||
// callers join this into their result, so it annotates a successful write
|
||||
// instead of masking it.
|
||||
unlockErr := unix.Flock(int(file.Fd()), unix.LOCK_UN)
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(unlockErr, closeErr); err != nil {
|
||||
return fmt.Errorf("credstore: release lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// acquireFileLock takes an exclusive OS lock (LockFileEx) so a read-modify-write
|
||||
// of the credential file is serialized, matching the flock behaviour on unix.
|
||||
//
|
||||
// The lock file is SEPARATE from the data file for the same reason: write
|
||||
// publishes by rename, and a lock on the renamed file would be attached to
|
||||
// something the next writer has already replaced.
|
||||
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
|
||||
path := s.lockPath()
|
||||
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("credstore: lock dir: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credstore: open lock: %w", err)
|
||||
}
|
||||
handle := windows.Handle(file.Fd())
|
||||
overlapped := new(windows.Overlapped)
|
||||
// A fixed 1-byte region, blocking (no LOCKFILE_FAIL_IMMEDIATELY) so a
|
||||
// waiter queues rather than failing the operation. Writers pass
|
||||
// LOCKFILE_EXCLUSIVE_LOCK; readers omit it (a shared lock) so they still
|
||||
// serialize against a writer's publish — which matters more on Windows,
|
||||
// where an unsynchronized reader holding the file open blocks the rename.
|
||||
flags := uint32(0)
|
||||
if exclusive {
|
||||
flags = windows.LOCKFILE_EXCLUSIVE_LOCK
|
||||
}
|
||||
if err := windows.LockFileEx(handle, flags, 0, 1, 0, overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("credstore: lock: %w", err)
|
||||
}
|
||||
return func() error {
|
||||
// Reported rather than swallowed, for the same reason as the unix side: a
|
||||
// cleanup that did not complete must not look identical to one that did.
|
||||
// It matters more here — on Windows the handle staying open is what blocks
|
||||
// the next writer's rename, so a failed release has a visible consequence.
|
||||
unlockErr := windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(unlockErr, closeErr); err != nil {
|
||||
return fmt.Errorf("credstore: release lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
@@ -61,15 +60,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
|
||||
})
|
||||
if err != nil {
|
||||
// Preserve the context.Canceled sentinel (it carries no key) so an
|
||||
// Esc-abort during dial still matches the UI's errors.Is check. Key
|
||||
// on ctx.Err() itself rather than unwrapping err: a cancellation can
|
||||
// surface as a plain transport error (e.g. "closed network
|
||||
// connection") rather than context.Canceled directly.
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
|
||||
return "", fmt.Errorf("connecting to Deepgram: %w", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
@@ -77,26 +68,15 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
// so no conversion (unlike sherpa-onnx, which wants float32).
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
// Select on ctx.Done() while waiting for the next chunk so a cancelled
|
||||
// session does not leave this goroutine blocked on an open idle channel.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
writeErrCh <- ctx.Err()
|
||||
for chunk := range chunks {
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
|
||||
writeErrCh <- err
|
||||
return
|
||||
case chunk, ok := <-chunks:
|
||||
if !ok {
|
||||
// CloseStream flushes any buffered audio and returns final
|
||||
// results before Deepgram closes the socket.
|
||||
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
|
||||
return
|
||||
}
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
|
||||
writeErrCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// CloseStream flushes any buffered audio and returns final results before
|
||||
// Deepgram closes the socket.
|
||||
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
|
||||
}()
|
||||
|
||||
// Deepgram results are per-utterance segments, not cumulative: accumulate the
|
||||
@@ -120,18 +100,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
}
|
||||
default:
|
||||
}
|
||||
// A user abort cancels the streaming context; the UI matches it
|
||||
// with errors.Is(err, context.Canceled), so return the sentinel
|
||||
// itself (it carries no key) instead of a flat redacted string.
|
||||
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
|
||||
// swap above can replace err with a plain transport error (e.g.
|
||||
// "closed network connection") that doesn't itself unwrap to
|
||||
// context.Canceled even though the cancellation is what caused it.
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
//nolint:staticcheck // Preserve established user-facing error text.
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
|
||||
// equal to the API key so the dial error embeds the key and redaction must strip it.
|
||||
func TestDeepgramDialFailureRedactsKey(t *testing.T) {
|
||||
const key = "sk-test-dial-key-abcdef"
|
||||
baseURL := dialFailServerWithUpgrade(t, key)
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: key, BaseURL: baseURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
|
||||
if ferr == nil {
|
||||
t.Fatal("expected dial failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from dial failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "connecting to Deepgram") {
|
||||
t.Errorf("expected dial-path error prefix, got: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// dialFailServerWithUpgrade returns a ws URL whose handshake responds 101 with
|
||||
// Upgrade set to upgradeValue (not "websocket"), so websocket.Dial fails with
|
||||
// that value in the error string. Hijacks and closes immediately to avoid the
|
||||
// library's 3s body-drain timeout on failed dials.
|
||||
func dialFailServerWithUpgrade(t *testing.T, upgradeValue string) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijack not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
|
||||
_, _ = bufrw.WriteString("Connection: Upgrade\r\n")
|
||||
_, _ = bufrw.WriteString("Upgrade: " + upgradeValue + "\r\n\r\n")
|
||||
_ = bufrw.Flush()
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
}
|
||||
|
||||
func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Drain the client's audio frames, then wait for CloseStream so the
|
||||
// client has flushed its writes before we reject the connection. Closing
|
||||
// immediately (before CloseStream) risks the client failing on a write
|
||||
// error and never observing this API-key-bearing close reason.
|
||||
for {
|
||||
typ, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key")
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 320)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
if ferr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(ferr.Error(), "sk-test-key") {
|
||||
t.Errorf("API key leaked: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) {
|
||||
firstFrame := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Hold the connection open, never answering, so the client blocks in
|
||||
// Read until its context is cancelled (the Esc-abort path).
|
||||
var once sync.Once
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
once.Do(func() { close(firstFrame) })
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
chunks <- make([]byte, 320)
|
||||
// The channel stays open: the session is live when the user aborts.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-firstFrame:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc can cancel while the WebSocket dial is still pending. The dial error is
|
||||
// redacted with %s, so we must return ctx.Err() rather than a flat string.
|
||||
func TestDeepgramStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{
|
||||
APIKey: "sk-test-key",
|
||||
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
chunks := make(chan []byte)
|
||||
defer close(chunks)
|
||||
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc right after accept can race the first write or the first Read; both
|
||||
// setup/write redaction sites must still yield context.Canceled.
|
||||
func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
|
||||
accepted := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
close(accepted)
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
// No frames yet: the writer blocks on chunks or the reader blocks on Read.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-accepted:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeepgramStreamErrorRedactsHeaderShapedKey exercises the same stream-error
|
||||
// redaction path as TestDeepgramStreamTranscribeErrorRedaction, with a close
|
||||
// reason that embeds the key next to an X-Api-Key-shaped header label.
|
||||
func TestDeepgramStreamErrorRedactsHeaderShapedKey(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
for {
|
||||
typ, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed")
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-custom-secret-key-1234567890", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 320)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
if ferr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(ferr.Error(), "sk-custom-secret-key-1234567890") {
|
||||
t.Errorf("Custom header API key leaked: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepgramSinglePassRedaction(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
for {
|
||||
typ, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234")
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-key1234567890abcdef1234", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 320)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
if ferr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
got := ferr.Error()
|
||||
if strings.Contains(got, "sk-key1234567890abcdef1234") {
|
||||
t.Fatalf("API key leaked into stream error: %q", got)
|
||||
}
|
||||
// One key occurrence must produce exactly one redaction marker (no double wrap).
|
||||
if strings.Count(got, "[REDACTED]") != 1 {
|
||||
t.Fatalf("redaction markers in %q: want exactly 1 [REDACTED]", got)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
@@ -23,10 +22,6 @@ type OpenAIRealtimeConfig struct {
|
||||
Model string // default "gpt-4o-transcribe"
|
||||
// BaseURL overrides the wss endpoint (tests point it at a fake server).
|
||||
BaseURL string
|
||||
// writeErrInjector, when set, is consulted before each JSON websocket write.
|
||||
// Used by same-package tests to force controllable write failures that embed
|
||||
// secrets; production callers leave it nil.
|
||||
writeErrInjector func() error
|
||||
}
|
||||
|
||||
type openAIRealtimeTranscriber struct {
|
||||
@@ -64,15 +59,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// Preserve the context.Canceled sentinel (it carries no key) so an
|
||||
// Esc-abort during dial still matches the UI's errors.Is check. Key
|
||||
// on ctx.Err() itself rather than unwrapping err: a cancellation can
|
||||
// surface as a plain transport error (e.g. "closed network
|
||||
// connection") rather than context.Canceled directly.
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
|
||||
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
@@ -86,53 +73,24 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := o.writeJSON(ctx, conn, sessionUpdate); err != nil {
|
||||
// Same cancellation short-circuit as the dial above: an Esc-abort
|
||||
// while the session update is in flight must stay context.Canceled.
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
|
||||
if err := writeJSON(ctx, conn, sessionUpdate); err != nil {
|
||||
return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err)
|
||||
}
|
||||
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
// Select on ctx.Done() while waiting for the next chunk so a cancelled
|
||||
// session does not leave this goroutine blocked on an open idle channel.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
writeErrCh <- ctx.Err()
|
||||
for chunk := range chunks {
|
||||
appendMsg := map[string]any{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.StdEncoding.EncodeToString(chunk),
|
||||
}
|
||||
if err := writeJSON(ctx, conn, appendMsg); err != nil {
|
||||
writeErrCh <- err
|
||||
return
|
||||
case chunk, ok := <-chunks:
|
||||
if !ok {
|
||||
// Commit the buffered audio to force final transcription.
|
||||
if err := o.writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"}); err != nil {
|
||||
writeErrCh <- err
|
||||
// Unblock the reader: without a close, conn.Read stays
|
||||
// parked on a silent peer and the error is never drained
|
||||
// from writeErrCh. CloseNow aborts instead of waiting on
|
||||
// the close handshake, which a silent peer never answers.
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
writeErrCh <- nil
|
||||
return
|
||||
}
|
||||
appendMsg := map[string]any{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.StdEncoding.EncodeToString(chunk),
|
||||
}
|
||||
if err := o.writeJSON(ctx, conn, appendMsg); err != nil {
|
||||
writeErrCh <- err
|
||||
// Same unblocking close as the commit path: a server that
|
||||
// goes silent after session setup would otherwise leave the
|
||||
// reader blocked in conn.Read with the error undrained.
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Commit the buffered audio to force final transcription of the utterance.
|
||||
writeErrCh <- writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"})
|
||||
}()
|
||||
|
||||
// OpenAI deltas are incremental (append), unlike Deepgram/sherpa's cumulative
|
||||
@@ -155,17 +113,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
}
|
||||
default:
|
||||
}
|
||||
// A user abort cancels the streaming context; the UI matches it
|
||||
// with errors.Is(err, context.Canceled), so return the sentinel
|
||||
// itself (it carries no key) instead of a flat redacted string.
|
||||
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
|
||||
// swap above can replace err with a plain transport error (e.g.
|
||||
// "closed network connection") that doesn't itself unwrap to
|
||||
// context.Canceled even though the cancellation is what caused it.
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
|
||||
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
@@ -177,12 +125,6 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
if onPartial != nil {
|
||||
onPartial(compose(), false)
|
||||
}
|
||||
// onPartial may deliver the partial that makes the TUI cancel
|
||||
// (Esc). Observe that before the next Read, which can already
|
||||
// have a racing OpenAI error frame buffered.
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
case realtimeCompleted:
|
||||
// A completed item replaces the in-progress delta buffer with the
|
||||
// server's authoritative transcript for that segment.
|
||||
@@ -196,9 +138,6 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
if onPartial != nil {
|
||||
onPartial(compose(), true)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
// Once we've committed the buffer (user stopped) and the server has
|
||||
// returned a completed transcription, the utterance is done.
|
||||
if committed {
|
||||
@@ -208,15 +147,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
case realtimeCommitted:
|
||||
committed = true
|
||||
case realtimeError:
|
||||
// Esc can race an incoming error event: conn.Read may already have
|
||||
// the OpenAI error in hand by the time cancellation lands. Key on
|
||||
// ctx.Err() first, same as every other return point in this loop,
|
||||
// so a cancel that wins the race still surfaces as context.Canceled
|
||||
// instead of a spurious redacted failure alongside it.
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey))
|
||||
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
|
||||
}
|
||||
// The writer signals commit completion out of band; observe it so a
|
||||
// stop with no further audio still flips `committed`.
|
||||
@@ -224,10 +155,6 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
case werr := <-writeErrCh:
|
||||
if werr == nil {
|
||||
committed = true
|
||||
} else if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
} else {
|
||||
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey))
|
||||
}
|
||||
default:
|
||||
}
|
||||
@@ -274,15 +201,6 @@ func parseRealtimeEvent(data []byte) realtimeEvent {
|
||||
return realtimeEvent{kind: realtimeOther}
|
||||
}
|
||||
|
||||
func (o *openAIRealtimeTranscriber) writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||
if o.cfg.writeErrInjector != nil {
|
||||
if err := o.cfg.writeErrInjector(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeJSON(ctx, conn, v)
|
||||
}
|
||||
|
||||
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
|
||||
// equal to the API key so the dial error embeds the key and redaction must strip it.
|
||||
func TestOpenAIRealtimeDialFailureRedactsKey(t *testing.T) {
|
||||
const key = "sk-test-dial-key-abcdef"
|
||||
baseURL := dialFailServerWithUpgrade(t, key)
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: key, BaseURL: baseURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
|
||||
if ferr == nil {
|
||||
t.Fatal("expected dial failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from dial failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "connecting to OpenAI Realtime") {
|
||||
t.Errorf("expected dial-path error prefix, got: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Session-configuration write fails after dial: inject a write error that embeds
|
||||
// the API key (peer close cannot reach this path reliably — the session write
|
||||
// always lands in the kernel buffer first) and assert redaction.
|
||||
func TestOpenAIRealtimeSessionConfigFailureRedactsKey(t *testing.T) {
|
||||
const key = "sk-test-session-key-abcdef"
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Hold open; the client fails on the injected session write before reading.
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
|
||||
APIKey: key,
|
||||
BaseURL: url,
|
||||
writeErrInjector: func() error {
|
||||
return errors.New("invalid API key " + key)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
|
||||
if ferr == nil {
|
||||
t.Fatal("expected session-config failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("session-config failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from session-config failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "configuring OpenAI Realtime session") {
|
||||
t.Errorf("expected session-config error prefix, got: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Asynchronous writer failure: session update succeeds, then the audio writer
|
||||
// injects a key-bearing write error. The server keeps the connection open and
|
||||
// emits a delta so the reader loop observes writeErrCh (not a peer close).
|
||||
func TestOpenAIRealtimeWriterFailureRedactsKey(t *testing.T) {
|
||||
const key = "sk-test-writer-key-abcdef"
|
||||
sessionDone := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
close(sessionDone)
|
||||
// Keep reading and emit a delta so the client processes an event and
|
||||
// drains writeErrCh while the connection is still open.
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(
|
||||
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
|
||||
))
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
var writes atomic.Int32
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
|
||||
APIKey: key,
|
||||
BaseURL: url,
|
||||
writeErrInjector: func() error {
|
||||
// First write is session.update; fail subsequent audio/commit writes.
|
||||
if writes.Add(1) == 1 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("invalid API key " + key)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
// Leave open after the first frame so the writer hits the injected error
|
||||
// on that append rather than a clean commit.
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
done <- ferr
|
||||
}()
|
||||
|
||||
var ferr error
|
||||
select {
|
||||
case ferr = <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
close(chunks)
|
||||
t.Fatal("StreamTranscribe did not complete after writer failure")
|
||||
}
|
||||
close(chunks)
|
||||
|
||||
if ferr == nil {
|
||||
t.Fatal("expected writer failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from writer failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
|
||||
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
|
||||
}
|
||||
select {
|
||||
case <-sessionDone:
|
||||
default:
|
||||
// Session may race the injected audio write; prefix assertion above is enough.
|
||||
}
|
||||
}
|
||||
|
||||
// Writer failure with a silent server: the peer never sends a frame after
|
||||
// session setup, so the reader is parked in conn.Read. The writer must close
|
||||
// the connection on failure or the writeErrCh error is never observed and
|
||||
// StreamTranscribe blocks forever.
|
||||
func TestOpenAIRealtimeWriterFailureUnblocksSilentServer(t *testing.T) {
|
||||
const key = "sk-test-silent-writer-key-abcdef"
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Hold the connection open without ever writing a frame.
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
var writes atomic.Int32
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
|
||||
APIKey: key,
|
||||
BaseURL: url,
|
||||
writeErrInjector: func() error {
|
||||
// First write is session.update; fail the subsequent append write.
|
||||
if writes.Add(1) == 1 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("invalid API key " + key)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
// Leave the channel open: the writer must fail on the append, not the commit.
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {})
|
||||
done <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case ferr := <-done:
|
||||
if ferr == nil {
|
||||
t.Fatal("expected writer failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from writer failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
|
||||
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
close(chunks)
|
||||
t.Fatal("StreamTranscribe did not unblock after writer failure with a silent server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
defer c.Close(websocket.StatusNormalClosure, "")
|
||||
for {
|
||||
typ, _, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`))
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
if ferr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(ferr.Error(), "sk-test-key") {
|
||||
t.Errorf("API key leaked: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) {
|
||||
firstFrame := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Hold the connection open, never answering, so the client blocks in
|
||||
// Read until its context is cancelled (the Esc-abort path).
|
||||
var once sync.Once
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
once.Do(func() { close(firstFrame) })
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
chunks <- make([]byte, 480)
|
||||
// The channel stays open: the session is live when the user aborts.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-firstFrame:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc can cancel while the WebSocket dial is still pending. The dial error is
|
||||
// redacted with %s, so we must return ctx.Err() rather than a flat string.
|
||||
func TestOpenAIRealtimeStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
|
||||
APIKey: "sk-test-key",
|
||||
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
chunks := make(chan []byte)
|
||||
defer close(chunks)
|
||||
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc right after accept can hit the session-update write or the first Read;
|
||||
// both redaction sites must still yield context.Canceled.
|
||||
func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
|
||||
accepted := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
close(accepted)
|
||||
// Do not read: leave the client in session-update write or first Read.
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-accepted:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel while the writer is blocked on more chunks. Whichever path observes
|
||||
// the cancellation first (the blocked conn.Read or the writeErrCh drain) must
|
||||
// return context.Canceled rather than a redacted flat string.
|
||||
func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) {
|
||||
sessionReceived := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
close(sessionReceived)
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
defer close(chunks)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-sessionReceived:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc can race an incoming OpenAI error event. conn.Read may already have
|
||||
// the error frame in hand by the time the cancellation lands. The server
|
||||
// fires a delta immediately followed by the error, back to back. Cancel from
|
||||
// inside the delta callback (same path the TUI uses when a partial triggers
|
||||
// Esc handling) so the cancel is observed before the next event is processed.
|
||||
// The returned error must still be context.Canceled, not the redacted OpenAI
|
||||
// error.
|
||||
func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(
|
||||
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
|
||||
))
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(
|
||||
`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`,
|
||||
))
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
chunks <- make([]byte, 480)
|
||||
var once sync.Once
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {
|
||||
// Cancel synchronously from the partial callback, before the
|
||||
// stream loop continues to the already-buffered error frame.
|
||||
once.Do(cancel)
|
||||
})
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("StreamTranscribe error = %v, want context.Canceled (cancel must win over a racing OpenAI error event)", ferr)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@@ -40,45 +38,12 @@ func KillProcessTree(pid int) error {
|
||||
// TerminateProcessTree requests graceful termination, then force-kills the
|
||||
// process tree after grace. Callers retain their distinct persistence models;
|
||||
// this function owns only the OS lifecycle primitive.
|
||||
//
|
||||
// The signal target is REDISCOVERED here via processSignalTarget(pid), which
|
||||
// asks the OS for pid's current group. A caller that already knows pid was
|
||||
// configured as its own group leader at launch (e.g. via ConfigureProcessGroup)
|
||||
// should use TerminateProcessGroup instead — see its doc comment for why this
|
||||
// rediscovery is fragile once the leader may have already exited.
|
||||
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return terminateTarget(pid, target, grace, poll)
|
||||
}
|
||||
|
||||
// TerminateProcessGroup is like TerminateProcessTree, but for a caller that
|
||||
// already knows pid is (or was, at launch) its own process-group leader — e.g.
|
||||
// a command started after ConfigureProcessGroup(cmd), which sets Setpgid so
|
||||
// cmd.Process.Pid always leads its own group at the moment it is signalled.
|
||||
//
|
||||
// TerminateProcessTree instead rediscovers the group via syscall.Getpgid(pid)
|
||||
// at call time. On Darwin, that lookup can return ESRCH once an unreaped group
|
||||
// leader has already exited, even though live descendants remain in the group
|
||||
// it configured — TerminateProcessTree would then silently fall back to
|
||||
// signalling only the (already dead) leader PID and leave those descendants
|
||||
// running. Skipping rediscovery in favor of the launch-time invariant avoids
|
||||
// that: the negative PID is used directly, regardless of whether the leader is
|
||||
// still alive to be looked up.
|
||||
func TerminateProcessGroup(pid int, grace, poll time.Duration) error {
|
||||
if pid <= 1 {
|
||||
return fmt.Errorf("refusing to signal invalid pid %d", pid)
|
||||
}
|
||||
return terminateTarget(pid, -pid, grace, poll)
|
||||
}
|
||||
|
||||
// terminateTarget signals target (an individual PID or, negative, a process
|
||||
// group) with SIGTERM then, after grace, SIGKILL. reportPid identifies the
|
||||
// original process in error messages regardless of which form target takes.
|
||||
func terminateTarget(reportPid, target int, grace, poll time.Duration) error {
|
||||
alive := func() bool { return signalTargetRunning(target) }
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
@@ -112,91 +77,11 @@ func terminateTarget(reportPid, target int, grace, poll time.Duration) error {
|
||||
time.Sleep(poll)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", reportPid)
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// signalTargetRunning reports whether the signal target still has a process that
|
||||
// is actually RUNNING, as opposed to one waiting to be reaped.
|
||||
//
|
||||
// kill(2) with signal 0 succeeds for a zombie: the PID still exists until its
|
||||
// parent collects it. That matters because the target here is usually a process
|
||||
// GROUP, and a terminated leader's child is briefly a zombie before init/launchd
|
||||
// reaps it. Treating that window as "still alive" made termination sit through
|
||||
// both grace periods, SIGKILL an already-dead group, and then report
|
||||
// "did not exit after SIGKILL" for a tree that had in fact stopped — turning a
|
||||
// successful cleanup into a spurious failure (and a flaky one, since it depends on
|
||||
// reap timing).
|
||||
//
|
||||
// Zombie detection goes through ps, which is available on every Unix we target and
|
||||
// avoids a /proc dependency Darwin does not have. It is only consulted when the
|
||||
// cheap kill check says something is still there, so the common path costs nothing
|
||||
// extra. If ps cannot be run or its output cannot be parsed, the conservative
|
||||
// pre-existing answer (the kill result) stands.
|
||||
func signalTargetRunning(target int) bool {
|
||||
if syscall.Kill(target, syscall.Signal(0)) != nil {
|
||||
return false
|
||||
}
|
||||
// A negative target is a process GROUP: check whether any member is
|
||||
// non-zombie. A positive target is an individual PID that is NOT its own
|
||||
// group leader (processSignalTarget's fallback) — its actual PGID differs
|
||||
// from its own PID, so matching rows by PGID here would look for a group
|
||||
// that doesn't exist and always report "unknown" (conservatively alive).
|
||||
// Check its own PID's state instead.
|
||||
var running, ok bool
|
||||
if target < 0 {
|
||||
running, ok = processGroupHasRunningMember(-target)
|
||||
} else {
|
||||
running, ok = processIsRunning(target)
|
||||
}
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return running
|
||||
}
|
||||
|
||||
// processGroupHasRunningMember reports whether any member of pgid is in a state
|
||||
// other than zombie. ok is false when the group's states could not be determined.
|
||||
func processGroupHasRunningMember(pgid int) (running bool, ok bool) {
|
||||
return processTableStateMatches(func(_, rowPgid int) bool { return rowPgid == pgid })
|
||||
}
|
||||
|
||||
// processIsRunning reports whether pid itself is in a state other than zombie.
|
||||
// ok is false when pid's state could not be determined.
|
||||
func processIsRunning(pid int) (running bool, ok bool) {
|
||||
return processTableStateMatches(func(rowPid, _ int) bool { return rowPid == pid })
|
||||
}
|
||||
|
||||
// processTableStateMatches scans the process table once, via ps, and reports
|
||||
// whether any row satisfying match is non-zombie, and whether at least one
|
||||
// matching row was found at all (ok). No rows found means either the process
|
||||
// (or group) genuinely doesn't exist (a race with exit) or ps's output was
|
||||
// unparseable/restricted; either way, the caller should not claim knowledge.
|
||||
func processTableStateMatches(match func(pid, pgid int) bool) (running bool, ok bool) {
|
||||
output, err := exec.Command("ps", "-A", "-o", "pid=,pgid=,stat=").Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
found := false
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
rowPid, errPid := strconv.Atoi(fields[0])
|
||||
rowPgid, errPgid := strconv.Atoi(fields[1])
|
||||
if errPid != nil || errPgid != nil || !match(rowPid, rowPgid) {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.HasPrefix(fields[2], "Z") {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, found
|
||||
}
|
||||
|
||||
func processSignalTarget(pid int) (int, error) {
|
||||
if pid <= 1 {
|
||||
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
|
||||
@@ -206,14 +91,10 @@ func processSignalTarget(pid int) (int, error) {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else {
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
// Preserve the individual target; the signal call below treats ESRCH as
|
||||
// already gone, which is a successful lifecycle outcome.
|
||||
return pid, nil
|
||||
}
|
||||
// Conservatively retain the individual PID after other lookup failures;
|
||||
// guessing and signalling a process group could affect unrelated processes.
|
||||
} else if errors.Is(err, syscall.ESRCH) {
|
||||
// Preserve the individual target; the signal call below treats ESRCH as
|
||||
// already gone, which is a successful lifecycle outcome.
|
||||
return pid, nil
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
|
||||
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
|
||||
// zombie, so a group whose only remaining member is waiting to be reaped used to
|
||||
// sit through both grace periods, take a pointless SIGKILL, and then be reported
|
||||
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
|
||||
// depended on when the reaper ran.
|
||||
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
ConfigureProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
// Deliberately do NOT Wait: the child stays a zombie in its own group, which
|
||||
// is exactly the state a terminated tree passes through.
|
||||
t.Cleanup(func() { _ = cmd.Wait() })
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if zombie, ok := processIsZombie(pid); ok && zombie {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
|
||||
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
|
||||
}
|
||||
|
||||
if signalTargetRunning(-pid) {
|
||||
t.Fatal("a group holding only a zombie must not count as running")
|
||||
}
|
||||
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
|
||||
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminateProcessTreeStopsRunningGroup is the positive control: a group with
|
||||
// a live member must still be seen as running and then actually stopped, so the
|
||||
// zombie tolerance above cannot degrade into ignoring real processes.
|
||||
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "sleep 30")
|
||||
ConfigureProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
defer func() { _ = cmd.Wait() }()
|
||||
|
||||
if !signalTargetRunning(-pid) {
|
||||
t.Fatal("a group with a live member must count as running")
|
||||
}
|
||||
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
|
||||
t.Fatalf("TerminateProcessTree: %v", err)
|
||||
}
|
||||
if running, ok := processGroupHasRunningMember(pid); ok && running {
|
||||
t.Fatal("the group still has a running member after termination")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignalTargetRunningTreatsZombieIndividualPIDAsExited is the regression
|
||||
// test for jatmn's second #774 finding: signalTargetRunning's zombie check
|
||||
// matched the individual-PID fallback target (a process that is NOT its own
|
||||
// group leader — processSignalTarget's positive-PID case) against process-table
|
||||
// rows by PGID. That target's actual PGID differs from its own PID by
|
||||
// definition (that's exactly why processSignalTarget fell back to the
|
||||
// individual PID instead of a negative group target), so no row ever matched,
|
||||
// "unknown" resulted every time, and signalTargetRunning conservatively (and
|
||||
// incorrectly) treated a zombie individual-PID target as still running.
|
||||
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
|
||||
// test process's group, so it is not its own leader and processSignalTarget
|
||||
// falls back to the individual (positive) PID.
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
t.Cleanup(func() { _ = cmd.Wait() })
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if zombie, ok := processIsZombie(pid); ok && zombie {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
t.Fatalf("processSignalTarget: %v", err)
|
||||
}
|
||||
if target != pid {
|
||||
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
|
||||
}
|
||||
if signalTargetRunning(target) {
|
||||
t.Fatal("a zombie individual-PID target must not count as running")
|
||||
}
|
||||
}
|
||||
|
||||
// processIsZombie reports a process's zombie state via ps. ok is false when the
|
||||
// state could not be read.
|
||||
func processIsZombie(pid int) (zombie bool, ok bool) {
|
||||
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
state := string(output)
|
||||
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
|
||||
state = state[1:]
|
||||
}
|
||||
if state == "" {
|
||||
return false, false
|
||||
}
|
||||
return state[0] == 'Z', true
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -14,34 +12,10 @@ import (
|
||||
|
||||
func ConfigureProcessGroup(cmd *exec.Cmd) {}
|
||||
|
||||
// KillProcessTree asks taskkill /T to kill pid and descendants it can still
|
||||
// discover from that live root. If taskkill fails, it falls back to killing pid
|
||||
// alone but still returns the tree error because descendants may have survived.
|
||||
// Neither path can recover descendants after the root has exited.
|
||||
func KillProcessTree(pid int) error {
|
||||
return killProcessTree(pid, runTaskkill, killRootProcess)
|
||||
}
|
||||
|
||||
func killProcessTree(pid int, killTree, killRoot func(int) error) error {
|
||||
treeErr := killTree(pid)
|
||||
if treeErr == nil {
|
||||
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
rootErr := killRoot(pid)
|
||||
if rootErr != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("kill process tree rooted at %d: %w", pid, treeErr),
|
||||
fmt.Errorf("kill root process %d: %w", pid, rootErr),
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("kill process tree rooted at %d (root process killed directly): %w", pid, treeErr)
|
||||
}
|
||||
|
||||
func runTaskkill(pid int) error {
|
||||
return exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
|
||||
}
|
||||
|
||||
func killRootProcess(pid int) error {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKillProcessTreeRetainsTreeErrorWhenRootFallbackSucceeds(t *testing.T) {
|
||||
treeErr := errors.New("taskkill failed")
|
||||
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return nil })
|
||||
if !errors.Is(err, treeErr) {
|
||||
t.Fatalf("killProcessTree error = %v, want tree error", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "root process killed directly") {
|
||||
t.Fatalf("killProcessTree error = %q, want fallback context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillProcessTreeJoinsTreeAndRootErrors(t *testing.T) {
|
||||
treeErr := errors.New("taskkill failed")
|
||||
rootErr := errors.New("root kill failed")
|
||||
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return rootErr })
|
||||
if !errors.Is(err, treeErr) || !errors.Is(err, rootErr) {
|
||||
t.Fatalf("killProcessTree error = %v, want both failures", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Loaded 100 of 382 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user