mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea0c21472 | ||
|
|
0368c2bd3b | ||
|
|
14f8a1ab90 | ||
|
|
ff608c7834 | ||
|
|
57858a31d1 | ||
|
|
1e73ecae8a | ||
|
|
cae0269e77 | ||
|
|
cd0eb194e4 | ||
|
|
4ca4fa75d3 | ||
|
|
edf660ab85 | ||
|
|
91b413c514 | ||
|
|
021281ebba | ||
|
|
8e266797a8 | ||
|
|
d37de9214b | ||
|
|
588635487f | ||
|
|
845df6c7d2 | ||
|
|
15eb16f736 | ||
|
|
097c2652cb | ||
|
|
15b7a5475c | ||
|
|
bdbda89f3e | ||
|
|
8acd42a3bd | ||
|
|
1c38e8cba5 | ||
|
|
58daea7a48 | ||
|
|
3e7692d8f0 | ||
|
|
33c94ec7a3 | ||
|
|
c9f9dc02ef | ||
|
|
ec3b822505 | ||
|
|
81a5e6cb71 | ||
|
|
5d1869e7bc | ||
|
|
518d2e53ba | ||
|
|
d82b94defe | ||
|
|
88fb7b6192 | ||
|
|
0125e70d0f | ||
|
|
f5fa4ffc64 | ||
|
|
9fbdc34155 | ||
|
|
583e6530bd | ||
|
|
ac50a5a840 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
*.go text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.sum text eol=lf
|
||||
+33
-12
@@ -30,6 +30,36 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install Make on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: choco install make --no-progress -y
|
||||
|
||||
- name: Validate quality targets with stock macOS Make
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 1
|
||||
GIT_CONFIG_KEY_0: grep.patternType
|
||||
GIT_CONFIG_VALUE_0: fixed
|
||||
run: make -n vulncheck
|
||||
|
||||
- name: Check vulnerabilities through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: cmd
|
||||
run: make vulncheck
|
||||
|
||||
- name: Check dead code through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make deadcode
|
||||
|
||||
- name: Run static lint through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make lint-static
|
||||
|
||||
- name: Check formatting
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
@@ -100,27 +130,18 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
||||
# their own modules, which can drop below the version go.mod requires and
|
||||
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
||||
# so all three run under it.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
|
||||
|
||||
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
||||
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
||||
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
||||
- name: govulncheck
|
||||
run: go run golang.org/x/vuln/cmd/[email protected] ./...
|
||||
run: make vulncheck
|
||||
|
||||
# Advisory: reports functions unreachable from any cmd/* main so dormant
|
||||
# code is visible in CI. Non-blocking while the dormant subsystems are
|
||||
# still being wired or removed.
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||
run: make deadcode
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
@@ -130,4 +151,4 @@ jobs:
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
||||
run: make lint-static
|
||||
@@ -1 +1 @@
|
||||
{".":"0.5.0"}
|
||||
{".":"0.6.0"}
|
||||
@@ -51,10 +51,8 @@ request, or completing an implementation task:
|
||||
or run focused tests with `-race`, when concurrency is affected.
|
||||
4. **Build**: `go run ./cmd/zero-release build`.
|
||||
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
||||
6. **Advisory lint**:
|
||||
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
7. **Security**:
|
||||
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
6. **Advisory lint**: `make lint-static`.
|
||||
7. **Security**: `make vulncheck`.
|
||||
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
||||
tracked changes).
|
||||
|
||||
@@ -69,3 +67,39 @@ 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.
|
||||
- **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,6 +6,37 @@ 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.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add persistent session goals ([#803](https://github.com/Gitlawb/zero/issues/803)) ([5d1869e](https://github.com/Gitlawb/zero/commit/5d1869e7bc193c97e07418b57b823905c1151d5d))
|
||||
* **providers:** add Fireworks AI provider preset ([#772](https://github.com/Gitlawb/zero/issues/772)) ([58daea7](https://github.com/Gitlawb/zero/commit/58daea7a482927e5ab35afaf97761c60bb35e113))
|
||||
* **skills:** report hash drift in skill info ([#795](https://github.com/Gitlawb/zero/issues/795)) ([9fbdc34](https://github.com/Gitlawb/zero/commit/9fbdc341551601c13434cf7b9b6f5e4ea4c483b1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** interrupt idle reads on cancellation ([#782](https://github.com/Gitlawb/zero/issues/782)) ([15b7a54](https://github.com/Gitlawb/zero/commit/15b7a5475cfeefddbc59634551451a8872b996b7))
|
||||
* **cli:** include plugins info in shell completions ([#794](https://github.com/Gitlawb/zero/issues/794)) ([fa8734f](https://github.com/Gitlawb/zero/commit/fa8734f17927de8832dfe041e043cfb86f93d12a))
|
||||
* **config:** kill provider-command process tree via job object on Windows ([#690](https://github.com/Gitlawb/zero/issues/690)) ([5c58256](https://github.com/Gitlawb/zero/commit/5c58256abc29c185b449f8ce79647eb3fae61882))
|
||||
* **config:** make the provider-command timeout an upper bound ([#811](https://github.com/Gitlawb/zero/issues/811)) ([#813](https://github.com/Gitlawb/zero/issues/813)) ([1c38e8c](https://github.com/Gitlawb/zero/commit/1c38e8cba50db255f3cda8b7cf4298712b2933e1))
|
||||
* **config:** stop billing provider-command fixtures to the 5s timeout ([#810](https://github.com/Gitlawb/zero/issues/810)) ([f5fa4ff](https://github.com/Gitlawb/zero/commit/f5fa4ffc64537897aa78a56edca36d8f1dacd021))
|
||||
* **cron:** reserve job IDs atomically ([#686](https://github.com/Gitlawb/zero/issues/686)) ([d9b882e](https://github.com/Gitlawb/zero/commit/d9b882eb0a94f38de8447cb54b57860ccd8ed053))
|
||||
* **lint:** resolve static analysis baseline ([#769](https://github.com/Gitlawb/zero/issues/769)) ([583e653](https://github.com/Gitlawb/zero/commit/583e6530bd849a8efcb2b88bbe8c297e8592e62c))
|
||||
* **providers:** retry provably pre-send transport failures ([#447](https://github.com/Gitlawb/zero/issues/447)) ([#750](https://github.com/Gitlawb/zero/issues/750)) ([8acd42a](https://github.com/Gitlawb/zero/commit/8acd42a3bd743a35807d762f23a33fb61c171839))
|
||||
* **sandbox:** add platform-specific write lock around grant state file ([#752](https://github.com/Gitlawb/zero/issues/752)) ([#755](https://github.com/Gitlawb/zero/issues/755)) ([88fb7b6](https://github.com/Gitlawb/zero/commit/88fb7b6192c1ac8173089967d5e9119f92d7a101))
|
||||
* **sandbox:** clarify scoped permission prompts ([#798](https://github.com/Gitlawb/zero/issues/798)) ([9fe02ae](https://github.com/Gitlawb/zero/commit/9fe02ae52e3234cb9dae76e0c5594a287ccbb52a))
|
||||
* **sandbox:** deny reads of git's credential stores ([#815](https://github.com/Gitlawb/zero/issues/815)) ([#816](https://github.com/Gitlawb/zero/issues/816)) ([33c94ec](https://github.com/Gitlawb/zero/commit/33c94ec7a3e7987e6e36b5fd5e037ff4e6e84c26))
|
||||
* **sandbox:** preserve user config and retry denied writes ([#801](https://github.com/Gitlawb/zero/issues/801)) ([81a5e6c](https://github.com/Gitlawb/zero/commit/81a5e6cb716ba4b7ecf7ff3ff261dfa51fd01098))
|
||||
* **sandbox:** scrub the daemon token file pointer ([#677](https://github.com/Gitlawb/zero/issues/677)) ([#818](https://github.com/Gitlawb/zero/issues/818)) ([3e7692d](https://github.com/Gitlawb/zero/commit/3e7692d8f0885d346809869935c81a9cf055d89e))
|
||||
* **swarm:** close lifecycle admission on shutdown ([#776](https://github.com/Gitlawb/zero/issues/776)) ([bdbda89](https://github.com/Gitlawb/zero/commit/bdbda89f3e42d93e8058ab3e1e9ef402659530cd))
|
||||
* **tui:** fix rendering corruption over multipass + Windows Terminal ([#709](https://github.com/Gitlawb/zero/issues/709)) ([5bb1bc1](https://github.com/Gitlawb/zero/commit/5bb1bc11c3188dcea7709ff07c8b25dda3541de8))
|
||||
* **tui:** paste clipboard images with Ctrl+V ([#534](https://github.com/Gitlawb/zero/issues/534)) ([#807](https://github.com/Gitlawb/zero/issues/807)) ([ec3b822](https://github.com/Gitlawb/zero/commit/ec3b8225050eb62f0f41a9582ed2881d2f9dcc8e))
|
||||
* **tui:** reset quiet clock for new runs ([#799](https://github.com/Gitlawb/zero/issues/799)) ([a4a805a](https://github.com/Gitlawb/zero/commit/a4a805a6c82c51cb255e909bd87ca183c8805cf2))
|
||||
* **windows:** use PowerShell for command execution ([#804](https://github.com/Gitlawb/zero/issues/804)) ([518d2e5](https://github.com/Gitlawb/zero/commit/518d2e53ba3fa073090a9b41c278face85b59b64))
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||
# lint` before opening a PR" — these targets back those instructions.
|
||||
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
|
||||
# with `make` — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
|
||||
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
|
||||
GO_TOOLCHAIN = go$(GO_VERSION)
|
||||
DEADCODE_VERSION := v0.46.0
|
||||
GOLANGCI_LINT_VERSION := v2.12.2
|
||||
GOVULNCHECK_VERSION := v1.3.0
|
||||
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
@@ -33,6 +39,22 @@ fmt-check:
|
||||
# Lint = formatting check + vet (no extra tooling required).
|
||||
lint: fmt-check vet
|
||||
|
||||
# Versioned tools select the toolchain from their own modules when invoked with
|
||||
# package@version. Read this module's go directive directly, without invoking
|
||||
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
|
||||
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
|
||||
# The target-specific export is shell-independent.
|
||||
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
|
||||
lint-static:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||
|
||||
deadcode:
|
||||
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
|
||||
|
||||
vulncheck:
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
@@ -55,4 +77,4 @@ baseline: build
|
||||
--output internal/perfbench/reports/baseline.json
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
|
||||
@@ -131,6 +131,7 @@ export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
@@ -147,6 +148,12 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
To configure Fireworks AI directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup fireworks --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
@@ -367,22 +374,16 @@ variable unset and set.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
|
||||
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
|
||||
|
||||
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
|
||||
2. **Vetting**: Run `go vet ./...` (or `make vet`).
|
||||
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
|
||||
2. **Vetting**: Run `make vet` (or `go vet ./...`).
|
||||
3. **Linting**: Run `make lint-static`.
|
||||
4. **Vulnerability Scan**: Run `make vulncheck`.
|
||||
|
||||
If you prefer to install these tools globally on your path, you can run:
|
||||
|
||||
```bash
|
||||
# Install golangci-lint
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||
|
||||
# Install govulncheck
|
||||
go install golang.org/x/vuln/cmd/[email protected]
|
||||
```
|
||||
Use the repository-managed targets rather than globally installed binaries:
|
||||
the targets apply the module's required Go toolchain as well as the reviewed,
|
||||
pinned tool versions.
|
||||
|
||||
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
||||
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
||||
|
||||
+2
-2
@@ -108,10 +108,10 @@ Discovery roots (earlier wins on name collisions):
|
||||
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
||||
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
||||
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
|
||||
|
||||
```text
|
||||
~/.local/share/zero/skills/ # primary (install/remove/lock target)
|
||||
~/.local/share/zero/skills/ # primary (install/remove target)
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
|
||||
+138
-6
@@ -17,6 +17,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// JSON-RPC 2.0 standard error codes.
|
||||
@@ -80,8 +81,9 @@ type NotifyFunc func(ctx context.Context, params json.RawMessage)
|
||||
// requests/notifications — needed because ACP is bidirectional (the agent calls
|
||||
// the client for session/request_permission, fs/*, terminal/*).
|
||||
type Conn struct {
|
||||
reader *bufio.Reader
|
||||
w io.Writer
|
||||
rawReader io.Reader // wrapped lazily in Serve, once ctx is known — see interruptibleReader
|
||||
readerCloser io.Closer
|
||||
w io.Writer
|
||||
|
||||
writeMu sync.Mutex // serializes all writes to w
|
||||
|
||||
@@ -96,10 +98,15 @@ type Conn struct {
|
||||
wg sync.WaitGroup // tracks in-flight inbound handlers
|
||||
}
|
||||
|
||||
// NewConn builds a peer reading ndjson from r and writing ndjson to w.
|
||||
// NewConn builds a peer reading ndjson from r and writing ndjson to w. This
|
||||
// Conn does not own r: cancelling Serve's context cannot forcibly close it, so
|
||||
// a Read on r that's genuinely idle (never returns on its own) leaves Serve
|
||||
// blocked until r itself produces something — e.g. a test that closes r's own
|
||||
// write side to unblock it. Use NewOwnedConn when this Conn should own r's
|
||||
// lifetime.
|
||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
return &Conn{
|
||||
reader: bufio.NewReader(r),
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
@@ -107,6 +114,18 @@ func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
}
|
||||
}
|
||||
|
||||
// NewOwnedConn is like NewConn but declares that this Conn exclusively owns r:
|
||||
// if r implements io.Closer, Serve closes it on context cancellation to
|
||||
// interrupt an otherwise-idle blocking Read. Only pass a reader whose lifetime
|
||||
// this Conn should control — e.g. the process's own stdin — never a reader a
|
||||
// caller retains or shares elsewhere, since cancellation tears it down
|
||||
// permanently for every consumer, not just this Conn.
|
||||
func NewOwnedConn(r io.Reader, w io.Writer) *Conn {
|
||||
c := NewConn(r, w)
|
||||
c.readerCloser, _ = r.(io.Closer)
|
||||
return c
|
||||
}
|
||||
|
||||
// Handle registers a request handler for method.
|
||||
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
|
||||
|
||||
@@ -128,17 +147,36 @@ func (c *Conn) Serve(ctx context.Context) error {
|
||||
c.wg.Wait()
|
||||
}()
|
||||
|
||||
interruptible := newInterruptibleReader(ctx, c.rawReader, c.readerCloser)
|
||||
reader := bufio.NewReader(interruptible)
|
||||
|
||||
for {
|
||||
// One ndjson value per line. Framing per line (rather than streaming the
|
||||
// decoder) keeps a single malformed line from making the whole connection
|
||||
// unrecoverable — we report -32700 and continue.
|
||||
line, err := c.reader.ReadBytes('\n')
|
||||
//
|
||||
// generation brackets this SPECIFIC ReadBytes call: bufio may invoke
|
||||
// interruptible.Read zero or more times underneath it (zero when the
|
||||
// answer is already sitting in bufio's own buffer — including a
|
||||
// previously-buffered, not-yet-surfaced error: bufio can return a
|
||||
// complete line with a nil error while quietly caching an error it
|
||||
// received ALONGSIDE that line's bytes, which then surfaces on the VERY
|
||||
// NEXT call with no further contact with interruptible.Read at all). The
|
||||
// generation only advances inside interruptible.Read's own cancellation
|
||||
// branch, so "unchanged across this call" is proof this call's outcome
|
||||
// didn't pass through that branch — whether because it was answered from
|
||||
// the buffer or because it raced ctx.Done() and won. Only that proof
|
||||
// makes it safe to call the outcome genuine.
|
||||
before := interruptible.generation()
|
||||
line, err := reader.ReadBytes('\n')
|
||||
interrupted := interruptible.generation() != before
|
||||
|
||||
if len(bytes.TrimSpace(line)) > 0 {
|
||||
c.handleLine(ctx, line)
|
||||
}
|
||||
if err != nil {
|
||||
c.failAllPending(err)
|
||||
if errors.Is(err, io.EOF) || ctx.Err() != nil {
|
||||
if errors.Is(err, io.EOF) || interrupted {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
@@ -146,6 +184,100 @@ func (c *Conn) Serve(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// interruptibleReader wraps a reader so a context cancellation can unblock a
|
||||
// currently in-flight Read without ever discarding — or misattributing — the
|
||||
// real outcome of a call that wasn't actually blocked.
|
||||
//
|
||||
// Each Read runs the underlying call on its own goroutine and races it against
|
||||
// ctx.Done(). If the underlying call finishes first, its result is returned
|
||||
// exactly as received; ctx being cancelled a moment later has no bearing on a
|
||||
// call that already had its answer. If ctx.Done() fires first, closer (when
|
||||
// non-nil) is closed to nudge a genuinely blocked read, the generation counter
|
||||
// is bumped, and the call STILL waits for and returns the underlying call's own
|
||||
// result — never a synthesized one — since closing a reader's outcome is
|
||||
// whatever the reader itself reports for it, not ours to invent.
|
||||
//
|
||||
// The counter is what lets Serve tell a call that was actually raced against
|
||||
// cancellation apart from one merely answered while cancellation happened to be
|
||||
// pending — the distinction the read loop's own doc comment explains.
|
||||
type interruptibleReader struct {
|
||||
inner io.Reader
|
||||
closer io.Closer
|
||||
ctx context.Context
|
||||
|
||||
closeOnce sync.Once
|
||||
gen atomic.Int64
|
||||
}
|
||||
|
||||
func newInterruptibleReader(ctx context.Context, inner io.Reader, closer io.Closer) *interruptibleReader {
|
||||
return &interruptibleReader{inner: inner, closer: closer, ctx: ctx}
|
||||
}
|
||||
|
||||
func (r *interruptibleReader) generation() int64 { return r.gen.Load() }
|
||||
|
||||
type interruptibleReadResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
|
||||
// interruptibleReadDecision arbitrates whether an underlying Read completed or
|
||||
// cancellation claimed it first. Completion is recorded before the result is
|
||||
// published so cancellation cannot misclassify a finished read merely because
|
||||
// its goroutine has not sent the result yet.
|
||||
type interruptibleReadDecision struct {
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (d *interruptibleReadDecision) complete() { d.once.Do(func() {}) }
|
||||
|
||||
func (d *interruptibleReadDecision) interrupt() (claimed bool) {
|
||||
d.once.Do(func() { claimed = true })
|
||||
return claimed
|
||||
}
|
||||
|
||||
// readInner records completion in the Read call's deferred epilogue. It also
|
||||
// converts a reader panic into a terminal error so the helper goroutine always
|
||||
// publishes a result for Read to join.
|
||||
func (r *interruptibleReader) readInner(p []byte, decision *interruptibleReadDecision) (res interruptibleReadResult) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
res = interruptibleReadResult{err: fmt.Errorf("acp: reader panicked: %v", recovered)}
|
||||
}
|
||||
decision.complete()
|
||||
}()
|
||||
res.n, res.err = r.inner.Read(p)
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *interruptibleReader) Read(p []byte) (int, error) {
|
||||
resultCh := make(chan interruptibleReadResult, 1)
|
||||
var decision interruptibleReadDecision
|
||||
go func() {
|
||||
resultCh <- r.readInner(p, &decision)
|
||||
}()
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.n, res.err
|
||||
case <-r.ctx.Done():
|
||||
// resultCh may not be readable yet even when inner.Read has returned: its
|
||||
// goroutine can be descheduled between recording completion and publishing
|
||||
// the result. Only interrupt a call that cancellation actually claims.
|
||||
if decision.interrupt() {
|
||||
r.gen.Add(1)
|
||||
if r.closer != nil {
|
||||
r.closeOnce.Do(func() { _ = r.closer.Close() })
|
||||
}
|
||||
}
|
||||
// Wait for the real result rather than returning synthetically: p is
|
||||
// still being written by the goroutine above until it does, so returning
|
||||
// early would race that write, and the actual error (whatever closing
|
||||
// the reader produced, or whatever the reader was already about to
|
||||
// report) is what the caller should see.
|
||||
res := <-resultCh
|
||||
return res.n, res.err
|
||||
}
|
||||
}
|
||||
|
||||
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
|
||||
// with -32700 (id null) and keeps the connection alive.
|
||||
func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
|
||||
@@ -3,11 +3,30 @@ package acp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testReadCloser struct {
|
||||
read func([]byte) (int, error)
|
||||
close func() error
|
||||
}
|
||||
|
||||
func (r testReadCloser) Read(p []byte) (int, error) { return r.read(p) }
|
||||
func (r testReadCloser) Close() error { return r.close() }
|
||||
|
||||
type testReader func([]byte) (int, error)
|
||||
|
||||
func (r testReader) Read(p []byte) (int, error) { return r(p) }
|
||||
|
||||
type testWriter func([]byte) (int, error)
|
||||
|
||||
func (w testWriter) Write(p []byte) (int, error) { return w(p) }
|
||||
|
||||
// connPair wires two Conns together over in-memory pipes and serves both.
|
||||
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
||||
t.Helper()
|
||||
@@ -25,6 +44,275 @@ func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServeCancellationInterruptsIdleRead(t *testing.T) {
|
||||
pipeReader, writer := io.Pipe()
|
||||
t.Cleanup(func() { _ = writer.Close() })
|
||||
// Buffered + non-blocking send, not close: interruptibleReader retries each
|
||||
// underlying Read on its own goroutine, so if bufio ever calls this more than
|
||||
// once, a close() here would panic on the second call.
|
||||
readStarted := make(chan struct{}, 1)
|
||||
closeCalls := make(chan struct{}, 2)
|
||||
reader := testReadCloser{
|
||||
read: func(p []byte) (int, error) {
|
||||
select {
|
||||
case readStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return pipeReader.Read(p)
|
||||
},
|
||||
close: func() error {
|
||||
closeCalls <- struct{}{}
|
||||
return pipeReader.Close()
|
||||
},
|
||||
}
|
||||
conn := NewOwnedConn(reader, io.Discard)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
<-readStarted
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned %v after cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after cancelling an idle connection")
|
||||
}
|
||||
if got := len(closeCalls); got != 1 {
|
||||
t.Fatalf("reader Close calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServePreservesTerminalReadErrorDuringCancellation(t *testing.T) {
|
||||
for _, closable := range []bool{false, true} {
|
||||
name := "non-closable"
|
||||
if closable {
|
||||
name = "closable"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
return copy(p, "not json"), wantErr
|
||||
})
|
||||
closeCalls := make(chan struct{}, 1)
|
||||
var reader io.Reader = read
|
||||
if closable {
|
||||
reader = testReadCloser{
|
||||
read: read,
|
||||
close: func() error {
|
||||
closeCalls <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
// Buffered + non-blocking send, not close: a later change adding a
|
||||
// second write (e.g. an additional error frame) must not panic on a
|
||||
// repeated close of an already-closed channel.
|
||||
writeStarted := make(chan struct{}, 1)
|
||||
releaseWrite := make(chan struct{})
|
||||
writer := testWriter(func(p []byte) (int, error) {
|
||||
select {
|
||||
case writeStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-releaseWrite
|
||||
return len(p), nil
|
||||
})
|
||||
newConn := NewConn
|
||||
if closable {
|
||||
// Ownership matters here: this case exists to prove that even a
|
||||
// Conn entitled to close its reader on cancellation still
|
||||
// preserves a terminal error that was already decided.
|
||||
newConn = NewOwnedConn
|
||||
}
|
||||
conn := newConn(reader, writer)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
<-writeStarted
|
||||
cancel()
|
||||
close(releaseWrite)
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Serve returned %v, want terminal read error %v", err, wantErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after terminal read error")
|
||||
}
|
||||
if got := len(closeCalls); got != 0 {
|
||||
t.Fatalf("reader Close calls = %d, want 0 after terminal read", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnServePreservesReadErrorBufferedAlongsideAPriorLine is the deterministic
|
||||
// regression test for jatmn's #782 finding: a real, non-EOF ReadBytes failure can
|
||||
// be DECIDED before cancellation ever happens, yet only SURFACE afterward, and
|
||||
// must still be reported rather than swallowed as a clean shutdown.
|
||||
//
|
||||
// bufio.Reader can obtain data AND a terminal error from ONE underlying Read call
|
||||
// (io.Reader explicitly permits returning (n>0, err) together). If a delimiter is
|
||||
// found within that data, ReadBytes returns the line with a nil error for THIS
|
||||
// call, but bufio caches the error internally and returns it — WITHOUT calling the
|
||||
// underlying reader again — on the very next ReadBytes call. This test forces that
|
||||
// exact sequence: the underlying reader hands back one complete, validly-framed
|
||||
// line together with wantErr in a SINGLE call (asserted below to be its ONLY
|
||||
// call), the loop dispatches that line through a synchronous path that blocks the
|
||||
// read loop, cancellation is asserted to have happened before the loop attempts
|
||||
// its next read, and only then is the block released. By the time the read loop
|
||||
// asks for more input, cancellation is already in effect and the underlying
|
||||
// reader is never touched again — so if Serve attributed the resulting error to
|
||||
// cancellation merely because ctx was already done, it would incorrectly return
|
||||
// nil. It must instead recognize that this read was answered entirely from
|
||||
// bufio's own cache, never raced against ctx.Done(), and report wantErr.
|
||||
func TestConnServePreservesReadErrorBufferedAlongsideAPriorLine(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
var readCalls atomic.Int32
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
readCalls.Add(1)
|
||||
// An unsupported jsonrpc version with an id takes the SYNCHRONOUS
|
||||
// writeError path in handleLine (not a dispatched goroutine), giving this
|
||||
// test a reliable blocking point between processing this line and the
|
||||
// loop's next ReadBytes call.
|
||||
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
||||
})
|
||||
writeStarted := make(chan struct{}, 1)
|
||||
releaseWrite := make(chan struct{})
|
||||
var releaseWriteOnce sync.Once
|
||||
release := func() { releaseWriteOnce.Do(func() { close(releaseWrite) }) }
|
||||
t.Cleanup(release)
|
||||
writer := testWriter(func(p []byte) (int, error) {
|
||||
select {
|
||||
case writeStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-releaseWrite
|
||||
return len(p), nil
|
||||
})
|
||||
conn := NewConn(read, writer)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
// The synchronous write confirms the first (and, per the panic guard above,
|
||||
// only) read has already returned and is being processed — cancelling now
|
||||
// lands squarely in the gap between that read and the loop's next one, never
|
||||
// racing an in-flight interruptibleReader.Read call.
|
||||
select {
|
||||
case <-writeStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not reach the synchronous write")
|
||||
}
|
||||
cancel()
|
||||
release()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Serve returned %v, want the buffered terminal read error %v", err, wantErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after the buffered terminal read error")
|
||||
}
|
||||
if got := readCalls.Load(); got != 1 {
|
||||
t.Fatalf("underlying Read calls = %d, want exactly 1 (bufio must have served the second ReadBytes from its cache)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptibleReaderPrefersDecidedResultOverCancellation is the regression
|
||||
// test for jatmn's second #782 finding: interruptibleReader.Read's select
|
||||
// between resultCh and ctx.Done() chooses pseudo-randomly when both are ready,
|
||||
// so a real, already-decided outcome (e.g. a terminal transport error) could be
|
||||
// misattributed to cancellation purely because ctx also happened to be done by
|
||||
// the time the select ran. Each iteration lets the underlying Read return (so
|
||||
// resultCh already holds the real outcome) before cancelling, maximizing the
|
||||
// odds of landing exactly in that ambiguous window; the generation counter must
|
||||
// never advance when the real, already-decided result is what gets returned.
|
||||
func TestInterruptibleReaderPrefersDecidedResultOverCancellation(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
for i := 0; i < 100; i++ {
|
||||
resultReady := make(chan struct{})
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
defer close(resultReady)
|
||||
return copy(p, "x"), wantErr
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := newInterruptibleReader(ctx, read, nil)
|
||||
|
||||
readDone := make(chan interruptibleReadResult, 1)
|
||||
go func() {
|
||||
n, err := r.Read(make([]byte, 8))
|
||||
readDone <- interruptibleReadResult{n, err}
|
||||
}()
|
||||
|
||||
<-resultReady
|
||||
time.Sleep(time.Millisecond) // bias resultCh's send ahead of cancel
|
||||
cancel()
|
||||
|
||||
res := <-readDone
|
||||
if !errors.Is(res.err, wantErr) {
|
||||
t.Fatalf("iteration %d: Read returned %v, want %v", i, res.err, wantErr)
|
||||
}
|
||||
if got := r.generation(); got != 0 {
|
||||
t.Fatalf("iteration %d: generation = %d, want 0 (a decided result must never be attributed to cancellation)", i, got)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptibleReadCompletionWinsBeforeResultPublication exercises the
|
||||
// actual inner-read wrapper at the handoff between the underlying Read and the
|
||||
// helper goroutine's result publication. Cancellation must not reclassify a
|
||||
// result returned by this wrapper as interrupted.
|
||||
func TestInterruptibleReadCompletionWinsBeforeResultPublication(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
return copy(p, "x"), wantErr
|
||||
})
|
||||
r := newInterruptibleReader(context.Background(), read, nil)
|
||||
var decision interruptibleReadDecision
|
||||
readComplete := make(chan struct{})
|
||||
resultCh := make(chan interruptibleReadResult)
|
||||
go func() {
|
||||
res := r.readInner(make([]byte, 8), &decision)
|
||||
close(readComplete)
|
||||
resultCh <- res
|
||||
}()
|
||||
|
||||
<-readComplete
|
||||
if decision.interrupt() {
|
||||
t.Fatal("cancellation claimed a read that completed before result publication")
|
||||
}
|
||||
res := <-resultCh
|
||||
if res.n != 1 || !errors.Is(res.err, wantErr) {
|
||||
t.Fatalf("inner read result = (%d, %v), want (1, %v)", res.n, res.err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServeReportsReaderPanicAsTerminalError(t *testing.T) {
|
||||
read := testReader(func([]byte) (int, error) {
|
||||
panic("boom")
|
||||
})
|
||||
conn := NewConn(read, io.Discard)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(context.Background()) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
const want = "acp: reader panicked: boom"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("Serve returned %v, want %q", err, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after reader panic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnRequestResponse(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
@@ -5,9 +5,29 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type normalizationRecordingShellTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *normalizationRecordingShellTool) Name() string { return "bash" }
|
||||
func (tool *normalizationRecordingShellTool) Description() string {
|
||||
return "record normalized shell arguments"
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: true}
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||
// omits (or malforms) additional_permissions can never succeed: the same
|
||||
// validation runs again at grant time regardless of the user's decision. It
|
||||
@@ -107,9 +127,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "missing additional_permissions", result.Output)
|
||||
}
|
||||
|
||||
// A well-formed additional_permissions payload must still reach the normal
|
||||
@@ -144,3 +162,237 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
|
||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
} {
|
||||
args := map[string]any{
|
||||
"command": "./zero --version",
|
||||
"sandbox_permissions": mode,
|
||||
"additional_permissions": map[string]any{
|
||||
"file_system": map[string]any{
|
||||
"deny_read": []any{},
|
||||
"entries": []any{},
|
||||
"read": []any{},
|
||||
"write": []any{},
|
||||
},
|
||||
"network": map[string]any{"enabled": false},
|
||||
},
|
||||
}
|
||||
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("mode %q retained empty additional_permissions: %#v", mode, args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("mode %q normalized to %q, want use_default", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallNormalizesProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
|
||||
tool := &normalizationRecordingShellTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tool)
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"./zero --version","sandbox_permissions":"with_additional_permissions",` +
|
||||
`"additional_permissions":{"file_system":{"deny_read":[],"entries":[],"read":[],"write":[]},"network":{"enabled":false}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{Cwd: t.TempDir()},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("provider-expanded empty permissions were rejected: %s", result.Output)
|
||||
}
|
||||
if len(tool.calls) != 1 {
|
||||
t.Fatalf("shell tool calls = %d, want 1", len(tool.calls))
|
||||
}
|
||||
args := tool.calls[0]
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("executeToolCall retained empty additional_permissions: %#v", args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("sandbox mode = %q, want use_default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdditionalPermissionsPreservesRealRequest(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
|
||||
if _, exists := args["additional_permissions"]; !exists {
|
||||
t.Fatal("non-empty additional_permissions was removed")
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsWithAdditionalPermissions) {
|
||||
t.Fatalf("sandbox mode changed to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdditionalPermissionsRemovesWorkspaceReadAlreadyCoveredBySandbox(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
})
|
||||
args := map[string]any{
|
||||
"command": "./zero --version",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"file_system": map[string]any{
|
||||
"entries": []any{
|
||||
map[string]any{
|
||||
"access": "read",
|
||||
"path": map[string]any{
|
||||
"type": "path",
|
||||
"path": ".",
|
||||
},
|
||||
},
|
||||
},
|
||||
"deny_read": []any{},
|
||||
"read": []any{},
|
||||
"write": []any{},
|
||||
},
|
||||
"network": map[string]any{"enabled": false},
|
||||
},
|
||||
}
|
||||
|
||||
normalizeNoopAdditionalPermissions(args, root, engine)
|
||||
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("already-covered additional_permissions was retained: %#v", args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("sandbox mode changed to %q", got)
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,9 @@ const compactionTriggerRatio = 0.7
|
||||
// transcript (and so tests can assert on it).
|
||||
const summaryLabel = "[Summary of earlier conversation]"
|
||||
|
||||
// summaryInstructions is the system prompt handed to the summarizer model.
|
||||
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||
// 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. " +
|
||||
"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. " +
|
||||
@@ -75,6 +76,12 @@ 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
|
||||
}
|
||||
@@ -216,11 +223,11 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
summary, err := opts.Summarize(middle)
|
||||
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
|
||||
if err != nil {
|
||||
return CompactionResult{}, err
|
||||
}
|
||||
summary = strings.TrimSpace(summary)
|
||||
summary := summaryResult.SummaryText
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
@@ -238,10 +245,53 @@ 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
|
||||
@@ -331,6 +381,8 @@ 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
|
||||
@@ -377,6 +429,8 @@ func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
|
||||
trace: options.Trace,
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -413,6 +467,14 @@ func (state *compactionState) maybeCompact(
|
||||
// the bodies of old, large tool results (the model has already acted on
|
||||
// them). If that brings us back under threshold, skip the paid summarizer
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
}
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
@@ -424,7 +486,7 @@ func (state *compactionState) maybeCompact(
|
||||
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -475,7 +537,7 @@ func (state *compactionState) recover(
|
||||
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
@@ -509,9 +571,12 @@ 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 summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +588,11 @@ func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage
|
||||
// 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) {
|
||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
|
||||
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)
|
||||
if err == nil {
|
||||
return summary, nil
|
||||
}
|
||||
@@ -532,11 +601,11 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
}
|
||||
|
||||
mid := len(messages) / 2
|
||||
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
|
||||
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
|
||||
if leftErr != nil {
|
||||
return "", leftErr
|
||||
}
|
||||
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
|
||||
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
|
||||
if rightErr != nil {
|
||||
return "", rightErr
|
||||
}
|
||||
@@ -549,7 +618,7 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
combined := strings.TrimSpace(left + "\n\n" + right)
|
||||
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
||||
}, onUsage)
|
||||
}, onUsage, planner, recorder)
|
||||
if reduceErr != nil {
|
||||
if isContextLimitError(reduceErr.Error()) {
|
||||
// Even the two combined partials don't fit (extreme): fall back to the
|
||||
@@ -565,15 +634,15 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
}
|
||||
|
||||
// summarizeMessagesOnce performs a single tool-less summarization call.
|
||||
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.
|
||||
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)},
|
||||
}
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
|
||||
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
|
||||
recordContextPlanTrace(recorder, plan)
|
||||
stream, err := provider.StreamCompletion(ctx, plan.Request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -43,8 +43,14 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
|
||||
if result.SummaryText != "manual summary" {
|
||||
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
||||
}
|
||||
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 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(result.Messages) != 4 {
|
||||
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
||||
@@ -96,11 +102,46 @@ 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 the files mutated by write_file/edit_file calls in messages
|
||||
// recentEdits returns files mutated by editing 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,6 +113,7 @@ 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 {
|
||||
@@ -129,12 +130,24 @@ 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
|
||||
@@ -425,15 +438,20 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
@@ -468,7 +486,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
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),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
@@ -484,6 +507,13 @@ 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.
|
||||
@@ -517,6 +547,95 @@ 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,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,9 @@ 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,
|
||||
@@ -80,6 +84,69 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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,11 +20,15 @@ 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) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
if len(edits) != 3 {
|
||||
t.Fatalf("expected 3 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])
|
||||
@@ -32,6 +36,9 @@ 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,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -58,12 +59,15 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
|
||||
if last.Content != "most recent question" {
|
||||
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
||||
}
|
||||
// The summarized middle excludes system and preserved suffix.
|
||||
if len(captured) != 3 {
|
||||
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
|
||||
// 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)
|
||||
}
|
||||
if captured[0].Content != "first question" {
|
||||
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Compaction must shrink the conversation.
|
||||
if estimateTokens(result) >= estimateTokens(messages) {
|
||||
@@ -379,6 +383,43 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeCompactStopsAfterSupersededReadsReachThreshold(t *testing.T) {
|
||||
body := strings.Repeat("same source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
|
||||
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
|
||||
}
|
||||
pruned, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed <= 0 {
|
||||
t.Fatal("test setup did not reclaim superseded read output")
|
||||
}
|
||||
prunedSize := estimateTokens(pruned)
|
||||
if estimateTokens(messages) <= prunedSize {
|
||||
t.Fatal("test setup does not cross the compaction threshold")
|
||||
}
|
||||
|
||||
provider := &summarizeRecordingProvider{}
|
||||
state := &compactionState{
|
||||
enabled: true,
|
||||
threshold: prunedSize,
|
||||
preserveLast: 2,
|
||||
}
|
||||
got := state.maybeCompact(context.Background(), provider, messages, nil)
|
||||
|
||||
if provider.summarizeCalls != 0 {
|
||||
t.Fatalf("cheap pruning should avoid the summarizer, got %d calls", provider.summarizeCalls)
|
||||
}
|
||||
if state.lowWaterMark != prunedSize {
|
||||
t.Fatalf("lowWaterMark = %d, want %d", state.lowWaterMark, prunedSize)
|
||||
}
|
||||
if !strings.Contains(got[2].Content, "superseded identical") || got[4].Content != body {
|
||||
t.Fatalf("unexpected pruned messages: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// reactiveProvider builds up history with a tool call on turn 1, then errors
|
||||
// with a context-limit message on turn 2 (once the history is large enough that
|
||||
// compaction can shrink it), then succeeds on the same-turn retry. Summary
|
||||
@@ -388,6 +429,7 @@ type reactiveProvider struct {
|
||||
summarizeCalls int
|
||||
turnRequests int
|
||||
failedOnce bool
|
||||
connectError bool
|
||||
bigText string
|
||||
finalText string
|
||||
}
|
||||
@@ -409,6 +451,9 @@ 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
|
||||
@@ -417,6 +462,39 @@ 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),
|
||||
@@ -428,6 +506,8 @@ 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.
|
||||
@@ -436,6 +516,10 @@ 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)
|
||||
@@ -446,6 +530,25 @@ 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,6 +23,24 @@ 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
|
||||
@@ -41,6 +59,24 @@ 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,6 +32,9 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type contextPlannerConfig struct {
|
||||
contextWindow int
|
||||
promptCacheKey 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,
|
||||
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, ",")
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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",
|
||||
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
|
||||
})
|
||||
|
||||
plan := planner.Plan(messages, toolDefs, "medium")
|
||||
want := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: "medium",
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -341,15 +341,15 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||
}
|
||||
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
||||
if !strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("stable tool_search catalog must retain already-loaded tool names, got %q", search.Description)
|
||||
}
|
||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -368,9 +368,8 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
// No hidden tools means no dynamic discovery text is needed.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
|
||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("stable catalog must retain all deferred names after loading, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+257
-112
@@ -183,10 +183,22 @@ 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,
|
||||
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
|
||||
@@ -275,42 +287,15 @@ 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()
|
||||
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))
|
||||
}
|
||||
plan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request := plan.Request
|
||||
recordContextPlan(options, plan)
|
||||
|
||||
// A transient upstream disconnect on the initial connect is retried with
|
||||
// backoff (before any content is forwarded, so no OnText is duplicated);
|
||||
@@ -335,12 +320,9 @@ 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.
|
||||
request = zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request = retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
// 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).
|
||||
@@ -424,12 +406,9 @@ 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.
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryStreamErr != nil {
|
||||
return collected, retryStreamErr
|
||||
@@ -487,12 +466,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
}
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -656,6 +632,13 @@ 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
|
||||
@@ -691,7 +674,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, toolResult: toolResult})
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -705,10 +688,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.Output,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.Output,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// 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
|
||||
@@ -719,11 +714,13 @@ 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.Output
|
||||
result.StopReason = stopReason
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -747,6 +744,7 @@ 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
|
||||
@@ -765,6 +763,10 @@ 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
|
||||
@@ -896,7 +898,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, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -938,7 +940,32 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
|
||||
})
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
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) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -947,12 +974,9 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
||||
// 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).
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
plan := planner.Plan(finalMessages, toolDefs, options.ReasoningEffort)
|
||||
recordContextPlan(options, plan)
|
||||
stream, err := streamWithReconnect(ctx, provider, plan.Request, reconnectNoticeFor(options))
|
||||
if err != nil {
|
||||
return "", messages, ""
|
||||
}
|
||||
@@ -1076,6 +1100,9 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
if isShellCommandTool(call.Name) {
|
||||
normalizeNoopAdditionalPermissions(args, options.Cwd, options.Sandbox)
|
||||
}
|
||||
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
|
||||
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
|
||||
// discovery tool exposed to the model rejects at dispatch time (an inescapable
|
||||
@@ -1354,7 +1381,8 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
Cwd: options.Cwd,
|
||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||
// a file that changed on disk outside Zero since it was last read.
|
||||
FileTracker: options.FileTracker,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
|
||||
// synchronously inside edit_file/write_file and block every mutating tool
|
||||
// call on the language server (≥300ms debounce floor, 10s cap). The loop
|
||||
@@ -1406,6 +1434,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
result = registry.RebudgetAfterHook(call.Name, args, result)
|
||||
}
|
||||
}
|
||||
result = registry.CommitFileObservation(result, options.FileTracker)
|
||||
// Stamp the sandbox risk classification for this EXECUTED call so run-policy
|
||||
// observers can see the risk of an allowed mutation. Prefer the preflight
|
||||
// decision's classification when the sandbox evaluated the call; otherwise
|
||||
@@ -1431,6 +1460,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
@@ -1617,9 +1647,15 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
|
||||
|
||||
func sandboxDeniedShellResult(result tools.Result) bool {
|
||||
if result.ExecutionOutcome != nil {
|
||||
// Typed denials must be handled by their exact capability path. Never
|
||||
// turn a structured narrow denial into the legacy unrestricted retry.
|
||||
return false
|
||||
// Only a typed platform denial that explicitly requests unrestricted
|
||||
// recovery may enter the legacy retry path. Narrow capability denials
|
||||
// remain on their exact capability path.
|
||||
denial := result.ExecutionOutcome.Denial
|
||||
return denial != nil &&
|
||||
denial.Source == execution.DenialSourcePlatformSandbox &&
|
||||
denial.Capability.Kind == execution.CapabilityUnrestricted &&
|
||||
denial.Recoverable &&
|
||||
denial.NextAction == execution.DenialNextActionRequestApproval
|
||||
}
|
||||
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
|
||||
}
|
||||
@@ -1684,20 +1720,21 @@ func sandboxRestrictionRetryReason(result tools.Result) string {
|
||||
|
||||
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
||||
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1712,20 +1749,21 @@ func unsandboxedRetryArgs(args map[string]any) map[string]any {
|
||||
|
||||
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
||||
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2742,21 +2780,89 @@ func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
|
||||
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
|
||||
// normalizeNoopAdditionalPermissions treats a provider-expanded empty or
|
||||
// already-covered additional_permissions object as if it had been omitted.
|
||||
// Some strict-schema providers materialize every optional property and may add
|
||||
// a workspace read that the default sandbox already grants; rejecting either
|
||||
// no-op makes the model retry an otherwise valid command. Requests that broaden
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var profile sandbox.RequestPermissionProfile
|
||||
if err := json.Unmarshal(data, &profile); err != nil {
|
||||
return
|
||||
}
|
||||
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
noop := normalized.Empty()
|
||||
if !noop && engine != nil {
|
||||
if grantProfile, grantErr := sandbox.RequestPermissionGrantProfile(normalized); grantErr == nil {
|
||||
noop = engine.CoversRequestPermissions(grantProfile)
|
||||
}
|
||||
}
|
||||
if !noop {
|
||||
return
|
||||
}
|
||||
delete(args, "additional_permissions")
|
||||
if rawMode, ok := args["sandbox_permissions"].(string); ok &&
|
||||
strings.TrimSpace(rawMode) == string(tools.SandboxPermissionsWithAdditionalPermissions) {
|
||||
args["sandbox_permissions"] = string(tools.SandboxPermissionsUseDefault)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
// 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)
|
||||
}
|
||||
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. "+
|
||||
`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)
|
||||
"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)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -2772,8 +2878,10 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"additional_permissions must include at least one of network or file_system, for example " +
|
||||
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
|
||||
"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)
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
@@ -2932,22 +3040,20 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
// 2. tool_search — always present, right after the stable block;
|
||||
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||||
// transition grows the tail instead of inserting into the eager block.
|
||||
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||||
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||||
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||||
// tool_search's compact catalog also stays byte-stable as tools load, so only
|
||||
// the appended full-schema tail changes.
|
||||
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||||
var hiddenTools []tools.Tool
|
||||
var deferredCatalog []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue // added explicitly between the eager block and the loaded tail
|
||||
}
|
||||
if tools.IsDeferred(tool) {
|
||||
deferredCatalog = append(deferredCatalog, tool)
|
||||
if loaded[name] {
|
||||
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
|
||||
} else {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -2960,15 +3066,16 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
return loadedTail[left].Name < loadedTail[right].Name
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
// Keep the catalog byte-stable as tools load. tool_search already redirects
|
||||
// requests for an exposed tool back to the current tool list, so retaining
|
||||
// loaded names costs only compact catalog lines and avoids invalidating the
|
||||
// provider cache at the loader definition on every load.
|
||||
discovery := tools.BuildToolSearchDescription(deferredCatalog)
|
||||
|
||||
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||||
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||||
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||||
// eager block and carries the discovery list for still-hidden tools.
|
||||
// eager block and carries the stable deferred-tool catalog.
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
@@ -3171,6 +3278,7 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
@@ -3201,9 +3309,46 @@ 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
|
||||
}
|
||||
+111
-17
@@ -64,6 +64,22 @@ func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
nonRecoverableDenial := protectedDenial
|
||||
nonRecoverableDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "sandbox enforcement failed closed",
|
||||
Recoverable: false,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if sandboxRestrictedShellRetryCandidate(call, nil, nonRecoverableDenial, options) {
|
||||
t.Fatal("non-recoverable sandbox denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
networkDenial := protectedDenial
|
||||
networkDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
@@ -288,6 +304,18 @@ func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any)
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Exit: &execution.Exit{Code: 1},
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "sandbox blocked command execution",
|
||||
Recoverable: true,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
},
|
||||
Meta: map[string]string{
|
||||
"exit_code": "1",
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
@@ -304,7 +332,8 @@ type sandboxDeniedExecCommandRetryTool struct {
|
||||
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
|
||||
|
||||
type sandboxNetworkDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
calls []map[string]any
|
||||
deferFileObservationCommit []bool
|
||||
}
|
||||
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
|
||||
@@ -327,6 +356,7 @@ func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[str
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
|
||||
if shellNetworkAllowed(ctx, options.Sandbox, args) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "server started"}
|
||||
}
|
||||
@@ -369,7 +399,8 @@ func agentNativeBackendStub() sandbox.Backend {
|
||||
}
|
||||
|
||||
type sandboxNamespaceLimitedRetryTool struct {
|
||||
calls []map[string]any
|
||||
calls []map[string]any
|
||||
deferFileObservationCommit []bool
|
||||
}
|
||||
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
|
||||
@@ -388,8 +419,12 @@ func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) RunWithOptions(_ context.Context, args map[string]any, options tools.RunOptions) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
|
||||
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
|
||||
}
|
||||
@@ -455,6 +490,9 @@ func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing
|
||||
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
|
||||
}
|
||||
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
|
||||
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
|
||||
}
|
||||
@@ -606,6 +644,9 @@ func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
|
||||
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
|
||||
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
|
||||
}
|
||||
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
|
||||
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
|
||||
}
|
||||
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("permission requests = %#v, want one network approval", requests)
|
||||
}
|
||||
@@ -2404,7 +2445,11 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "PATH=.:$PATH curl https://example.com"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
if windowsTestUsesPowerShell() {
|
||||
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
|
||||
} else {
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
}
|
||||
fakeCurl := filepath.Join(root, "curl.cmd")
|
||||
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2525,13 +2570,17 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
|
||||
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "echo rm -rf /"
|
||||
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
|
||||
command = `Write-Output 'rm -rf /'`
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2627,6 +2676,12 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func windowsTestUsesPowerShell() bool {
|
||||
executable, _ := tools.HostShellCommand("")
|
||||
name := strings.ToLower(filepath.Base(executable))
|
||||
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
|
||||
}
|
||||
|
||||
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
|
||||
for _, decision := range decisions {
|
||||
if decision == want {
|
||||
@@ -3160,22 +3215,17 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
wants := []string{"workdir/cwd", "MSYS binaries"}
|
||||
if windowsTestUsesPowerShell() {
|
||||
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
|
||||
} else {
|
||||
wants = append(wants, "cmd.exe", "double quotes")
|
||||
}
|
||||
// The examples must themselves use the safe (double-quoted) form; a
|
||||
// single-quoted example here would teach the model the exact syntax
|
||||
// that fails under cmd.exe.
|
||||
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
|
||||
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
|
||||
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
}
|
||||
@@ -3542,6 +3592,11 @@ 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 {
|
||||
@@ -3556,6 +3611,33 @@ 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" }
|
||||
@@ -3647,6 +3729,8 @@ 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",
|
||||
@@ -3655,6 +3739,10 @@ 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)
|
||||
}
|
||||
@@ -3681,6 +3769,12 @@ 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
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,6 +20,16 @@ type propagationOutputTool struct {
|
||||
|
||||
func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||
for index, arg := range os.Args {
|
||||
if arg == "--zero-output-budget-hook-repeat" && index+1 < len(os.Args) {
|
||||
repeats, err := strconv.Atoi(os.Args[index+1])
|
||||
if err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
if _, err := os.Stdout.WriteString(strings.Repeat("hook feedback ", repeats)); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
|
||||
continue
|
||||
}
|
||||
@@ -30,25 +41,102 @@ func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||
}
|
||||
|
||||
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
|
||||
feedback := strings.Repeat("hook feedback ", 200)
|
||||
return outputBudgetHookDispatcherFor("propagation_output", 200)
|
||||
}
|
||||
|
||||
func outputBudgetHookDispatcherFor(toolName string, repeats int) *hooks.Dispatcher {
|
||||
feedback := strings.Repeat("hook feedback ", repeats)
|
||||
hookArgs := []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook",
|
||||
feedback,
|
||||
}
|
||||
if repeats > 1_000 {
|
||||
hookArgs = []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook-repeat",
|
||||
strconv.Itoa(repeats),
|
||||
}
|
||||
}
|
||||
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{{
|
||||
ID: "large-feedback",
|
||||
Event: hooks.EventAfterTool,
|
||||
Matcher: "propagation_output",
|
||||
Matcher: toolName,
|
||||
Command: os.Args[0],
|
||||
Args: []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook",
|
||||
feedback,
|
||||
},
|
||||
Args: hookArgs,
|
||||
Enabled: true,
|
||||
}},
|
||||
}})
|
||||
}
|
||||
|
||||
func TestExecuteToolCallCommitsReadObservationAfterFinalBoundary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "target.txt")
|
||||
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkPath := filepath.Join(root, "small.txt")
|
||||
if err := os.Symlink(targetPath, linkPath); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
resolvedPath, err := filepath.EvalSymlinks(linkPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
tracker := tools.NewFileTracker()
|
||||
|
||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
||||
ID: "read-small", Name: "read_file", Arguments: `{"path":"small.txt"}`,
|
||||
}, PermissionModeAuto, Options{Cwd: root, FileTracker: tracker})
|
||||
if abortErr != nil || result.Status != tools.StatusOK {
|
||||
t.Fatalf("executeToolCall: result=%#v err=%v", result, abortErr)
|
||||
}
|
||||
if !tracker.SeenWhole(resolvedPath) {
|
||||
t.Fatal("unmodified final read output was not committed as observed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallDoesNotCommitReadObservationBeforeHookBudget(t *testing.T) {
|
||||
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "target.txt")
|
||||
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkPath := filepath.Join(root, "small.txt")
|
||||
if err := os.Symlink(targetPath, linkPath); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
resolvedPath, err := filepath.EvalSymlinks(linkPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
tracker := tools.NewFileTracker()
|
||||
|
||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
||||
ID: "read-hook", Name: "read_file", Arguments: `{"path":"small.txt"}`,
|
||||
}, PermissionModeAuto, Options{
|
||||
Cwd: root, FileTracker: tracker, Hooks: outputBudgetHookDispatcherFor("read_file", 20_000),
|
||||
})
|
||||
if abortErr != nil {
|
||||
t.Fatalf("executeToolCall: %v", abortErr)
|
||||
}
|
||||
if !result.Truncated {
|
||||
t.Fatalf("precondition: hook feedback was not truncated: %#v", result)
|
||||
}
|
||||
if tracker.SeenWhole(resolvedPath) {
|
||||
t.Fatal("read observation committed before hook output was budgeted")
|
||||
}
|
||||
}
|
||||
|
||||
func (tool propagationOutputTool) Name() string { return "propagation_output" }
|
||||
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
|
||||
func (tool propagationOutputTool) Parameters() tools.Schema {
|
||||
|
||||
@@ -91,7 +91,7 @@ func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
|
||||
if c.policy == nil || c.escalated {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
|
||||
if !c.failureTripped && !c.riskTripped && !c.scTripped && !c.uncertainTrip {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
c.escalated = true
|
||||
|
||||
@@ -121,3 +121,67 @@ func toolNameForResult(messages []zeroruntime.Message, index int) string {
|
||||
func isPrunedPlaceholder(content string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
|
||||
}
|
||||
|
||||
var supersedableReadTools = map[string]bool{
|
||||
"read_file": true,
|
||||
"read_minified_file": true,
|
||||
"grep": true,
|
||||
"glob": true,
|
||||
"list_directory": true,
|
||||
}
|
||||
|
||||
// pruneSupersededReadResults removes only older byte-identical results of the
|
||||
// same read call. It runs when context pressure already requires a rewrite, so
|
||||
// it does not trade a warm provider-cache prefix for a small apparent saving.
|
||||
func pruneSupersededReadResults(messages []zeroruntime.Message) ([]zeroruntime.Message, int) {
|
||||
type seenResultKey struct {
|
||||
call string
|
||||
content string
|
||||
}
|
||||
seen := map[seenResultKey]struct{}{}
|
||||
out := messages
|
||||
reclaimed := 0
|
||||
copied := false
|
||||
|
||||
for index := len(messages) - 1; index >= 0; index-- {
|
||||
message := messages[index]
|
||||
if message.Role != zeroruntime.MessageRoleTool || isPrunedPlaceholder(message.Content) {
|
||||
continue
|
||||
}
|
||||
call, ok := toolCallForResult(messages, index)
|
||||
if !ok || !supersedableReadTools[call.Name] {
|
||||
continue
|
||||
}
|
||||
key := seenResultKey{
|
||||
call: call.Name + "\x00" + strings.TrimSpace(call.Arguments),
|
||||
content: message.Content,
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
placeholder := fmt.Sprintf("[superseded identical %s result — the newer result remains in context]", call.Name)
|
||||
if !copied {
|
||||
out = append([]zeroruntime.Message(nil), messages...)
|
||||
copied = true
|
||||
}
|
||||
out[index].Content = placeholder
|
||||
reclaimed += max(0, ApproxTextTokens(message.Content)-ApproxTextTokens(placeholder))
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return out, reclaimed
|
||||
}
|
||||
|
||||
func toolCallForResult(messages []zeroruntime.Message, resultIndex int) (zeroruntime.ToolCall, bool) {
|
||||
id := messages[resultIndex].ToolCallID
|
||||
if id == "" {
|
||||
return zeroruntime.ToolCall{}, false
|
||||
}
|
||||
for index := resultIndex - 1; index >= 0; index-- {
|
||||
for _, call := range messages[index].ToolCalls {
|
||||
if call.ID == id {
|
||||
return call, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return zeroruntime.ToolCall{}, false
|
||||
}
|
||||
@@ -25,6 +25,13 @@ func toolCallMsg(id, name string) zeroruntime.Message {
|
||||
}
|
||||
}
|
||||
|
||||
func toolCallWithArgs(id, name, args string) zeroruntime.Message {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: name, Arguments: args}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSkipsSmallSessions(t *testing.T) {
|
||||
// Total reclaimable is under the gate → no pruning.
|
||||
msgs := []zeroruntime.Message{
|
||||
@@ -120,3 +127,69 @@ func TestPruneDoesNotMutateInput(t *testing.T) {
|
||||
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsOnlyDropsIdenticalOlderReads(t *testing.T) {
|
||||
body := strings.Repeat("same source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
|
||||
toolCallWithArgs("different", "read_file", `{"path":"b.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "different", Content: body},
|
||||
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
|
||||
}
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
|
||||
t.Fatalf("older duplicate was not pruned: reclaimed=%d output=%q", reclaimed, out[1].Content)
|
||||
}
|
||||
if out[3].Content != body || out[5].Content != body {
|
||||
t.Fatal("different call and newest result must remain exact")
|
||||
}
|
||||
if messages[1].Content != body {
|
||||
t.Fatal("input slice was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsKeepsChangedAndMutatingResults(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("r1", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r1", Content: "version one"},
|
||||
toolCallWithArgs("r2", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r2", Content: "version two"},
|
||||
toolCallWithArgs("e1", "edit_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "edited"},
|
||||
toolCallWithArgs("e2", "edit_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "edited"},
|
||||
}
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed != 0 {
|
||||
t.Fatalf("unsafe results were pruned: %d", reclaimed)
|
||||
}
|
||||
for index := range messages {
|
||||
if out[index].Content != messages[index].Content {
|
||||
t.Fatalf("message %d changed unexpectedly", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsFindsMatchingBodyAcrossChangedRead(t *testing.T) {
|
||||
bodyA := strings.Repeat("version a\n", 100)
|
||||
bodyB := strings.Repeat("version b\n", 100)
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("old-a", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old-a", Content: bodyA},
|
||||
toolCallWithArgs("middle-b", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "middle-b", Content: bodyB},
|
||||
toolCallWithArgs("new-a", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new-a", Content: bodyA},
|
||||
}
|
||||
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
|
||||
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
|
||||
t.Fatalf("older matching body was not pruned across a changed result: reclaimed=%d output=%q", reclaimed, out[1].Content)
|
||||
}
|
||||
if out[3].Content != bodyB || out[5].Content != bodyA {
|
||||
t.Fatal("changed middle result and newest matching result must remain exact")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,16 @@ func TestSpecialistDelegationContext(t *testing.T) {
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
for _, want := range []string{"reduce total context", "Do not delegate small direct work", "bounded assignment"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing evidence-driven delegation guidance %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"delegate to it proactively", "launch several specialists in parallel"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("retained broad delegation guidance %q: %q", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
|
||||
@@ -178,21 +179,19 @@ func approvedCommandPrefixContext(options Options) string {
|
||||
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
|
||||
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
|
||||
// tool outputs out of the main context. It renders only when specialists are
|
||||
// known (which is only where the Task tool is actually registered), so a run with
|
||||
// no delegatable specialists produces the previous prompt unchanged.
|
||||
// specialistDelegationContext describes when a specialist earns its extra
|
||||
// context and coordination cost. It renders only when specialists are known
|
||||
// (which is only where the Task tool is actually registered), so a run with no
|
||||
// delegatable specialists produces the previous prompt unchanged.
|
||||
func specialistDelegationContext(options Options) string {
|
||||
if len(options.Specialists) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<specialists>\n")
|
||||
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
|
||||
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
|
||||
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
|
||||
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
|
||||
b.WriteString("Use the Task tool when a specialist's focused context, expertise, or an independent parallel subtask is likely to improve quality or reduce total context. ")
|
||||
b.WriteString("Do not delegate small direct work, duplicate exploration, or split sequential steps merely because a specialist is available. ")
|
||||
b.WriteString("When delegating, give a bounded assignment and consume its concise evidence instead of importing a long transcript.\n")
|
||||
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
||||
for _, info := range options.Specialists {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -310,11 +309,7 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("<environment>\n")
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
|
||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||
b.WriteString("Git branch: " + branch + "\n")
|
||||
}
|
||||
|
||||
@@ -30,16 +30,13 @@ 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.** For multi-step work, call update_plan with an ordered checklist and
|
||||
keep it live. The plan bar is the user's progress signal — call update_plan
|
||||
after EACH concrete unit of work (every file written, every command run), not
|
||||
just at coarse milestones: mark the finished item completed and the next one
|
||||
in_progress before you start it. A plan stuck at 0/N while files are landing is
|
||||
a bug, not economy — these calls are cheap, expected, and the update_plan cards
|
||||
are hidden from the transcript, so frequent updates cost the user nothing and
|
||||
never clutter the conversation. Keep at most one item in_progress, and never
|
||||
batch the updates to the end of the turn. Skip the plan for trivial one-step
|
||||
tasks.
|
||||
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.
|
||||
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
|
||||
@@ -54,11 +51,16 @@ work.
|
||||
## Editing discipline
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
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.
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
write_file, edit_file, apply_patch - over shelling out to
|
||||
cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||
shell or script invocation.
|
||||
- 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 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
|
||||
|
||||
@@ -71,5 +71,6 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||
- Be concise and concrete. When you run a shell command with side effects, state
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
- 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.
|
||||
</model_guidance>`
|
||||
+217
-10
@@ -1,10 +1,14 @@
|
||||
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"
|
||||
@@ -52,17 +56,41 @@ 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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type taskStateEventKind int
|
||||
@@ -72,6 +100,7 @@ const (
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
taskStateEventPermission
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
@@ -80,6 +109,7 @@ type taskStateEvent struct {
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
permission PermissionEvent
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
@@ -101,6 +131,7 @@ func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
@@ -134,6 +165,7 @@ 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 {
|
||||
@@ -160,6 +192,10 @@ 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++
|
||||
@@ -183,9 +219,180 @@ 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,6 +115,68 @@ 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"}]}`})
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
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, " ") + "]"
|
||||
}
|
||||
+10
-5
@@ -68,8 +68,11 @@ 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
|
||||
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
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
@@ -330,9 +333,11 @@ 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 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, 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 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,
|
||||
|
||||
@@ -125,9 +125,7 @@ func Score(suite Suite, input ScoreInput) Report {
|
||||
if len(task.RequiredTraceEvents) > 0 {
|
||||
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
|
||||
}
|
||||
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
|
||||
report.Results = append(report.Results, result)
|
||||
}
|
||||
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
|
||||
report.finishSummary()
|
||||
return report
|
||||
}
|
||||
|
||||
@@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
|
||||
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
|
||||
|
||||
if info.PrimaryLanguage != "" {
|
||||
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
|
||||
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
|
||||
}
|
||||
if langs := topLanguages(info.Languages, 5); langs != "" {
|
||||
b.WriteString("- Languages: " + langs + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
|
||||
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
|
||||
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
|
||||
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
|
||||
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
|
||||
}
|
||||
if len(info.BuildTools) > 0 {
|
||||
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
|
||||
|
||||
@@ -149,12 +149,9 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
|
||||
// The forked child must be gone too (poll until reaped by init).
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
// 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.
|
||||
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||
break // child no longer exists
|
||||
}
|
||||
// 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)
|
||||
|
||||
+7
-3
@@ -27,6 +27,8 @@ Usage:
|
||||
Not meant to be run interactively — point your editor's ACP / external-agent
|
||||
setting at "zero acp".`
|
||||
|
||||
var acpSignalContext = signalContext
|
||||
|
||||
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
|
||||
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
|
||||
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
|
||||
@@ -42,7 +44,9 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
}
|
||||
}
|
||||
|
||||
conn := acp.NewConn(deps.stdin, stdout)
|
||||
// The process's own stdin belongs exclusively to this Conn for the life of
|
||||
// the command, so cancellation may close it to interrupt an idle read.
|
||||
conn := acp.NewOwnedConn(deps.stdin, stdout)
|
||||
acp.NewAgent(conn, acp.Deps{
|
||||
ResolveConfig: deps.resolveConfig,
|
||||
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
@@ -73,9 +77,9 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
AgentInfo: acp.Implementation{Name: "zero", Version: version},
|
||||
})
|
||||
|
||||
ctx, stop := signalContext()
|
||||
ctx, stop := acpSignalContext()
|
||||
defer stop()
|
||||
if err := conn.Serve(ctx); err != nil && ctx.Err() == nil {
|
||||
if err := conn.Serve(ctx); err != nil {
|
||||
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type acpTestReader func([]byte) (int, error)
|
||||
|
||||
func (r acpTestReader) Read(p []byte) (int, error) { return r(p) }
|
||||
|
||||
type acpTestWriter func([]byte) (int, error)
|
||||
|
||||
func (w acpTestWriter) Write(p []byte) (int, error) { return w(p) }
|
||||
|
||||
func installACPTestContext(t *testing.T) context.CancelFunc {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
previous := acpSignalContext
|
||||
acpSignalContext = func() (context.Context, context.CancelFunc) {
|
||||
return ctx, cancel
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
acpSignalContext = previous
|
||||
})
|
||||
return cancel
|
||||
}
|
||||
|
||||
func TestRunACPCancellationPreservesTerminalReadError(t *testing.T) {
|
||||
cancel := installACPTestContext(t)
|
||||
wantErr := errors.New("transport read failed")
|
||||
reader := acpTestReader(func(p []byte) (int, error) {
|
||||
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
||||
})
|
||||
writeStarted := make(chan struct{}, 1)
|
||||
releaseWrite := make(chan struct{})
|
||||
var stdout bytes.Buffer
|
||||
writer := acpTestWriter(func(p []byte) (int, error) {
|
||||
writeStarted <- struct{}{}
|
||||
<-releaseWrite
|
||||
return stdout.Write(p)
|
||||
})
|
||||
var stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runACP(nil, writer, &stderr, fillAppDeps(appDeps{stdin: reader}))
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-writeStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ACP did not reach the response write")
|
||||
}
|
||||
// Keep Serve in the synchronous write until cancellation is observable. The
|
||||
// regression requires the genuine read error to win over that cancellation.
|
||||
cancel()
|
||||
close(releaseWrite)
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != exitCrash {
|
||||
t.Fatalf("exit code = %d, want crash %d", code, exitCrash)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ACP did not exit after terminal read error")
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "acp: "+wantErr.Error()) {
|
||||
t.Fatalf("stderr = %q, want terminal read error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
|
||||
cancel := installACPTestContext(t)
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
t.Cleanup(func() { _ = pipeWriter.Close() })
|
||||
readStarted := make(chan struct{}, 1)
|
||||
reader := &acpNotifyingReadCloser{ReadCloser: pipeReader, readStarted: readStarted}
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runACP(nil, &stdout, &stderr, fillAppDeps(appDeps{stdin: reader}))
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-readStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ACP did not begin reading")
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want success %d; stderr: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ACP did not exit after cancellation")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
type acpNotifyingReadCloser struct {
|
||||
io.ReadCloser
|
||||
readStarted chan<- struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *acpNotifyingReadCloser) Read(p []byte) (int, error) {
|
||||
r.once.Do(func() { r.readStarted <- struct{}{} })
|
||||
return r.ReadCloser.Read(p)
|
||||
}
|
||||
@@ -515,9 +515,7 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm
|
||||
if task.Agent.Truncated {
|
||||
converted.Truncated = true
|
||||
}
|
||||
for _, failure := range agentEvalFailuresFromTaskReport(task) {
|
||||
converted.Failures = append(converted.Failures, failure)
|
||||
}
|
||||
converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
@@ -585,6 +585,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
baseProbeProviderHealth := deps.probeProviderHealth
|
||||
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
|
||||
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
|
||||
if options.OAuthResolver == nil {
|
||||
options.OAuthResolver, _ = oauthLoginForProfile(options.Profile)
|
||||
}
|
||||
return baseProbeProviderHealth(ctx, options)
|
||||
}
|
||||
return deps
|
||||
|
||||
@@ -40,10 +40,11 @@ func runContext(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
report, err := contextreport.Build(contextreport.Options{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Provider: resolved.Provider,
|
||||
Registry: newCoreRegistry(workspaceRoot),
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Provider: resolved.Provider,
|
||||
Registry: newCoreRegistry(workspaceRoot),
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
|
||||
@@ -77,11 +77,10 @@ func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredEligibleCountIgnoresCoreTools(t *testing.T) {
|
||||
func TestDeferredEligibleCountIncludesOptionalBuiltins(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
// newCoreRegistry holds only built-ins; none implement Deferred().
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got != 0 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want 0", got)
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 2 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want at least 2 optional built-ins", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer)
|
||||
}
|
||||
if info.Hash != "" {
|
||||
lines = append(lines, " hash: "+info.Hash)
|
||||
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
||||
}
|
||||
lines = append(lines, " path: "+info.Skill.Path)
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||
|
||||
@@ -83,6 +83,48 @@ func TestRunSkillAddInfoRemove(t *testing.T) {
|
||||
if !strings.Contains(info, src) || !strings.Contains(info, "sha256:") {
|
||||
t.Fatalf("info missing source/hash:\n%s", info)
|
||||
}
|
||||
if !strings.Contains(info, "hash drift: false") {
|
||||
t.Fatalf("info missing hash drift status:\n%s", info)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info --json exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
var beforeJSON map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &beforeJSON); err != nil {
|
||||
t.Fatalf("parse skill info json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if beforeJSON["hashDrift"] != false {
|
||||
t.Fatalf("hashDrift before edit = %#v, want false", beforeJSON["hashDrift"])
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(skillsDir, "confirmation-policy", "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, []byte("---\ndescription: edited\n---\nchanged\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info after edit exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "hash drift: true") {
|
||||
t.Fatalf("info missing hash drift after edit:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info --json after edit exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
var afterJSON map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &afterJSON); err != nil {
|
||||
t.Fatalf("parse skill info json after edit: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if afterJSON["hashDrift"] != true {
|
||||
t.Fatalf("hashDrift after edit = %#v, want true", afterJSON["hashDrift"])
|
||||
}
|
||||
|
||||
// remove deletes it
|
||||
stdout.Reset()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -24,12 +25,47 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
|
||||
var heads, posts atomic.Int64
|
||||
// Closed when the prewarm probe lands, so the turn can be held open until it
|
||||
// does. See the POST handler for why that ordering has to be forced.
|
||||
headSeen := make(chan struct{})
|
||||
var headOnce sync.Once
|
||||
var prewarmWaitTimedOut atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodHead:
|
||||
heads.Add(1)
|
||||
headOnce.Do(func() { close(headSeen) })
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
|
||||
// Hold the turn open until the prewarm probe arrives. The probe is
|
||||
// issued on the run's context, and runExec cancels that context the
|
||||
// moment it returns, because the stop function signalContext hands
|
||||
// back is a CancelFunc. A probe that has not reached the wire by the
|
||||
// end of the run therefore never reaches it at all. That is why the
|
||||
// poll this replaced could not work at any deadline: once the run is
|
||||
// over the probe is cancelled, not merely slow.
|
||||
//
|
||||
// This widens the window, it does not close it. Once the goroutine is
|
||||
// scheduled it still has only prewarmTimeout to dial loopback and
|
||||
// write the request, and that timeout is armed inside the goroutine
|
||||
// itself, so holding the turn here cannot extend it. If this test
|
||||
// ever fails again, prewarmTimeout is where to look, not this hold.
|
||||
//
|
||||
// No self-deadlock: the pinned transport leaves MaxConnsPerHost at 0,
|
||||
// so the HEAD dials its own connection instead of queueing behind
|
||||
// this held POST. Do not set a per-host connection limit there.
|
||||
//
|
||||
// Bounded, so a probe that genuinely never fires fails the assertion
|
||||
// below with a useful message instead of hanging the test.
|
||||
select {
|
||||
case <-headSeen:
|
||||
case <-time.After(10 * time.Second):
|
||||
// Record it. A probe that shows up after this gave up would still
|
||||
// leave heads at 1, so the count alone proves the probe was
|
||||
// eventually received, not that it was ordered before the turn,
|
||||
// which is the whole claim of this test.
|
||||
prewarmWaitTimedOut.Store(true)
|
||||
}
|
||||
posts.Add(1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n"))
|
||||
@@ -75,14 +111,13 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
|
||||
if got := posts.Load(); got < 1 {
|
||||
t.Fatalf("server saw %d chat-completions POSTs, want >= 1", got)
|
||||
}
|
||||
// The prewarm probe is asynchronous; it fires well before the model turn
|
||||
// finishes, but poll rather than assuming scheduling order. The deadline
|
||||
// exceeds the production prewarmTimeout (3s) so a slow probe cannot flake
|
||||
// this assertion.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for heads.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Checked before the count, because the count cannot tell the two apart: a
|
||||
// probe that arrived only after the guard expired still leaves heads at 1.
|
||||
if prewarmWaitTimedOut.Load() {
|
||||
t.Fatal("the turn was not held until the prewarm probe arrived; the probe did not precede it")
|
||||
}
|
||||
// No polling: the turn above could not complete until the probe landed, so
|
||||
// the count is settled by this point.
|
||||
if got := heads.Load(); got != 1 {
|
||||
t.Fatalf("server saw %d prewarm HEAD probes, want exactly 1", got)
|
||||
}
|
||||
|
||||
@@ -308,6 +308,34 @@ 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 {
|
||||
@@ -734,14 +762,12 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
|
||||
switch {
|
||||
case legacyFound:
|
||||
raw = legacyRaw
|
||||
found = true
|
||||
case config.IsDefaultMCPServer(name):
|
||||
// A built-in default server isn't written to the file until the user
|
||||
// overrides it. Treat it as present with an empty base so disabling it
|
||||
// writes a minimal {"disabled":true} entry that merges over the default —
|
||||
// letting `zero mcp disable <default>` work even though it lives in code.
|
||||
raw = nil
|
||||
found = true
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -219,3 +220,81 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
@@ -109,6 +112,65 @@ func TestRunDoctorConnectivityProbesProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorConnectivityUsesOAuthLogin(t *testing.T) {
|
||||
tokenPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: tokenPath})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{
|
||||
AccessToken: "oauth-doctor-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed OAuth token: %v", err)
|
||||
}
|
||||
|
||||
var gotAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profile := config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/v1",
|
||||
Model: "grok-4",
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"doctor", "--connectivity"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Provider: profile,
|
||||
Providers: []config.ProviderProfile{profile},
|
||||
ActiveProvider: profile.Name,
|
||||
}, nil
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
now: fixedCLITime("2026-07-29T20:00:00Z"),
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("doctor exit = %d, want success; stderr=%q\n%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
if gotAuth != "Bearer oauth-doctor-token" {
|
||||
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[pass] provider.connectivity") {
|
||||
t.Fatalf("doctor did not report passing connectivity:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorConnectivityReportsProviderHealthFailure(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||
}
|
||||
|
||||
@@ -83,10 +83,10 @@ func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
|
||||
options := providerDetectOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
case "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
|
||||
@@ -244,19 +244,19 @@ func parseUsageArgs(args []string) (usageOptions, bool, error) {
|
||||
func FormatReport(report usage.Report, insertions int, deletions int) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Usage report (cost is a reconstructed estimate)\n\n")
|
||||
builder.WriteString(fmt.Sprintf("%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost"))
|
||||
fmt.Fprintf(&builder, "%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost")
|
||||
for _, bucket := range report.Buckets {
|
||||
builder.WriteString(fmt.Sprintf("%-12s %10d %14s %14s\n",
|
||||
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost)))
|
||||
fmt.Fprintf(&builder, "%-12s %10d %14s %14s\n",
|
||||
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost))
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("\n%-12s %10d %14s %14s\n",
|
||||
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost)))
|
||||
fmt.Fprintf(&builder, "\n%-12s %10d %14s %14s\n",
|
||||
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost))
|
||||
|
||||
builder.WriteString(fmt.Sprintf("\nnet LOC (estimate): +%d / -%d = %d\n",
|
||||
insertions, deletions, report.NetLOC))
|
||||
fmt.Fprintf(&builder, "\nnet LOC (estimate): +%d / -%d = %d\n",
|
||||
insertions, deletions, report.NetLOC)
|
||||
if report.NetLOCPositive {
|
||||
builder.WriteString(fmt.Sprintf("tokens per net LOC: %.1f\n", report.TokensPerNetLOC))
|
||||
builder.WriteString(fmt.Sprintf("est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC)))
|
||||
fmt.Fprintf(&builder, "tokens per net LOC: %.1f\n", report.TokensPerNetLOC)
|
||||
fmt.Fprintf(&builder, "est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC))
|
||||
} else {
|
||||
builder.WriteString("tokens per net LOC: n/a (net LOC <= 0)\n")
|
||||
builder.WriteString("est. cost per net LOC: n/a (net LOC <= 0)\n")
|
||||
|
||||
+121
-16
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,11 @@ func LoadProviderCommand(command string) (FileConfig, error) {
|
||||
stdout, stderr, err := runProviderCommand(command, providerCommandTimeout)
|
||||
if err != nil {
|
||||
if errors.Is(err, errProviderCommandTimeout) {
|
||||
return FileConfig{}, fmt.Errorf("provider command timed out after 5s")
|
||||
// Report which phase ran out of time, and anything the command
|
||||
// managed to say. This used to be a bare string with the underlying
|
||||
// error and stderr both dropped, which left a user whose command was
|
||||
// slow on a cold machine with nothing to act on.
|
||||
return FileConfig{}, fmt.Errorf("provider command timed out after %s: %w%s", providerCommandTimeout, err, commandOutput(stderr))
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider command failed: %w%s", err, commandOutput(stderr))
|
||||
}
|
||||
@@ -40,20 +45,111 @@ func LoadProviderCommand(command string) (FileConfig, error) {
|
||||
|
||||
var errProviderCommandTimeout = errors.New("provider command timeout")
|
||||
|
||||
// providerCommandDrainGrace bounds how long the timeout path waits for cmd.Wait
|
||||
// to return after the process tree has been terminated. Terminate normally makes
|
||||
// that immediate; this exists so a descendant that refuses to die cannot extend
|
||||
// a call whose deadline has already expired.
|
||||
const providerCommandDrainGrace = 2 * time.Second
|
||||
|
||||
// syncBuffer serialises the command's I/O pump against reads from this package.
|
||||
// On the timeout path runProviderCommand can return while cmd.Wait is still
|
||||
// draining, so the buffers outlive the call and a plain bytes.Buffer would be
|
||||
// read and written concurrently.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
// snapshot copies out what has been written so far. A copy rather than the
|
||||
// buffer's own slice, which the pump may still append to.
|
||||
func (b *syncBuffer) snapshot() []byte {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return append([]byte(nil), b.buf.Bytes()...)
|
||||
}
|
||||
|
||||
// awaitWithGrace waits for done and gives up after grace, reporting whether the
|
||||
// wait completed rather than expired.
|
||||
//
|
||||
// Split out from the timeout path so the bound can be asserted on its own. In
|
||||
// place it is effectively untestable: Terminate kills the tree, so Wait returns
|
||||
// promptly and an unbounded receive would behave identically in every test that
|
||||
// is not pathological, which is exactly how the old unbounded receive survived.
|
||||
func awaitWithGrace(done <-chan error, grace time.Duration) bool {
|
||||
timer := time.NewTimer(grace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// runProviderCommand runs a provider command under a real deadline.
|
||||
//
|
||||
// "Real" is the point: timeout used to bound only the wait, so the call could
|
||||
// take arbitrarily longer than it. Two phases sat outside it. Starting the
|
||||
// process ran before the clock began, and on Windows that is CREATE_SUSPENDED,
|
||||
// job-object creation and assignment, and a system-wide thread snapshot, none of
|
||||
// which is fast on a cold or contended machine. Draining after termination was
|
||||
// unbounded as well. Both are inside the budget now, so the deadline is an upper
|
||||
// bound rather than a floor.
|
||||
func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) {
|
||||
cmd := shellCommand(command)
|
||||
// Bound the time Wait spends draining I/O pipes after the process exits,
|
||||
// in case an orphaned descendant still holds the write ends open.
|
||||
cmd.WaitDelay = time.Second
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
stdout := &syncBuffer{}
|
||||
stderr := &syncBuffer{}
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
||||
proc, err := startCommandProcess(cmd)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
// Armed before the process is started, so process creation is inside the
|
||||
// budget rather than ahead of it.
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
|
||||
type startResult struct {
|
||||
proc *commandProcess
|
||||
err error
|
||||
}
|
||||
// Buffered, so the goroutine can finish and exit even when nobody is left
|
||||
// waiting on it because the deadline already fired.
|
||||
started := make(chan startResult, 1)
|
||||
go func() {
|
||||
proc, err := startCommandProcess(cmd)
|
||||
started <- startResult{proc: proc, err: err}
|
||||
}()
|
||||
|
||||
var proc *commandProcess
|
||||
select {
|
||||
case result := <-started:
|
||||
if result.err != nil {
|
||||
return stdout.snapshot(), stderr.snapshot(), result.err
|
||||
}
|
||||
proc = result.proc
|
||||
case <-deadline.C:
|
||||
// The start is still in flight and will eventually hand back whatever it
|
||||
// created, so cleanup is handed off rather than abandoned. Wait reaps the
|
||||
// process; without it a started-but-orphaned command becomes a zombie.
|
||||
go func() {
|
||||
result := <-started
|
||||
if result.err != nil {
|
||||
return
|
||||
}
|
||||
result.proc.Terminate()
|
||||
_ = cmd.Wait()
|
||||
result.proc.Close()
|
||||
}()
|
||||
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: starting the command exceeded %s", errProviderCommandTimeout, timeout)
|
||||
}
|
||||
defer proc.Close()
|
||||
|
||||
@@ -62,9 +158,6 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
@@ -77,13 +170,25 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
|
||||
proc.Terminate()
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
// Wrap rather than replace. A descendant holding the inherited pipes
|
||||
// open and the command never finishing at all are different failures
|
||||
// that both collapsed into the same bare sentinel, which left callers
|
||||
// and tests unable to tell which one happened. Callers that only ask
|
||||
// errors.Is(err, errProviderCommandTimeout) are unaffected, so the
|
||||
// message LoadProviderCommand reports is unchanged.
|
||||
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: %w", errProviderCommandTimeout, err)
|
||||
}
|
||||
return stdout.Bytes(), stderr.Bytes(), err
|
||||
case <-timer.C:
|
||||
return stdout.snapshot(), stderr.snapshot(), err
|
||||
case <-deadline.C:
|
||||
proc.Terminate()
|
||||
<-done
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
// Bounded, unlike the bare receive this replaces. Terminate kills the
|
||||
// tree so Wait almost always returns at once, but "almost always" was
|
||||
// doing real work here: a tree that resists termination used to hold the
|
||||
// call open indefinitely, long past the deadline that had just expired.
|
||||
// Returning early leaves cmd.Wait running, which is why the buffers are
|
||||
// synchronised.
|
||||
awaitWithGrace(done, providerCommandDrainGrace)
|
||||
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: the command did not finish within %s", errProviderCommandTimeout, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+256
-27
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -91,6 +92,29 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
assertProcessTerminatedIfStarted(t, pidFile)
|
||||
}
|
||||
|
||||
// providerCommandTestBudget bounds the two background-child tests below, and is
|
||||
// deliberately NOT providerCommandTimeout. Those tests assert which error each
|
||||
// cmd.Wait path yields and that the leftover descendant is killed; neither is a
|
||||
// claim about how fast Windows can start a process. Driving them through
|
||||
// LoadProviderCommand billed the fixture's own startup to the production 5s
|
||||
// budget, so on a loaded runner the outer timer won the select in
|
||||
// runProviderCommand: the nonzero-exit test saw a timeout instead of the exit
|
||||
// status it asserts, and the timer's Terminate killed the background child
|
||||
// before it recorded its PID, putting the PID file permanently out of reach no
|
||||
// matter how long the assertion polled for it.
|
||||
//
|
||||
// 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 = 60 * time.Second
|
||||
|
||||
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
|
||||
// dying of old age is unobservable: any run where runProviderCommand has not
|
||||
// returned yet trips the budget first. That is what lets both tests drop their
|
||||
// old elapsed-versus-lifetime bounds. "Still alive when the call returned" is
|
||||
// now true by construction, so a child found dead afterwards can only have been
|
||||
// killed by proc.Terminate.
|
||||
const backgroundChildLifetime = 120 * time.Second
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
|
||||
// exits immediately but leaves a detached child holding the inherited
|
||||
// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only
|
||||
@@ -98,24 +122,22 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
// leftover child instead of just returning and leaking it.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg.pid")
|
||||
childLifetime := 10 * time.Second
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
BackgroundSleepSeconds: int(childLifetime / time.Second),
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
t.Fatal("runProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out after 5s") {
|
||||
t.Fatalf("error = %q, want timeout", err.Error())
|
||||
}
|
||||
if elapsed >= childLifetime {
|
||||
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
|
||||
// Name the exact path. The outer-timer path reports the bare sentinel, so
|
||||
// matching only that, or the "timed out after 5s" text this test used to
|
||||
// match, would also be satisfied by a slow runner taking precisely the path
|
||||
// this test exists to rule out.
|
||||
if !errors.Is(err, errProviderCommandTimeout) || !errors.Is(err, exec.ErrWaitDelay) {
|
||||
t.Fatalf("error = %v, want errProviderCommandTimeout wrapping exec.ErrWaitDelay", err)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
@@ -128,29 +150,56 @@ func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
// be terminated on that path.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
|
||||
childLifetime := 10 * time.Second
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "boom",
|
||||
ExitCode: 7,
|
||||
BackgroundSleepSeconds: int(childLifetime / time.Second),
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want failure from nonzero exit")
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
// The shell's own exit status has to survive. A timeout here means the
|
||||
// budget swallowed it, which is exactly what this test kept reporting on CI.
|
||||
if errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want the shell's exit status rather than a timeout", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exit status") {
|
||||
t.Fatalf("error = %q, want exit status failure", err.Error())
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error = %v, want *exec.ExitError from the nonzero exit", err)
|
||||
}
|
||||
if elapsed >= childLifetime {
|
||||
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
|
||||
if exitErr.ExitCode() != 7 {
|
||||
t.Fatalf("exit code = %d, want 7", exitErr.ExitCode())
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
func TestAssertProcessTerminatedWaitsForDelayedPIDFile(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "delayed.pid")
|
||||
firstMissing := make(chan struct{})
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
<-firstMissing
|
||||
tempFile := pidFile + ".tmp"
|
||||
if err := os.WriteFile(tempFile, []byte("2147483647\n"), 0o600); err != nil {
|
||||
writeDone <- err
|
||||
return
|
||||
}
|
||||
writeDone <- os.Rename(tempFile, pidFile)
|
||||
}()
|
||||
|
||||
missingObserved := false
|
||||
assertProcessTerminatedAfterMissing(t, pidFile, func() {
|
||||
missingObserved = true
|
||||
close(firstMissing)
|
||||
})
|
||||
if !missingObserved {
|
||||
t.Fatal("PID file existed before the missing-file polling path was exercised")
|
||||
}
|
||||
if err := <-writeDone; err != nil {
|
||||
t.Fatalf("write delayed pid file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -167,16 +216,41 @@ func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
|
||||
func assertProcessTerminated(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(pidFile)
|
||||
assertProcessTerminatedAfterMissing(t, pidFile, nil)
|
||||
}
|
||||
|
||||
func assertProcessTerminatedAfterMissing(t *testing.T, pidFile string, onMissing func()) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var data []byte
|
||||
var err error
|
||||
missingObserved := false
|
||||
for time.Now().Before(deadline) {
|
||||
data, err = os.ReadFile(pidFile)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("read sleeper pid file: %v", err)
|
||||
}
|
||||
if !missingObserved {
|
||||
missingObserved = true
|
||||
if onMissing != nil {
|
||||
onMissing()
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read sleeper pid file: %v", err)
|
||||
t.Fatalf("read sleeper pid file before timeout: %v", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("parse sleeper pid %q: %v", data, err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
return
|
||||
@@ -253,8 +327,33 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
|
||||
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
|
||||
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
|
||||
waitForReady := "while (-not (Test-Path -LiteralPath '" + psSingleQuote(readyFile) + "')) { Start-Sleep -Milliseconds 10 }"
|
||||
lines = append(lines, "powershell -NoProfile -Command \""+waitForReady+"\"")
|
||||
// Wait for the child to record its PID before exiting, so the PID is
|
||||
// on disk while the tree is still alive: runProviderCommand kills the
|
||||
// tree one WaitDelay after the script exits, and a child that has not
|
||||
// written by then never will.
|
||||
//
|
||||
// This waits in cmd itself. Spawning a second PowerShell just to poll
|
||||
// cost another interpreter cold start, and that start is pure overhead
|
||||
// added AFTER the PID is already written. On a loaded runner it pushed
|
||||
// the script's exit past the 5s provider-command timeout, so the
|
||||
// nonzero-exit test saw a timeout instead of the exit status it
|
||||
// asserts. The wait is also bounded now: giving up and letting the PID
|
||||
// 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 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 15 goto zeroReady",
|
||||
"ping -n 2 127.0.0.1 >nul",
|
||||
"goto zeroWaitReady",
|
||||
":zeroReady",
|
||||
)
|
||||
}
|
||||
lines = append(lines, "exit /b "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
|
||||
@@ -315,3 +414,133 @@ func itoa(value int) string {
|
||||
}
|
||||
return string(digits[index:])
|
||||
}
|
||||
|
||||
// The whole point of #811: providerCommandTimeout has to be an upper bound on
|
||||
// how long the call takes, not a floor. Process creation used to run before the
|
||||
// clock started and the post-termination drain was unbounded, so a slow or
|
||||
// contended machine could block startup for far longer than the error text
|
||||
// promised.
|
||||
//
|
||||
// This one guards the end-to-end contract against gross regressions. It is NOT
|
||||
// the assertion that pins the drain bound: Terminate kills the tree, so Wait
|
||||
// returns promptly and this passes even with the drain unbounded. That property
|
||||
// is pinned by TestAwaitWithGraceGivesUp, and the start-phase bound by
|
||||
// TestRunProviderCommandCountsProcessStartAgainstTheDeadline.
|
||||
func TestRunProviderCommandBoundsTotalDuration(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bound.pid")
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 60, PidFile: pidFile})
|
||||
|
||||
const budget = 2 * time.Second
|
||||
start := time.Now()
|
||||
_, _, err := runProviderCommand(command, budget)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want the timeout sentinel", err)
|
||||
}
|
||||
// Budget, plus the bounded drain, plus room for a loaded runner. A failure
|
||||
// here means some phase is outside the deadline again, which is the bug.
|
||||
ceiling := budget + providerCommandDrainGrace + 8*time.Second
|
||||
if elapsed > ceiling {
|
||||
t.Fatalf("returned after %s with a %s budget; the timeout is not bounding the call (ceiling %s)", elapsed, budget, ceiling)
|
||||
}
|
||||
assertProcessTerminatedIfStarted(t, pidFile)
|
||||
}
|
||||
|
||||
// The timeout error has to carry something an operator can act on. It used to be
|
||||
// a bare string with the underlying error and the command's stderr both dropped,
|
||||
// so a user whose provider command was slow got no clue which phase ran out or
|
||||
// what the command had said.
|
||||
func TestLoadProviderCommandTimeoutReportsDiagnostics(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "provider is warming up",
|
||||
SleepSeconds: 30,
|
||||
PidFile: filepath.Join(t.TempDir(), "diag.pid"),
|
||||
})
|
||||
|
||||
_, err := LoadProviderCommand(command)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out") {
|
||||
t.Fatalf("error = %q, want a timeout message", err.Error())
|
||||
}
|
||||
// Wrapped, so callers can still classify it.
|
||||
if !errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want errors.Is against the timeout sentinel", err)
|
||||
}
|
||||
// And it must say which phase, rather than only that time ran out.
|
||||
if !strings.Contains(err.Error(), "did not finish") && !strings.Contains(err.Error(), "starting the command") {
|
||||
t.Fatalf("error = %q, want it to name the phase that exceeded the deadline", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// The output buffers are written by cmd.Wait's I/O pump, which keeps running
|
||||
// after the timeout path has returned them to the caller, so a plain
|
||||
// bytes.Buffer would be read and written concurrently. Asserted on the buffer
|
||||
// directly: driving it end to end does not reliably produce the overlap, because
|
||||
// a terminated command usually stops writing before the snapshot is taken, so
|
||||
// such a test passes with the locking removed and proves nothing.
|
||||
//
|
||||
// Only meaningful under -race, which CI runs.
|
||||
func TestSyncBufferAllowsConcurrentWriteAndSnapshot(t *testing.T) {
|
||||
buffer := &syncBuffer{}
|
||||
writing := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writing)
|
||||
for i := 0; i < 2000; i++ {
|
||||
_, _ = buffer.Write([]byte("x"))
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 2000; i++ {
|
||||
_ = buffer.snapshot()
|
||||
}
|
||||
<-writing
|
||||
if len(buffer.snapshot()) != 2000 {
|
||||
t.Fatalf("buffer holds %d bytes, want 2000", len(buffer.snapshot()))
|
||||
}
|
||||
}
|
||||
|
||||
// The drain after termination must be bounded. Asserted directly on the helper,
|
||||
// because in place it is untestable without a tree that refuses to die: Wait
|
||||
// normally returns at once, which is precisely why an unbounded receive sat
|
||||
// there unnoticed. A channel that never fires is the pathological case made
|
||||
// cheap.
|
||||
func TestAwaitWithGraceGivesUp(t *testing.T) {
|
||||
never := make(chan error)
|
||||
start := time.Now()
|
||||
if awaitWithGrace(never, 50*time.Millisecond) {
|
||||
t.Fatal("reported completion for a wait that never finished")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("gave up after %s, want roughly the grace period", elapsed)
|
||||
}
|
||||
// Still reports completion when the wait does finish, or the timeout path
|
||||
// would always claim the tree hung.
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
if !awaitWithGrace(done, time.Second) {
|
||||
t.Fatal("reported expiry for a wait that had already finished")
|
||||
}
|
||||
}
|
||||
|
||||
// Process creation has to be inside the budget. It used to run before the clock
|
||||
// started, and on Windows that phase is CREATE_SUSPENDED plus job-object setup
|
||||
// plus a system-wide thread snapshot, none of it fast on a contended machine.
|
||||
//
|
||||
// A budget that is already spent when the call begins can only be exceeded
|
||||
// during the start, so the phase named in the error is what distinguishes the
|
||||
// two orderings: with the clock started afterwards, the start would always
|
||||
// complete first and the failure would come from the wait instead.
|
||||
func TestRunProviderCommandCountsProcessStartAgainstTheDeadline(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-x","model":"m"}`})
|
||||
|
||||
_, _, err := runProviderCommand(command, time.Nanosecond)
|
||||
if !errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want the timeout sentinel", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "starting the command") {
|
||||
t.Fatalf("error = %q, want the start phase to be the one that exceeded the deadline; "+
|
||||
"anything else means process creation is outside the budget again", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
|
||||
// server the user explicitly enabled. The only layer allowed to
|
||||
// override an explicit higher-scope decision is the CLI override scope
|
||||
// (canReenable=true).
|
||||
if canReenable || !(baseDisabledSet && baseDisabled != next.Disabled) {
|
||||
if canReenable || !baseDisabledSet || baseDisabled == next.Disabled {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ type PreferencesConfig struct {
|
||||
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
|
||||
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
|
||||
// the user turned idle recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
Recaps *bool `json:"recaps,omitempty"`
|
||||
}
|
||||
@@ -130,7 +130,7 @@ type RecentModelEntry struct {
|
||||
// MaxRecentModels caps the persisted/displayed recent-selection history.
|
||||
const MaxRecentModels = 5
|
||||
|
||||
// RecapsEnabled reports whether post-turn recaps are on. Unset defaults to ON.
|
||||
// RecapsEnabled reports whether idle recaps are on. Unset defaults to ON.
|
||||
func (p PreferencesConfig) RecapsEnabled() bool {
|
||||
return p.Recaps == nil || *p.Recaps
|
||||
}
|
||||
|
||||
@@ -546,7 +546,7 @@ func SetRecentModels(path string, entries []RecentModelEntry) (FileConfig, error
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetRecapsEnabled persists the post-turn recap preference, mirroring
|
||||
// SetRecapsEnabled persists the idle recap preference, mirroring
|
||||
// SetFavoriteModels (read-modify-atomic-write).
|
||||
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
@@ -40,6 +40,7 @@ type Options struct {
|
||||
Provider config.ProviderProfile
|
||||
Registry *tools.Registry
|
||||
ContextWindow int
|
||||
DeferThreshold int
|
||||
ProjectContextFiles []string
|
||||
}
|
||||
|
||||
@@ -56,6 +57,8 @@ type Report struct {
|
||||
FreeTokens int `json:"freeTokens"`
|
||||
UsedFraction float64 `json:"usedFraction"`
|
||||
ToolCount int `json:"toolCount"`
|
||||
AvailableToolCount int `json:"availableToolCount,omitempty"`
|
||||
DeferredToolCount int `json:"deferredToolCount,omitempty"`
|
||||
ProjectGuidelineFile string `json:"projectGuidelineFile,omitempty"`
|
||||
Categories []Category `json:"categories"`
|
||||
}
|
||||
@@ -113,8 +116,10 @@ func Build(options Options) (Report, error) {
|
||||
categories = append(categories, category(CategoryWorkspaceMap, "Workspace map", estimateTextTokens(workspaceMap), report.ContextWindow))
|
||||
}
|
||||
|
||||
toolCount, toolTokens := estimateRegistryTools(options.Registry)
|
||||
toolCount, availableTools, deferredTools, toolTokens := estimateRegistryTools(options.Registry, options.DeferThreshold)
|
||||
report.ToolCount = toolCount
|
||||
report.AvailableToolCount = availableTools
|
||||
report.DeferredToolCount = deferredTools
|
||||
if toolTokens > 0 {
|
||||
categories = append(categories, category(CategoryTools, "Tools", toolTokens, report.ContextWindow))
|
||||
}
|
||||
@@ -175,7 +180,11 @@ func Format(report Report) string {
|
||||
lines = append(lines, fmt.Sprintf("usage: %s tokens (context window unknown)", compactNumber(report.UsedTokens)))
|
||||
}
|
||||
if report.ToolCount > 0 {
|
||||
lines = append(lines, fmt.Sprintf("tools: %d", report.ToolCount))
|
||||
toolLine := fmt.Sprintf("tools: %d exposed", report.ToolCount)
|
||||
if report.AvailableToolCount > report.ToolCount {
|
||||
toolLine += fmt.Sprintf(", %d available, %d deferred", report.AvailableToolCount, report.DeferredToolCount)
|
||||
}
|
||||
lines = append(lines, toolLine)
|
||||
}
|
||||
if report.ProjectGuidelineFile != "" {
|
||||
lines = append(lines, "project_guidelines: "+report.ProjectGuidelineFile)
|
||||
@@ -212,29 +221,51 @@ func category(key string, name string, tokens int, contextWindow int) Category {
|
||||
return cat
|
||||
}
|
||||
|
||||
func estimateRegistryTools(registry *tools.Registry) (int, int) {
|
||||
func estimateRegistryTools(registry *tools.Registry, deferThreshold int) (int, int, int, int) {
|
||||
if registry == nil {
|
||||
return 0, 0
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
all := registry.All()
|
||||
sort.Slice(all, func(left int, right int) bool {
|
||||
return all[left].Name() < all[right].Name()
|
||||
})
|
||||
total := 0
|
||||
count := 0
|
||||
var visible []tools.Tool
|
||||
var deferred []tools.Tool
|
||||
for _, tool := range all {
|
||||
if tool.Safety().Permission == tools.PermissionDeny {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
visible = append(visible, tool)
|
||||
if tools.IsDeferred(tool) {
|
||||
deferred = append(deferred, tool)
|
||||
}
|
||||
}
|
||||
active := deferThreshold > 0 && len(deferred) >= deferThreshold
|
||||
exposed := visible
|
||||
if active {
|
||||
exposed = make([]tools.Tool, 0, len(visible)-len(deferred)+1)
|
||||
for _, tool := range visible {
|
||||
if !tools.IsDeferred(tool) && tool.Name() != tools.ToolSearchToolName {
|
||||
exposed = append(exposed, tool)
|
||||
}
|
||||
}
|
||||
exposed = append(exposed, tools.NewToolSearchTool(registry))
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, tool := range exposed {
|
||||
total += estimateTextTokens(tool.Name())
|
||||
total += estimateTextTokens(tool.Description())
|
||||
description := tool.Description()
|
||||
if active && tool.Name() == tools.ToolSearchToolName {
|
||||
description = tools.BuildToolSearchDescription(deferred)
|
||||
}
|
||||
total += estimateTextTokens(description)
|
||||
if encoded, err := json.Marshal(tool.Parameters()); err == nil {
|
||||
total += estimateTextTokens(string(encoded))
|
||||
}
|
||||
total += toolDefinitionOverheadTokens
|
||||
}
|
||||
return count, total
|
||||
return len(exposed), len(visible), len(deferred), total
|
||||
}
|
||||
|
||||
func readProjectGuidelines(root string, names []string) (string, string) {
|
||||
|
||||
@@ -130,6 +130,35 @@ func TestBuildHasStableJSONContractAndCategoryMath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportsInitialDeferredToolSurface(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
|
||||
eager, err := Build(Options{WorkspaceRoot: root, Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deferred, err := Build(Options{WorkspaceRoot: root, Registry: registry, DeferThreshold: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deferred.DeferredToolCount < 3 {
|
||||
t.Fatalf("DeferredToolCount = %d, want at least 3", deferred.DeferredToolCount)
|
||||
}
|
||||
if deferred.AvailableToolCount != eager.ToolCount {
|
||||
t.Fatalf("available tools = %d, want eager total %d", deferred.AvailableToolCount, eager.ToolCount)
|
||||
}
|
||||
if deferred.ToolCount >= deferred.AvailableToolCount {
|
||||
t.Fatalf("initial exposed tools = %d, available = %d; expected a smaller initial surface", deferred.ToolCount, deferred.AvailableToolCount)
|
||||
}
|
||||
if categoryByKey(deferred, CategoryTools).Tokens >= categoryByKey(eager, CategoryTools).Tokens {
|
||||
t.Fatalf("deferred tool schemas did not reduce the initial context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClampsFreeBudgetWhenOverContextWindow(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "AGENTS.md", strings.Repeat("very large project rule\n", 1000))
|
||||
|
||||
@@ -100,7 +100,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
}
|
||||
default:
|
||||
}
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %w", err)
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
|
||||
@@ -2,6 +2,7 @@ package imageinput
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -16,6 +17,13 @@ import (
|
||||
// clipboard, or (nil, "", nil) when the clipboard has no image. Called when
|
||||
// text clipboard is empty (the user pasted a screenshot). The media type is
|
||||
// sniffed from the bytes, not trusted from the clipboard.
|
||||
// ErrClipboardImageUnreadable reports that the clipboard holds an image the host
|
||||
// has no way to extract. It is deliberately distinct from "no image on the
|
||||
// clipboard", which is an ordinary no-op: this one is actionable, and silence is
|
||||
// the wrong response to it.
|
||||
var ErrClipboardImageUnreadable = errors.New(
|
||||
"the clipboard holds an image but no helper on this host can read it; on macOS install pngpaste (brew install pngpaste) or a Python with pyobjc")
|
||||
|
||||
func ReadClipboardImage() ([]byte, string, error) {
|
||||
data, err := readClipboardImageBytes()
|
||||
if err != nil {
|
||||
@@ -95,35 +103,71 @@ func readClipboardImageWindows() ([]byte, error) {
|
||||
|
||||
// readClipboardImageDarwin uses osascript to check for and read a clipboard
|
||||
// image. Returns (nil, nil) when no image is present.
|
||||
func readClipboardImageDarwin() ([]byte, error) {
|
||||
// Check clipboard info for image classes.
|
||||
check := `osascript -e 'clipboard info'`
|
||||
out, err := exec.Command("sh", "-c", check).Output()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
// darwinClipboardInfo reports what the pasteboard is currently offering, and
|
||||
// darwinClipboardExtract pulls the PNG bytes out of it.
|
||||
//
|
||||
// Both are vars so the two-step logic below can be driven in a test. The step
|
||||
// that matters, "the clipboard holds an image and nothing here can extract it",
|
||||
// only happens when the first succeeds and the second fails, which cannot be
|
||||
// arranged by stubbing the caller: a test that substitutes ReadClipboardImage
|
||||
// asserts the plumbing around this function rather than the decision inside it,
|
||||
// and stays green with that decision reverted.
|
||||
var (
|
||||
darwinClipboardInfo = func() (string, error) {
|
||||
out, err := exec.Command("sh", "-c", `osascript -e 'clipboard info'`).Output()
|
||||
return string(out), err
|
||||
}
|
||||
info := string(out)
|
||||
if !strings.Contains(info, "PNG") && !strings.Contains(info, "JPEG") && !strings.Contains(info, "TIFF") && !strings.Contains(info, "GIF") {
|
||||
return nil, nil
|
||||
}
|
||||
// Write clipboard image to a temp file via AppleScript, then read it.
|
||||
// Using pngpaste if available, falling back to a Python one-liner.
|
||||
cmd := exec.Command("sh", "-c", `pngpaste - 2>/dev/null || python3 -c "
|
||||
darwinClipboardExtract = func() ([]byte, error) {
|
||||
// pngpaste if available, falling back to a Python one-liner. Neither
|
||||
// ships with macOS: pngpaste is Homebrew-only and Apple dropped the
|
||||
// bundled PyObjC, so a stock Mac fails here every time.
|
||||
cmd := exec.Command("sh", "-c", `pngpaste - 2>/dev/null || python3 -c "
|
||||
import AppKit, sys
|
||||
pb = AppKit.NSPasteboard.generalPasteboard()
|
||||
data = pb.dataForType_(AppKit.NSPasteboardTypePNG)
|
||||
if data:
|
||||
sys.stdout.buffer.write(data.bytes())
|
||||
"`)
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
)
|
||||
|
||||
// darwinClipboardOffersImage reports whether the pasteboard info line names an
|
||||
// image class.
|
||||
func darwinClipboardOffersImage(info string) bool {
|
||||
for _, class := range []string{"PNG", "JPEG", "TIFF", "GIF"} {
|
||||
if strings.Contains(info, class) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readClipboardImageDarwin() ([]byte, error) {
|
||||
info, err := darwinClipboardInfo()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
if !darwinClipboardOffersImage(info) {
|
||||
return nil, nil
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
// Past this point the clipboard is KNOWN to hold an image, because the probe
|
||||
// above said so. A failure here is therefore not "no image", it is "there is
|
||||
// an image and nothing on this host can extract it". Reporting that as
|
||||
// absence is what left users staring at a paste that silently did nothing.
|
||||
data, err := darwinClipboardExtract()
|
||||
if err != nil {
|
||||
return nil, ErrClipboardImageUnreadable
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, ErrClipboardImageUnreadable
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// readClipboardImageLinux tries wl-paste (Wayland) then xclip (X11) to read
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package imageinput
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Drives readClipboardImageDarwin's own decision rather than the plumbing around
|
||||
// it. Both shell steps are substituted, so this runs on any platform and, more
|
||||
// importantly, actually reaches the branch under test: a test that stubs
|
||||
// ReadClipboardImage instead stays green with the production change reverted,
|
||||
// which is how the first version of this coverage slipped through.
|
||||
func TestReadClipboardImageDarwinDistinguishesUnreadableFromAbsent(t *testing.T) {
|
||||
restore := func() func() {
|
||||
info, extract := darwinClipboardInfo, darwinClipboardExtract
|
||||
return func() { darwinClipboardInfo, darwinClipboardExtract = info, extract }
|
||||
}()
|
||||
t.Cleanup(restore)
|
||||
|
||||
const pngInfo = `«class PNGf», «class 8BPS»`
|
||||
|
||||
t.Run("image present but no extractor is actionable", func(t *testing.T) {
|
||||
darwinClipboardInfo = func() (string, error) { return pngInfo, nil }
|
||||
darwinClipboardExtract = func() ([]byte, error) { return nil, errors.New("exit status 127") }
|
||||
|
||||
data, err := readClipboardImageDarwin()
|
||||
if !errors.Is(err, ErrClipboardImageUnreadable) {
|
||||
t.Fatalf("err = %v, want ErrClipboardImageUnreadable; a stock Mac gets silence instead of a remedy", err)
|
||||
}
|
||||
if data != nil {
|
||||
t.Fatalf("data = %q, want none", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extractor producing nothing is also actionable", func(t *testing.T) {
|
||||
darwinClipboardInfo = func() (string, error) { return pngInfo, nil }
|
||||
darwinClipboardExtract = func() ([]byte, error) { return nil, nil }
|
||||
|
||||
if _, err := readClipboardImageDarwin(); !errors.Is(err, ErrClipboardImageUnreadable) {
|
||||
t.Fatalf("err = %v, want ErrClipboardImageUnreadable for an extractor that exits 0 with no bytes", err)
|
||||
}
|
||||
})
|
||||
|
||||
// The other half of the distinction, and the reason this cannot simply always
|
||||
// report an error: an empty clipboard is an ordinary no-op, and turning that
|
||||
// into a notice would make every stray Ctrl+V nag the user.
|
||||
t.Run("no image on the clipboard stays silent", func(t *testing.T) {
|
||||
darwinClipboardInfo = func() (string, error) { return `«class utf8»`, nil }
|
||||
darwinClipboardExtract = func() ([]byte, error) {
|
||||
t.Fatal("extractor ran despite the clipboard offering no image")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := readClipboardImageDarwin()
|
||||
if err != nil || data != nil {
|
||||
t.Fatalf("data=%q err=%v, want a silent no-op", data, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("probe failure stays silent", func(t *testing.T) {
|
||||
darwinClipboardInfo = func() (string, error) { return "", errors.New("osascript missing") }
|
||||
darwinClipboardExtract = func() ([]byte, error) {
|
||||
t.Fatal("extractor ran despite the info probe failing")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if data, err := readClipboardImageDarwin(); err != nil || data != nil {
|
||||
t.Fatalf("data=%q err=%v, want a silent no-op", data, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("working extractor returns the bytes", func(t *testing.T) {
|
||||
darwinClipboardInfo = func() (string, error) { return pngInfo, nil }
|
||||
darwinClipboardExtract = func() ([]byte, error) { return []byte("png-bytes"), nil }
|
||||
|
||||
data, err := readClipboardImageDarwin()
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want none", err)
|
||||
}
|
||||
if string(data) != "png-bytes" {
|
||||
t.Fatalf("data = %q, want the extractor's bytes", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func buildMinimalPDF(text string) []byte {
|
||||
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
|
||||
buf.WriteString("0000000000 65535 f \n")
|
||||
for _, off := range offsets {
|
||||
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
|
||||
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
|
||||
@@ -277,7 +277,7 @@ func buildEmptyTextPDF() []byte {
|
||||
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
|
||||
buf.WriteString("0000000000 65535 f \n")
|
||||
for _, off := range offsets {
|
||||
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
|
||||
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
@@ -64,7 +65,7 @@ func TestDecodeSymbols(t *testing.T) {
|
||||
|
||||
func TestNavigateUnknownOp(t *testing.T) {
|
||||
m := NewManager(t.TempDir())
|
||||
_, _, ok, err := m.Navigate(nil, NavRequest{Op: "bogus", Path: "x.go"})
|
||||
_, _, ok, err := m.Navigate(context.TODO(), NavRequest{Op: "bogus", Path: "x.go"})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unknown nav op")
|
||||
}
|
||||
@@ -76,7 +77,7 @@ func TestNavigateUnknownOp(t *testing.T) {
|
||||
func TestNavigateUnsupportedExtensionDegrades(t *testing.T) {
|
||||
// A file type with no configured server degrades to ok=false, no error.
|
||||
m := NewManager(t.TempDir())
|
||||
_, _, ok, err := m.Navigate(nil, NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1})
|
||||
_, _, ok, err := m.Navigate(context.TODO(), NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("unsupported extension should not error, got %v", err)
|
||||
}
|
||||
|
||||
+134
-2
@@ -13,8 +13,10 @@ package minify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/scanner"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -34,8 +36,11 @@ func File(path string, content []byte) Result {
|
||||
if out, ok := minifyGo(content); ok {
|
||||
return Result{Content: out, Language: "go", Applied: true}
|
||||
}
|
||||
// Unparsable Go (a snippet, generics edge, or syntax error): fall through
|
||||
// to the safe generic path rather than risk a partial AST reprint.
|
||||
if out, ok := minifyGoFragment(content); ok {
|
||||
return Result{Content: out, Language: "go-fragment", Applied: true}
|
||||
}
|
||||
// An incomplete fragment that cannot be wrapped safely falls through to
|
||||
// whitespace-only normalization; no source text is guessed or discarded.
|
||||
} else if style, ok := commentStyles[ext]; ok {
|
||||
// Strip comments with a string-aware lexer, then collapse the whitespace the
|
||||
// removed comments left behind. The stripper only handles languages whose
|
||||
@@ -46,6 +51,133 @@ func File(path string, content []byte) Result {
|
||||
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
|
||||
}
|
||||
|
||||
// Fragment minifies a bounded source range without assuming lexical state from
|
||||
// text outside the range. Go remains parser-validated; other languages use the
|
||||
// conservative whitespace-only transform because a range may begin inside a
|
||||
// multiline string or block comment.
|
||||
func Fragment(path string, content []byte) Result {
|
||||
if strings.EqualFold(filepath.Ext(path), ".go") {
|
||||
if out, ok := minifyGo(content); ok {
|
||||
return Result{Content: out, Language: "go", Applied: true}
|
||||
}
|
||||
if out, ok := minifyGoFragment(content); ok {
|
||||
return Result{Content: out, Language: "go-fragment", Applied: true}
|
||||
}
|
||||
}
|
||||
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
|
||||
}
|
||||
|
||||
// ContextualFragment minifies a bounded fragment after checking its starting
|
||||
// position against the complete source. A Go range that begins inside a string
|
||||
// or comment must remain verbatim apart from whitespace normalization: parsing
|
||||
// that fragment by itself would mistake literal text for Go syntax.
|
||||
func ContextualFragment(path string, source, content []byte, startOffset int) Result {
|
||||
if strings.EqualFold(filepath.Ext(path), ".go") && goFragmentStartsInsideToken(source, startOffset) {
|
||||
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
|
||||
}
|
||||
return Fragment(path, content)
|
||||
}
|
||||
|
||||
func goFragmentStartsInsideToken(source []byte, startOffset int) bool {
|
||||
if startOffset <= 0 || startOffset >= len(source) {
|
||||
return false
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file := fset.AddFile("", fset.Base(), len(source))
|
||||
var lexer scanner.Scanner
|
||||
lexer.Init(file, source, nil, scanner.ScanComments)
|
||||
for {
|
||||
position, kind, literal := lexer.Scan()
|
||||
if kind == token.EOF {
|
||||
return false
|
||||
}
|
||||
if kind != token.STRING && kind != token.COMMENT {
|
||||
continue
|
||||
}
|
||||
start := file.Offset(position)
|
||||
if startOffset > start && startOffset < start+len(literal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// minifyGoFragment handles bounded reads that do not contain a package clause.
|
||||
// It finds the longest complete declaration or statement prefix that the real
|
||||
// Go parser accepts, prints that prefix without comments, and preserves any
|
||||
// incomplete tail conservatively. Ranges are intentionally capped: repeatedly
|
||||
// parsing a large broken file would otherwise turn a cheap read into O(n²) work.
|
||||
func minifyGoFragment(content []byte) (string, bool) {
|
||||
lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n")
|
||||
if len(lines) > 1 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
if len(lines) > 512 {
|
||||
return "", false
|
||||
}
|
||||
for end := len(lines); end > 0; end-- {
|
||||
prefix := strings.Join(lines[:end], "\n")
|
||||
if compact, ok := parseGoDeclarations(prefix); ok {
|
||||
return joinCompactPrefix(compact, lines[end:]), true
|
||||
}
|
||||
if compact, ok := parseGoStatements(prefix); ok {
|
||||
return joinCompactPrefix(compact, lines[end:]), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseGoDeclarations(fragment string) (string, bool) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "", "package compact\n"+fragment, parser.SkipObjectResolution)
|
||||
if err != nil || len(file.Decls) == 0 {
|
||||
return "", false
|
||||
}
|
||||
nodes := make([]ast.Node, len(file.Decls))
|
||||
for i, declaration := range file.Decls {
|
||||
nodes[i] = declaration
|
||||
}
|
||||
return printGoNodes(fset, nodes)
|
||||
}
|
||||
|
||||
func parseGoStatements(fragment string) (string, bool) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "", "package compact\nfunc _(){\n"+fragment+"\n}", parser.SkipObjectResolution)
|
||||
if err != nil || len(file.Decls) != 1 {
|
||||
return "", false
|
||||
}
|
||||
function, ok := file.Decls[0].(*ast.FuncDecl)
|
||||
if !ok || function.Body == nil || len(function.Body.List) == 0 {
|
||||
return "", false
|
||||
}
|
||||
nodes := make([]ast.Node, len(function.Body.List))
|
||||
for i, statement := range function.Body.List {
|
||||
nodes[i] = statement
|
||||
}
|
||||
return printGoNodes(fset, nodes)
|
||||
}
|
||||
|
||||
func printGoNodes(fset *token.FileSet, nodes []ast.Node) (string, bool) {
|
||||
var buf bytes.Buffer
|
||||
cfg := printer.Config{Mode: printer.TabIndent, Tabwidth: 1}
|
||||
for i, node := range nodes {
|
||||
if i > 0 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if err := cfg.Fprint(&buf, fset, node); err != nil {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), true
|
||||
}
|
||||
|
||||
func joinCompactPrefix(compact string, remainder []string) string {
|
||||
tail := minifyGeneric([]byte(strings.Join(remainder, "\n")))
|
||||
if tail == "" {
|
||||
return compact
|
||||
}
|
||||
return compact + "\n" + tail
|
||||
}
|
||||
|
||||
// minifyGo parses Go WITHOUT comments (omitting parser.ParseComments leaves them
|
||||
// unattached) and reprints the AST, yielding valid, comment-free, gofmt-shaped
|
||||
// source with tab indentation (1 char per level). It returns ok=false on any
|
||||
|
||||
@@ -39,17 +39,58 @@ func Greet(name string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyGoFallsBackOnUnparsableGo(t *testing.T) {
|
||||
// A snippet (no package clause) cannot parse as a file -> safe generic path.
|
||||
func TestMinifyGoStatementFragment(t *testing.T) {
|
||||
r := File("snippet.go", []byte("x := 1 \n\n\n// keep me\ny := 2\n"))
|
||||
if r.Applied {
|
||||
t.Fatalf("expected fallback for unparsable Go, got %+v", r)
|
||||
if !r.Applied || r.Language != "go-fragment" {
|
||||
t.Fatalf("expected parsed Go fragment, got %+v", r)
|
||||
}
|
||||
if strings.Contains(r.Content, "\n\n\n") {
|
||||
t.Errorf("generic should collapse blank runs:\n%q", r.Content)
|
||||
t.Errorf("fragment should collapse blank runs:\n%q", r.Content)
|
||||
}
|
||||
if !strings.Contains(r.Content, "// keep me") {
|
||||
t.Errorf("generic must NOT strip comments (unsafe without a parser): %q", r.Content)
|
||||
if strings.Contains(r.Content, "keep me") || !strings.Contains(r.Content, "y := 2") {
|
||||
t.Errorf("fragment was not compacted safely: %q", r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyGoFragmentAccepts512LinesWithFinalNewline(t *testing.T) {
|
||||
src := strings.Repeat("x++\n", 512)
|
||||
r := File("snippet.go", []byte(src))
|
||||
if !r.Applied || r.Language != "go-fragment" {
|
||||
t.Fatalf("expected 512-line fragment to remain eligible, got %+v", r)
|
||||
}
|
||||
if got := strings.Count(r.Content, "x++"); got != 512 {
|
||||
t.Fatalf("compacted statement count = %d, want 512", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyGoDeclarationPrefixPreservesIncompleteTail(t *testing.T) {
|
||||
src := `func Handle737(n int) (int, error) {
|
||||
if n%2 == 1 {
|
||||
return 0, errBad
|
||||
}
|
||||
return n + 737, nil
|
||||
}
|
||||
|
||||
// Handle738 begins outside the requested range.
|
||||
func Handle738(n int) (int, error) {
|
||||
if n%2 == 1 {`
|
||||
r := File("range.go", []byte(src))
|
||||
if !r.Applied || r.Language != "go-fragment" {
|
||||
t.Fatalf("expected declaration-prefix compaction, got %+v", r)
|
||||
}
|
||||
if strings.Contains(r.Content, "Handle737 returns") || !strings.Contains(r.Content, "return n + 737") {
|
||||
t.Fatalf("complete declaration was not compacted correctly:\n%s", r.Content)
|
||||
}
|
||||
if !strings.Contains(r.Content, "func Handle738") || !strings.Contains(r.Content, "if n%2 == 1 {") {
|
||||
t.Fatalf("incomplete tail was not preserved:\n%s", r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyGoLargeInvalidInputFallsBackConservatively(t *testing.T) {
|
||||
src := strings.Repeat("x := 1 // keep\n", 513)
|
||||
r := File("broken.go", []byte(src))
|
||||
if r.Applied || !strings.Contains(r.Content, "// keep") {
|
||||
t.Fatalf("large invalid input must retain conservative fallback: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ var descriptors = []Descriptor{
|
||||
openAICompat("groq", "Groq", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", []string{"GROQ_API_KEY"}),
|
||||
openAICompat("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat", []string{"DEEPSEEK_API_KEY"}),
|
||||
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
|
||||
openAICompat("fireworks", "Fireworks AI", "https://api.fireworks.ai/inference/v1", "accounts/fireworks/models/kimi-k2p7-code", []string{"FIREWORKS_API_KEY"}, "fireworks-ai"),
|
||||
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
|
||||
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
|
||||
openAICompat("atlascloud", "Atlas Cloud", "https://api.atlascloud.ai/v1", "qwen/qwen3.5-flash", []string{"ATLASCLOUD_API_KEY"}, "atlas cloud", "atlas"),
|
||||
|
||||
@@ -22,6 +22,7 @@ var expectedCatalogIDs = []string{
|
||||
"groq",
|
||||
"deepseek",
|
||||
"together",
|
||||
"fireworks",
|
||||
"dashscope",
|
||||
"moonshot",
|
||||
"atlascloud",
|
||||
@@ -155,6 +156,28 @@ func TestLongCatDescriptor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireworksDescriptor(t *testing.T) {
|
||||
descriptor, err := Require("fireworks")
|
||||
if err != nil {
|
||||
t.Fatalf("Require(fireworks) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "Fireworks AI" {
|
||||
t.Fatalf("Name = %q, want Fireworks AI", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.fireworks.ai/inference/v1" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want Fireworks OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "accounts/fireworks/models/kimi-k2p7-code" {
|
||||
t.Fatalf("DefaultModel = %q, want accounts/fireworks/models/kimi-k2p7-code", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"FIREWORKS_API_KEY"}) {
|
||||
t.Fatalf("AuthEnvVars = %#v, want FIREWORKS_API_KEY", descriptor.AuthEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasCloudDescriptor(t *testing.T) {
|
||||
descriptor, err := Require("atlascloud")
|
||||
if err != nil {
|
||||
@@ -346,7 +369,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -46,12 +46,13 @@ const (
|
||||
const defaultTimeout = 5 * time.Second
|
||||
|
||||
type Options struct {
|
||||
Profile config.ProviderProfile
|
||||
Connectivity bool
|
||||
HTTPClient *http.Client
|
||||
Resolver Resolver
|
||||
Timeout time.Duration
|
||||
UserAgent string
|
||||
Profile config.ProviderProfile
|
||||
Connectivity bool
|
||||
HTTPClient *http.Client
|
||||
Resolver Resolver
|
||||
OAuthResolver providerio.TokenResolver
|
||||
Timeout time.Duration
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type Resolver interface {
|
||||
@@ -207,11 +208,26 @@ func Probe(ctx context.Context, options Options) Result {
|
||||
"providerKind": metadata.ProviderKind,
|
||||
}, profile))
|
||||
|
||||
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) {
|
||||
oauthConfigured := options.OAuthResolver != nil
|
||||
if oauthConfigured {
|
||||
var resolveErr error
|
||||
profile, oauthConfigured, resolveErr = profileWithOAuthCredential(ctx, profile, options.OAuthResolver, false)
|
||||
if resolveErr != nil {
|
||||
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, "Provider OAuth credential could not be resolved: "+resolveErr.Error(), map[string]any{
|
||||
"oauthLogin": true,
|
||||
}, profile))
|
||||
return result.finalize()
|
||||
}
|
||||
}
|
||||
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) && !oauthConfigured {
|
||||
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, fmt.Sprintf("Provider %s requires API credentials.", providerName(profile)), credentialDetails(profile), profile))
|
||||
return result.finalize()
|
||||
}
|
||||
if hasCredential(profile) {
|
||||
if oauthConfigured {
|
||||
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has OAuth credentials configured.", providerName(profile)), map[string]any{
|
||||
"oauthLogin": true,
|
||||
}, profile))
|
||||
} else if hasCredential(profile) {
|
||||
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has credentials configured.", providerName(profile)), credentialDetails(profile), profile))
|
||||
} else {
|
||||
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s does not require API credentials.", providerName(profile)), credentialDetails(profile), profile))
|
||||
@@ -286,9 +302,35 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
if err != nil {
|
||||
return classifyTransportError(err, profile)
|
||||
}
|
||||
defer func() {
|
||||
_ = response.Body.Close()
|
||||
}()
|
||||
defer func(body io.ReadCloser) {
|
||||
_ = body.Close()
|
||||
}(response.Body)
|
||||
if response.StatusCode == http.StatusUnauthorized && options.OAuthResolver != nil {
|
||||
refreshed, ok, refreshErr := profileWithOAuthCredential(requestCtx, options.Profile, options.OAuthResolver, true)
|
||||
if refreshErr != nil {
|
||||
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryAuth, "Provider OAuth credential refresh failed: "+refreshErr.Error(), map[string]any{
|
||||
"statusCode": response.StatusCode,
|
||||
}, profile)
|
||||
}
|
||||
if ok {
|
||||
_ = response.Body.Close()
|
||||
profile = refreshed
|
||||
request, allowLoopbackOrPrivate, err = healthRequest(requestCtx, profile, kind, options)
|
||||
if err != nil {
|
||||
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryConfig, err.Error(), nil, profile)
|
||||
}
|
||||
if options.HTTPClient == nil {
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
|
||||
}
|
||||
response, err = client.Do(request)
|
||||
if err != nil {
|
||||
return classifyTransportError(err, profile)
|
||||
}
|
||||
defer func(body io.ReadCloser) {
|
||||
_ = body.Close()
|
||||
}(response.Body)
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 64*1024))
|
||||
if err != nil {
|
||||
@@ -320,6 +362,23 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
}, profile)
|
||||
}
|
||||
|
||||
func profileWithOAuthCredential(ctx context.Context, profile config.ProviderProfile, resolver providerio.TokenResolver, forceRefresh bool) (config.ProviderProfile, bool, error) {
|
||||
if resolver == nil {
|
||||
return profile, false, nil
|
||||
}
|
||||
header, value, ok, err := resolver(ctx, forceRefresh)
|
||||
if err != nil || !ok {
|
||||
return profile, false, err
|
||||
}
|
||||
if strings.TrimSpace(header) == "" {
|
||||
header = "Authorization"
|
||||
}
|
||||
profile.AuthHeader = header
|
||||
profile.AuthScheme = ""
|
||||
profile.AuthHeaderValue = value
|
||||
return profile, true, nil
|
||||
}
|
||||
|
||||
func healthRequest(ctx context.Context, profile config.ProviderProfile, kind config.ProviderKind, options Options) (*http.Request, bool, error) {
|
||||
baseURL, err := resolvedBaseURL(profile, kind)
|
||||
if err != nil {
|
||||
@@ -391,7 +450,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
|
||||
}
|
||||
if addr, err := netip.ParseAddr(normalized); err == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return nil
|
||||
@@ -407,7 +466,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
@@ -529,7 +588,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
|
||||
return nil, err
|
||||
}
|
||||
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
@@ -542,7 +601,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
|
||||
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
@@ -748,6 +807,9 @@ func redact(message string, profile config.ProviderProfile) string {
|
||||
|
||||
func providerSecrets(profile config.ProviderProfile) []string {
|
||||
secrets := []string{profile.APIKey, profile.AuthHeaderValue}
|
||||
if fields := strings.Fields(profile.AuthHeaderValue); len(fields) == 2 {
|
||||
secrets = append(secrets, fields[1])
|
||||
}
|
||||
for _, value := range profile.CustomHeaders {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
secrets = append(secrets, value)
|
||||
|
||||
@@ -180,6 +180,182 @@ func TestProbeResolvedKindConnectivityAuthErrorRedactsSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeConnectivityUsesOAuthCredential(t *testing.T) {
|
||||
var gotAuth string
|
||||
resolverCalled := false
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
result := Probe(context.Background(), Options{
|
||||
Profile: config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
Model: "grok-4",
|
||||
},
|
||||
Connectivity: true,
|
||||
HTTPClient: client,
|
||||
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
resolverCalled = true
|
||||
return "Authorization", "Bearer oauth-test-token", true, nil
|
||||
},
|
||||
})
|
||||
|
||||
if result.Status != StatusPass {
|
||||
t.Fatalf("Status = %q, want pass: %#v", result.Status, result.Checks)
|
||||
}
|
||||
if !resolverCalled {
|
||||
t.Fatal("OAuth resolver was not called")
|
||||
}
|
||||
if gotAuth != "Bearer oauth-test-token" {
|
||||
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeConnectivityOAuthCredentialsAreRedacted(t *testing.T) {
|
||||
const (
|
||||
staticKey = "STATICKEYVALUE1234567890abcdef"
|
||||
oauthToken = "hf_VASANTHSENTINEL0011223344"
|
||||
)
|
||||
var authHeaders []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Status: "401 Unauthorized",
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"invalid credentials ` + staticKey + ` and ` + oauthToken + `"}}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
result := Probe(context.Background(), Options{
|
||||
Profile: config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: staticKey,
|
||||
Model: "grok-4",
|
||||
},
|
||||
Connectivity: true,
|
||||
HTTPClient: client,
|
||||
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
return "Authorization", "Bearer " + oauthToken, true, nil
|
||||
},
|
||||
})
|
||||
|
||||
if len(authHeaders) != 2 {
|
||||
t.Fatalf("Authorization headers = %#v, want initial request and one refresh retry", authHeaders)
|
||||
}
|
||||
for _, header := range authHeaders {
|
||||
if header != "Bearer "+oauthToken {
|
||||
t.Fatalf("Authorization = %q, want OAuth bearer", header)
|
||||
}
|
||||
}
|
||||
check := result.Check("provider.connectivity")
|
||||
if check == nil || check.Category != CategoryAuth {
|
||||
t.Fatalf("provider.connectivity = %#v, want auth failure", check)
|
||||
}
|
||||
if strings.Contains(check.Message, staticKey) {
|
||||
t.Fatalf("static API key leaked in message: %q", check.Message)
|
||||
}
|
||||
if strings.Contains(check.Message, oauthToken) {
|
||||
t.Fatalf("OAuth token leaked in message: %q", check.Message)
|
||||
}
|
||||
if strings.Count(check.Message, "[REDACTED]") != 2 {
|
||||
t.Fatalf("expected both credentials to be redacted, got %q", check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeWithoutConnectivityRejectsUnavailableOAuthCredential(t *testing.T) {
|
||||
resolverCalled := false
|
||||
result := Probe(context.Background(), Options{
|
||||
Profile: config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
Model: "grok-4",
|
||||
},
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
resolverCalled = true
|
||||
return "", "", false, nil
|
||||
},
|
||||
})
|
||||
|
||||
if !resolverCalled {
|
||||
t.Fatal("OAuth resolver was not called")
|
||||
}
|
||||
if result.Status != StatusFail {
|
||||
t.Fatalf("Status = %q, want fail: %#v", result.Status, result.Checks)
|
||||
}
|
||||
check := result.Check("provider.auth")
|
||||
if check == nil || check.Status != StatusFail || check.Category != CategoryAuth {
|
||||
t.Fatalf("provider.auth = %#v, want auth failure", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeConnectivityRefreshesOAuthCredentialAfterUnauthorized(t *testing.T) {
|
||||
var authHeaders []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
|
||||
status := http.StatusOK
|
||||
body := `{"data":[]}`
|
||||
if len(authHeaders) == 1 {
|
||||
status = http.StatusUnauthorized
|
||||
body = `{"error":{"message":"expired"}}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Status: http.StatusText(status),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
var refreshFlags []bool
|
||||
result := Probe(context.Background(), Options{
|
||||
Profile: config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
Model: "grok-4",
|
||||
},
|
||||
Connectivity: true,
|
||||
HTTPClient: client,
|
||||
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
|
||||
OAuthResolver: func(_ context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
refreshFlags = append(refreshFlags, forceRefresh)
|
||||
token := "stale-token"
|
||||
if forceRefresh {
|
||||
token = "fresh-token"
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
},
|
||||
})
|
||||
|
||||
if result.Status != StatusPass {
|
||||
t.Fatalf("Status = %q, want pass after refresh: %#v", result.Status, result.Checks)
|
||||
}
|
||||
if len(refreshFlags) != 2 || refreshFlags[0] || !refreshFlags[1] {
|
||||
t.Fatalf("OAuth refresh flags = %#v, want [false true]", refreshFlags)
|
||||
}
|
||||
if len(authHeaders) != 2 || authHeaders[0] != "Bearer stale-token" || authHeaders[1] != "Bearer fresh-token" {
|
||||
t.Fatalf("Authorization headers = %#v, want stale then fresh bearer", authHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeConnectivityClassifiesTimeout(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
|
||||
@@ -122,6 +122,14 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "deepseek-ai/DeepSeek-R1", Description: "reasoning model"},
|
||||
{ID: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", Description: "multimodal model"},
|
||||
},
|
||||
"fireworks": {
|
||||
// Serverless-only curated set: ordinary API-key setups hit /inference/v1
|
||||
// and cannot use on-demand deployment IDs.
|
||||
{ID: "accounts/fireworks/models/kimi-k2p7-code", Description: "catalog default"},
|
||||
{ID: "accounts/fireworks/models/deepseek-v4-flash", Description: "fast coding model"},
|
||||
{ID: "accounts/fireworks/models/gpt-oss-120b", Description: "general model"},
|
||||
{ID: "accounts/fireworks/models/deepseek-v4-pro", Description: "reasoning model"},
|
||||
},
|
||||
"dashscope": {
|
||||
{ID: "qwen-plus", Description: "catalog default"},
|
||||
{ID: "qwen-max", Description: "strong general model"},
|
||||
|
||||
@@ -28,6 +28,11 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"llama-3.3-70b-versatile", "openai/gpt-oss-120b"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "fireworks",
|
||||
want: []string{"accounts/fireworks/models/kimi-k2p7-code", "accounts/fireworks/models/deepseek-v4-flash"},
|
||||
notWant: []string{"gpt-4.1", "llama-3.3-70b-versatile"},
|
||||
},
|
||||
{
|
||||
provider: "chatgpt",
|
||||
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
|
||||
|
||||
@@ -132,6 +132,8 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
|
||||
return "zai"
|
||||
case "minimaxi-cn":
|
||||
return "minimax"
|
||||
case "fireworks":
|
||||
return "fireworks-ai"
|
||||
default:
|
||||
return strings.TrimSpace(provider.ID)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
||||
"ollama-cloud": "ollama-cloud",
|
||||
"zai-cn": "zai",
|
||||
"minimaxi-cn": "minimax",
|
||||
"fireworks": "fireworks-ai",
|
||||
}
|
||||
for zeroID, want := range tests {
|
||||
provider, ok := providercatalog.Get(zeroID)
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) {
|
||||
if !detected[0].Reachable {
|
||||
t.Fatalf("runtime should be reachable: %#v", detected[0])
|
||||
}
|
||||
if detected[0].Models == nil || len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
|
||||
if len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
|
||||
t.Fatalf("expected discovered model list, got %#v", detected[0].Models)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ type TokenResolver func(ctx context.Context, forceRefresh bool) (header string,
|
||||
//
|
||||
// base carries the API-key auth config (key + default header/scheme). setExtra
|
||||
// (optional) sets the provider's non-auth headers on every attempt. Transient
|
||||
// retries (429/503/529) are handled by the inner SendWithRetry.
|
||||
// retries are handled by the inner SendWithRetry: 429/503/529 on their own
|
||||
// budget, and provably pre-send transport failures (refused or timed-out
|
||||
// connect, DNS, TLS handshake) on a separate, shorter one.
|
||||
func SendWithAuthRetry(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
|
||||
@@ -3,6 +3,9 @@ package providerio
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,13 +20,22 @@ import (
|
||||
// (previously only the OpenAI provider retried; Anthropic and Gemini surfaced the
|
||||
// first failure).
|
||||
//
|
||||
// What is retried: ONLY 429 (rate limit) and 503 (service unavailable) — the
|
||||
// statuses where the server explicitly did NOT accept the request, so replaying
|
||||
// it cannot duplicate work. Other 5xx (500/502/504) and transport/network errors
|
||||
// are NOT retried: a completion POST is non-idempotent and may have reached and
|
||||
// been processed by the server, so replaying it could duplicate
|
||||
// (billable) work. Only the INITIAL request is ever in scope; once the response
|
||||
// body starts streaming it is never re-issued.
|
||||
// What is retried: 429 (rate limit) and 503 (service unavailable) — the statuses
|
||||
// where the server explicitly did NOT accept the request — plus PROVABLY
|
||||
// pre-send transport failures (DNS resolution, a refused/unreachable dial, a TLS
|
||||
// handshake timeout) where no request bytes ever left this host. In every one of
|
||||
// those cases we KNOW the server did not receive and process the request, so a
|
||||
// replay cannot duplicate work. Other 5xx (500/502/504) and every ambiguous or
|
||||
// post-send transport error (connection reset, broken pipe, EOF, a generic i/o
|
||||
// timeout, context deadline) are NOT retried: a completion POST is
|
||||
// non-idempotent and may already have reached and been processed by the server,
|
||||
// so replaying it could duplicate (billable) work. Only the INITIAL request is
|
||||
// ever in scope; once the response body starts streaming it is never re-issued.
|
||||
// Pre-send classification is by syscall errno (portable across POSIX and
|
||||
// Windows), scoped to an Op=="dial" *net.OpError so the same errno raised on a
|
||||
// post-send read/write can't be misread as pre-send. NXDOMAIN is treated as
|
||||
// permanent (not retried), and the pre-send path fails fast on its own
|
||||
// sub-second schedule rather than the seconds-long 429 backoff.
|
||||
|
||||
const defaultMaxRetryAttempts = 6
|
||||
|
||||
@@ -37,12 +49,32 @@ const maxBackoff = 30 * time.Second
|
||||
// schedule is 2s, 4s, 8s, 16s, then maxBackoff. A var so tests can shrink it.
|
||||
var retryBackoffBase = 2 * time.Second
|
||||
|
||||
// preSendMaxAttempts bounds retries of a provably pre-send transport failure.
|
||||
// Unlike a 429 (whose window is measured in seconds), a refused/unreachable dial
|
||||
// either recovers within a few hundred ms or is a deterministic misconfiguration
|
||||
// (wrong host/port, dead local daemon) that will never recover — so a short,
|
||||
// low-count schedule fails fast instead of stalling the agent ~60s on the
|
||||
// rate-limit schedule. This bounds the pre-send path independently of the caller's
|
||||
// maxAttempts, which is tuned for 429/503.
|
||||
const preSendMaxAttempts = 3
|
||||
|
||||
// preSendBackoffBase is the first wait before replaying a pre-send transport
|
||||
// failure. Modeled on the mid-stream reconnect base (internal/agent/reconnect.go),
|
||||
// it is sub-second so a transient dial blip recovers quickly and a permanent one
|
||||
// fails fast. A var so tests can shrink it.
|
||||
var preSendBackoffBase = 500 * time.Millisecond
|
||||
|
||||
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
|
||||
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
|
||||
// between tries and honoring a server Retry-After header and context
|
||||
// cancellation. Other 5xx and transport/network errors are returned immediately,
|
||||
// never replayed (see the package note). The request is rebuilt from body each
|
||||
// attempt; setHeader (if non-nil) sets headers on every attempt.
|
||||
// cancellation. The one transport-error exception is a PROVABLY pre-send failure
|
||||
// (see isPreSendTransportError): a refused/unreachable dial or DNS failure where
|
||||
// no request bytes left this host, retried on its own short schedule
|
||||
// (preSendMaxAttempts / preSendBackoff). Every other 5xx and every ambiguous or
|
||||
// post-send transport error is returned immediately, never replayed, because a
|
||||
// non-idempotent completion POST may already have been received. The request is
|
||||
// rebuilt from body each attempt; setHeader (if non-nil) sets headers on every
|
||||
// attempt.
|
||||
//
|
||||
// It returns the final *http.Response (which the caller inspects for a non-2xx
|
||||
// status, exactly as before) or an error for a network failure / context
|
||||
@@ -60,7 +92,47 @@ func SendWithRetry(
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = defaultMaxRetryAttempts
|
||||
}
|
||||
for attempt := 1; ; attempt++ {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
// Redirects are still FOLLOWED, so a redirecting gateway keeps working, but
|
||||
// entering one makes that attempt non-retryable. The hazard is that client.Do
|
||||
// can transmit the original POST to the first host, follow a 307/308, and then
|
||||
// fail the dial to the redirect target: that error is an Op=="dial" error, so
|
||||
// isPreSendTransportError would read it as "nothing was sent" and replay a
|
||||
// completion the first host already received. Observing the redirect rather
|
||||
// than blocking it keeps both properties, since a request that was redirected
|
||||
// is never replayed while redirecting endpoints stay usable. The caller's own
|
||||
// CheckRedirect still runs and still decides whether to proceed.
|
||||
redirected := false
|
||||
callerCheckRedirect := client.CheckRedirect
|
||||
redirectAwareClient := *client
|
||||
redirectAwareClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||
redirected = true
|
||||
if callerCheckRedirect != nil {
|
||||
return callerCheckRedirect(request, via)
|
||||
}
|
||||
// Mirror net/http's default policy, which a nil CheckRedirect would have
|
||||
// applied: stop after 10 hops.
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// The two retry budgets are fully independent, each with its own counter and
|
||||
// its own backoff ordinal, because they answer different questions: a
|
||||
// dial/DNS/TLS failure is a fast local blip (preSendMaxAttempts, sub-second
|
||||
// schedule) while a 429/503 is a server-side window measured in seconds
|
||||
// (maxAttempts, retryBackoffBase). A single shared loop counter coupled them
|
||||
// in both directions: status retries starved the pre-send budget, and
|
||||
// pre-send retries shortened the status window and started its backoff at a
|
||||
// later rung. Neither kind of failure now consumes or advances the other.
|
||||
preSendAttempts := 0
|
||||
statusAttempts := 0
|
||||
for {
|
||||
// Per attempt: only a redirect entered by THIS attempt can make its own
|
||||
// transport error unsafe to replay.
|
||||
redirected = false
|
||||
request, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,32 +142,78 @@ func SendWithRetry(
|
||||
}
|
||||
|
||||
connectSpan := trace.FromContext(ctx).Span(trace.SpanProviderConnect)
|
||||
response, err := client.Do(request)
|
||||
response, err := redirectAwareClient.Do(request)
|
||||
connectSpan.End()
|
||||
if err != nil {
|
||||
// A transport failure on a POST does NOT mean the server didn't receive
|
||||
// it — the request may have arrived and be generating a (billable,
|
||||
// non-idempotent) completion while only the response/connection failed.
|
||||
// Replaying it could duplicate that work, so surface the error instead
|
||||
// of retrying. Only 429/503 responses (below), where we KNOW the request
|
||||
// was not accepted, are safe to retry.
|
||||
// Context cancellation always surfaces as cancellation, never a retry.
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
// A transport failure on a POST does NOT usually mean the server didn't
|
||||
// receive it — the request may have arrived and be generating a
|
||||
// (billable, non-idempotent) completion while only the response or
|
||||
// connection failed. Replaying that could duplicate work, so it is
|
||||
// surfaced immediately. The one safe exception is a PROVABLY pre-send
|
||||
// failure (see isPreSendTransportError): the request never left this
|
||||
// host, so a replay cannot duplicate anything — exactly like a 429/503
|
||||
// where we KNOW the request was not accepted. The pre-send path uses
|
||||
// its own short bound and sub-second schedule (preSendMaxAttempts /
|
||||
// preSendBackoff), not the seconds-long 429 schedule, so a permanent
|
||||
// dial failure fails fast instead of stalling the agent for ~60s.
|
||||
// A redirect means the original POST already left this host, so the
|
||||
// dial that failed belongs to a later hop and replaying would duplicate
|
||||
// a completion the first host may already have processed.
|
||||
if isPreSendTransportError(err) && !redirected {
|
||||
preSendAttempts++
|
||||
if preSendAttempts < preSendMaxAttempts {
|
||||
if r := trace.FromContext(ctx); r != nil {
|
||||
r.Counter(trace.CounterRetryCount, 1)
|
||||
}
|
||||
if preSendBackoff(ctx, preSendAttempts) {
|
||||
continue
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ShouldRetryStatus(response.StatusCode) && attempt < maxAttempts {
|
||||
if r := trace.FromContext(ctx); r != nil {
|
||||
r.Counter(trace.CounterRetryCount, 1)
|
||||
// Deliberately NOT guarded on `redirected`, unlike the transport-error
|
||||
// branch above. The two differ in what is known about the request. A
|
||||
// transport failure after a redirect leaves it unknown whether the
|
||||
// redirect target received and began processing the POST, so replaying
|
||||
// could duplicate billable work. Here every hop answered: a 307/308 is the
|
||||
// origin declining and delegating, and a retryable status is the target
|
||||
// declining as well, so nothing accepted the request and a replay
|
||||
// duplicates nothing.
|
||||
//
|
||||
// The exposure is also identical to a retryable status with no redirect in
|
||||
// play, which this package has always retried. Guarding on redirects here
|
||||
// would not close a gap, it would make a rate-limited completion behind a
|
||||
// redirecting gateway fail outright instead of backing off.
|
||||
// The transport hop succeeded, so the connect phase is healthy again and
|
||||
// the pre-send budget starts over. It bounds CONSECUTIVE connect failures,
|
||||
// not the lifetime of the call: without this reset, two early dial retries
|
||||
// followed by a 429 and then one more dial blip would surface a transport
|
||||
// error while the status budget still had room, which is the coupling
|
||||
// these two counters were split apart to remove. Total work stays bounded,
|
||||
// since every reset costs a status attempt and those are capped.
|
||||
preSendAttempts = 0
|
||||
|
||||
if ShouldRetryStatus(response.StatusCode) {
|
||||
statusAttempts++
|
||||
if statusAttempts < maxAttempts {
|
||||
if r := trace.FromContext(ctx); r != nil {
|
||||
r.Counter(trace.CounterRetryCount, 1)
|
||||
}
|
||||
wait := RetryAfter(response)
|
||||
_ = response.Body.Close()
|
||||
if Backoff(ctx, statusAttempts, wait) {
|
||||
continue
|
||||
}
|
||||
// Backoff aborted: the only reason it returns false is ctx cancellation.
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
wait := RetryAfter(response)
|
||||
_ = response.Body.Close()
|
||||
if Backoff(ctx, attempt, wait) {
|
||||
continue
|
||||
}
|
||||
// Backoff aborted: the only reason it returns false is ctx cancellation.
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Success, a non-retryable status, or retries exhausted on a retryable
|
||||
@@ -109,6 +227,119 @@ func SendWithRetry(
|
||||
}
|
||||
}
|
||||
|
||||
// isPreSendTransportError reports whether a transport error PROVES no request
|
||||
// bytes reached the server, so replaying the request cannot duplicate a
|
||||
// billable completion. Only narrow, unambiguous pre-connect failures qualify: a
|
||||
// recoverable DNS lookup failure, a refused/unreachable dial, and a TLS
|
||||
// handshake timeout (the handshake completes before any HTTP request bytes are
|
||||
// written).
|
||||
//
|
||||
// The refused/unreachable errnos (ECONNREFUSED/ENETUNREACH/EHOSTUNREACH) are
|
||||
// matched by syscall errno via errors.Is — portable across platforms, since Go
|
||||
// maps the WSA* codes on Windows too — but ONLY when carried by an Op=="dial"
|
||||
// *net.OpError. errors.Is matches an errno anywhere in the chain regardless of
|
||||
// which socket operation raised it, and the kernel raises these same errnos on
|
||||
// an established connection when a route drops mid-generation; scoping to the
|
||||
// dial phase is what makes "no request bytes were sent" structural rather than
|
||||
// argued, so a post-send read/write can never replay a non-idempotent POST.
|
||||
// NXDOMAIN is treated as permanent (deterministic) and is NOT retried.
|
||||
//
|
||||
// Ambiguous or post-send failures are deliberately excluded and checked FIRST,
|
||||
// so an error that is both is never treated as pre-send: a reset or broken pipe
|
||||
// can follow a request that was already sent, and a bare "i/o timeout" covers
|
||||
// read timeouts after send as well as dial timeouts, so none of these prove the
|
||||
// request was not received and a non-idempotent POST must not be replayed on
|
||||
// them.
|
||||
func isPreSendTransportError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// DNS resolution precedes any connection, so a lookup failure proves nothing
|
||||
// was sent — but only retry a lookup that could plausibly succeed on a replay.
|
||||
// NXDOMAIN (IsNotFound) is authoritative and deterministic (a mistyped or
|
||||
// non-existent host), so retrying it just stalls the agent through the whole
|
||||
// backoff schedule before failing anyway; a timeout or temporary SERVFAIL is
|
||||
// worth another try. errors.As unwraps url.Error/OpError to reach the DNSError.
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return !dnsErr.IsNotFound
|
||||
}
|
||||
// Post-send / ambiguous failures, excluded first. EOF and reset are matched
|
||||
// by identity so wording (or a hostname containing "eof") can't fool them.
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || isConnResetErrno(err) {
|
||||
return false
|
||||
}
|
||||
// A CONNECT-phase timeout is provably pre-send for the same structural reason
|
||||
// a refused dial is: the connection was never established, so no request
|
||||
// bytes left this host. It has to be admitted BEFORE the wording exclusions
|
||||
// below, because net surfaces it with "i/o timeout" in the message, which the
|
||||
// blanket exclusion would otherwise reject. The gate is what keeps that safe:
|
||||
// a post-send read timeout is also Timeout(), but carries Op "read", so it
|
||||
// never reaches here.
|
||||
if opErr := connectOpError(err); opErr != nil && opErr.Timeout() {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
if strings.Contains(msg, "connection reset") ||
|
||||
strings.Contains(msg, "broken pipe") ||
|
||||
strings.Contains(msg, "i/o timeout") {
|
||||
return false
|
||||
}
|
||||
// A refused/unreachable connection is provably pre-send ONLY when it was
|
||||
// raised during the DIAL (connection-establishment) phase. The same errnos
|
||||
// are also raised by the kernel on an ESTABLISHED connection — when a route
|
||||
// drops or an ICMP unreachable arrives mid-generation — which is post-send.
|
||||
// errors.Is matches an errno anywhere in the chain regardless of the
|
||||
// operation, so we first require an Op=="dial" *net.OpError: that makes "no
|
||||
// request bytes left this host" structural, so a post-send read/write carrying
|
||||
// one of these can never replay a non-idempotent POST. dialPreSendErrnos is
|
||||
// platform-specific: on Windows a refused dial carries the raw WSA* errno
|
||||
// (WSAECONNREFUSED etc.), which does NOT satisfy errors.Is against the POSIX
|
||||
// syscall.ECONNREFUSED, so the Windows list adds those codes. The string
|
||||
// markers only catch a POSIX dial error already flattened past its errno.
|
||||
if connectOpError(err) != nil {
|
||||
for _, errno := range dialPreSendErrnos {
|
||||
if errors.Is(err, errno) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(msg, "connection refused"),
|
||||
strings.Contains(msg, "network is unreachable"),
|
||||
strings.Contains(msg, "no route to host"):
|
||||
return true
|
||||
}
|
||||
}
|
||||
// A TLS handshake times out after the TCP dial connects but before any HTTP
|
||||
// request bytes are written, so it is structurally pre-send. net/http surfaces
|
||||
// it as its own error string, not a *net.OpError, so it is matched by wording
|
||||
// independent of the dial gate.
|
||||
if strings.Contains(msg, "tls handshake timeout") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// connectOpError returns the outermost *net.OpError in err's chain when it comes
|
||||
// from the connection-establishment phase, else nil. Go tags a direct dial with
|
||||
// Op "dial" and a failed CONNECT through an HTTP proxy with Op "proxyconnect";
|
||||
// a failure on an already-established connection carries Op "read"/"write".
|
||||
// This is how isPreSendTransportError scopes the pre-send match to the connect
|
||||
// phase so a post-send failure can't be misclassified as pre-send.
|
||||
//
|
||||
// "proxyconnect" belongs here for the same reason "dial" does: when HTTPS_PROXY
|
||||
// is set and the CONNECT fails, the tunnel to the model host was never built, so
|
||||
// no request bytes reached it. Omitting it meant every pre-send failure behind a
|
||||
// corporate proxy fell through to no-retry, which is the environment where
|
||||
// transient connect failures are most common.
|
||||
func connectOpError(err error) *net.OpError {
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) && (opErr.Op == "dial" || opErr.Op == "proxyconnect") {
|
||||
return opErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShouldRetryStatus reports whether an HTTP status is safe to retry for a
|
||||
// non-idempotent completion POST: 429 (Too Many Requests), 503 (Service
|
||||
// Unavailable), and 529 (Anthropic's "overloaded"). All mean the server
|
||||
@@ -137,6 +368,39 @@ func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// preSendBackoff waits before pre-send retry attempt N (1-based) on a short
|
||||
// sub-second schedule (500ms, 1s, ...), returning false if the context is
|
||||
// cancelled during the wait. Separate from Backoff's seconds-long rate-limit
|
||||
// schedule because a pre-send dial failure recovers in milliseconds or never.
|
||||
func preSendBackoff(ctx context.Context, attempt int) bool {
|
||||
timer := time.NewTimer(preSendBackoffWait(attempt))
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// preSendBackoffWait computes the pre-send wait before retry attempt N (1-based):
|
||||
// exponential from preSendBackoffBase (500ms, 1s, 2s, ...), capped at maxBackoff.
|
||||
// The exponent is clamped so a large attempt count cannot overflow.
|
||||
func preSendBackoffWait(attempt int) time.Duration {
|
||||
exponent := attempt - 1
|
||||
if exponent > 5 {
|
||||
exponent = 5
|
||||
}
|
||||
if exponent < 0 {
|
||||
exponent = 0
|
||||
}
|
||||
wait := preSendBackoffBase * time.Duration(1<<exponent)
|
||||
if wait > maxBackoff {
|
||||
wait = maxBackoff
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// backoffWait computes the wait before retry attempt N (1-based): Retry-After
|
||||
// when supplied, else exponential from retryBackoffBase, both capped at
|
||||
// maxBackoff. The exponent is clamped so a large attempt count cannot overflow.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build !windows && !plan9
|
||||
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// dialPreSendErrnos are the syscall errnos a refused or unreachable DIAL raises
|
||||
// on this platform. On POSIX the standard syscall constants are exactly what the
|
||||
// net package surfaces, so matching them via errors.Is is sufficient. Plan 9 is
|
||||
// excluded because it does not define these BSD-style errno constants (it uses
|
||||
// string errors), so the constants below would not compile there; Zero targets
|
||||
// linux/darwin/windows, so no Plan 9 variant is needed.
|
||||
var dialPreSendErrnos = []error{
|
||||
syscall.ECONNREFUSED,
|
||||
syscall.ENETUNREACH,
|
||||
syscall.EHOSTUNREACH,
|
||||
// A connect timeout is normally caught by the Timeout() check on the connect
|
||||
// gate; this covers the case where the errno survives but the timeout flag
|
||||
// does not, e.g. an error rebuilt from the errno alone.
|
||||
syscall.ETIMEDOUT,
|
||||
}
|
||||
|
||||
// isConnResetErrno reports whether err carries a connection-reset errno, the
|
||||
// post-send exclusion in isPreSendTransportError. It lives here (per platform)
|
||||
// so retry.go references no syscall constants and stays buildable on Plan 9,
|
||||
// which defines none of them.
|
||||
func isConnResetErrno(err error) bool {
|
||||
return errors.Is(err, syscall.ECONNRESET)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build plan9
|
||||
|
||||
package providerio
|
||||
|
||||
// Plan 9 has no BSD-style errno constants (it uses string errors), so the
|
||||
// errno-based classification is unavailable here: isConnResetErrno returns false
|
||||
// and dialPreSendErrnos is empty. retry.go still classifies via its string
|
||||
// markers on this platform, the pre-send inclusions (connection refused /
|
||||
// network unreachable / no route to host) and the separate post-send
|
||||
// "connection reset" exclusion each keep working through the wording checks.
|
||||
// Zero targets linux/darwin/windows, so this variant only keeps the package
|
||||
// building on Plan 9 rather than adding real support.
|
||||
var dialPreSendErrnos = []error{}
|
||||
|
||||
func isConnResetErrno(error) bool { return false }
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build windows
|
||||
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// dialPreSendErrnos are the errnos a refused or unreachable DIAL raises on
|
||||
// Windows. A real Windows dial failure carries the raw Winsock error
|
||||
// (WSAECONNREFUSED = 10061, WSAENETUNREACH = 10051, WSAEHOSTUNREACH = 10065), and
|
||||
// errors.Is against the POSIX syscall.ECONNREFUSED returns FALSE there because
|
||||
// Go's Windows syscall.ECONNREFUSED is a distinct value the net package never
|
||||
// produces. So the WSA codes must be matched explicitly, or a refused dial would
|
||||
// silently never be retried on Windows. The POSIX constants stay in the list too
|
||||
// so an error built with them (e.g. a test fixture) still classifies.
|
||||
var dialPreSendErrnos = []error{
|
||||
syscall.ECONNREFUSED,
|
||||
syscall.ENETUNREACH,
|
||||
syscall.EHOSTUNREACH,
|
||||
syscall.ETIMEDOUT,
|
||||
windows.WSAECONNREFUSED,
|
||||
windows.WSAENETUNREACH,
|
||||
windows.WSAEHOSTUNREACH,
|
||||
// Same reasoning as the POSIX ETIMEDOUT entry, with the Winsock code a real
|
||||
// Windows connect timeout actually carries.
|
||||
windows.WSAETIMEDOUT,
|
||||
}
|
||||
|
||||
// isConnResetErrno reports whether err carries a connection-reset errno, the
|
||||
// post-send exclusion in isPreSendTransportError. It matches the same
|
||||
// syscall.ECONNRESET the shared path used before the errno classification was
|
||||
// split out per platform, so behavior on Windows is unchanged; it lives here
|
||||
// so retry.go references no syscall constants and stays buildable on Plan 9.
|
||||
func isConnResetErrno(err error) bool {
|
||||
return errors.Is(err, syscall.ECONNRESET)
|
||||
}
|
||||
@@ -1,15 +1,34 @@
|
||||
//go:build !plan9
|
||||
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wrapDialErrno builds the error shape a dial failure has (a *net.OpError
|
||||
// wrapping an *os.SyscallError wrapping the errno) so errors.Is reaches the
|
||||
// errno exactly as in production. These fixtures use the POSIX syscall.Exxx
|
||||
// constants, which do NOT reproduce a real Windows dial: that carries distinct
|
||||
// windows.WSA* errnos (Go's syscall.ECONNREFUSED is a value the net package
|
||||
// never produces on Windows). They still classify on Windows only because
|
||||
// dialPreSendErrnos there also lists the POSIX constants for exactly this
|
||||
// fixture reason; the real Windows dial branch is covered separately by
|
||||
// TestIsPreSendTransportErrorRealRefusedDial, which makes an actual refused dial.
|
||||
func wrapDialErrno(op string, errno syscall.Errno) error {
|
||||
return &net.OpError{Op: op, Net: "tcp", Err: os.NewSyscallError("connectex", errno)}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
@@ -35,6 +54,152 @@ func TestSendWithRetryDoesNotReplayTransportErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A PROVABLY pre-send transport failure (no request bytes left this host) is
|
||||
// safe to replay and must be retried, bounded by preSendMaxAttempts (its own
|
||||
// short schedule), unlike an ambiguous post-send failure. Real dial failures
|
||||
// arrive as an Op=="dial" *net.OpError, so the errno cases are the production
|
||||
// shape on every platform (Windows included); the string case exercises the
|
||||
// wording fallback for a dial error already flattened past its errno.
|
||||
func TestSendWithRetryReplaysProvablyPreSendErrors(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
cases := map[string]error{
|
||||
"errno refused (dial)": wrapDialErrno("dial", syscall.ECONNREFUSED),
|
||||
"errno network unreachable": wrapDialErrno("dial", syscall.ENETUNREACH),
|
||||
"errno host unreachable": wrapDialErrno("dial", syscall.EHOSTUNREACH),
|
||||
"dial string fallback": &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connect: connection refused")},
|
||||
"tls handshake timeout": errors.New("net/http: TLS handshake timeout"),
|
||||
"dns timeout": &net.DNSError{Err: "server misbehaving", Name: "nope.invalid", IsTimeout: true},
|
||||
}
|
||||
for name, transportErr := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var calls int32
|
||||
client := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return nil, transportErr
|
||||
})}
|
||||
// maxAttempts=6 (the default) proves the pre-send path is bounded by
|
||||
// preSendMaxAttempts, not the caller's 429-tuned maxAttempts.
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "http://example.invalid", []byte("{}"), nil, 6)
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected the transport error to surface after retries are exhausted")
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != preSendMaxAttempts {
|
||||
t.Fatalf("pre-send error tried %d times, want %d (preSendMaxAttempts)", got, preSendMaxAttempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An ambiguous transport failure that could have followed a sent request must
|
||||
// NOT be replayed: a non-idempotent completion POST could duplicate billable
|
||||
// work. This is the safety line the fix must not cross.
|
||||
func TestSendWithRetryDoesNotReplayAmbiguousTransportErrors(t *testing.T) {
|
||||
for name, transportErr := range map[string]error{
|
||||
"generic i/o timeout": errors.New("dial tcp 1.2.3.4:443: i/o timeout"),
|
||||
"broken pipe": errors.New("write tcp: broken pipe"),
|
||||
"unexpected eof": io.ErrUnexpectedEOF,
|
||||
// The pre-send errnos are also raised on an ESTABLISHED connection: a route
|
||||
// dropping mid-generation surfaces on the pending read/write, which is
|
||||
// post-send. Scoping to Op=="dial" must keep these from replaying the POST.
|
||||
"host unreachable on read is post-send": wrapDialErrno("read", syscall.EHOSTUNREACH),
|
||||
"net unreachable on write is post-send": wrapDialErrno("write", syscall.ENETUNREACH),
|
||||
// NXDOMAIN is authoritative and deterministic, so retrying it would only
|
||||
// stall the agent before failing anyway.
|
||||
"dns nxdomain": &net.DNSError{Err: "no such host", Name: "nope.invalid", IsNotFound: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var calls int32
|
||||
client := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return nil, transportErr
|
||||
})}
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "http://example.invalid", []byte("{}"), nil, 3)
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected the transport error to surface")
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("ambiguous transport error replayed %d times, want 1 (no retry)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A REAL refused dial (nothing listening on the target) must classify as
|
||||
// pre-send on every platform. This is the regression the errno-constant fixtures
|
||||
// miss: on Windows the kernel raises WSAECONNREFUSED, which errors.Is does NOT
|
||||
// match against syscall.ECONNREFUSED, so before dialPreSendErrnos carried the WSA
|
||||
// codes a refused dial returned false here and Windows never retried it. Using a
|
||||
// real Dial exercises the platform's true error shape.
|
||||
func TestIsPreSendTransportErrorRealRefusedDial(t *testing.T) {
|
||||
// Reserve an ephemeral port, then close it, so a dial there is refused.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
if cerr := ln.Close(); cerr != nil {
|
||||
t.Fatalf("close listener: %v", cerr)
|
||||
}
|
||||
conn, dialErr := (&net.Dialer{Timeout: 2 * time.Second}).Dial("tcp", addr)
|
||||
if dialErr == nil {
|
||||
_ = conn.Close()
|
||||
t.Skip("expected a refused dial but the port accepted a connection")
|
||||
}
|
||||
if !isPreSendTransportError(dialErr) {
|
||||
t.Fatalf("real refused dial not classified pre-send: %v", dialErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPreSendTransportError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
// DNS: a lookup that could recover is pre-send; NXDOMAIN is permanent.
|
||||
{"dns timeout", &net.DNSError{Err: "server misbehaving", Name: "x.invalid", IsTimeout: true}, true},
|
||||
{"dns temporary", &net.DNSError{Err: "server misbehaving", Name: "x.invalid", IsTemporary: true}, true},
|
||||
{"dns nxdomain is permanent", &net.DNSError{Err: "no such host", Name: "x.invalid", IsNotFound: true}, false},
|
||||
{"tls handshake timeout", errors.New("net/http: TLS handshake timeout"), true},
|
||||
// Errno-wrapped DIAL failures: how a real refused/unreachable dial arrives
|
||||
// on EVERY platform, including Windows where the wording differs entirely.
|
||||
{"errno refused (dial)", wrapDialErrno("dial", syscall.ECONNREFUSED), true},
|
||||
{"errno network unreachable (dial)", wrapDialErrno("dial", syscall.ENETUNREACH), true},
|
||||
{"errno host unreachable (dial)", wrapDialErrno("dial", syscall.EHOSTUNREACH), true},
|
||||
{"dial operror string fallback", &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connect: connection refused")}, true},
|
||||
// The SAME errnos raised on a post-send read/write are NOT pre-send — the
|
||||
// kernel reports them on an established connection when a route drops.
|
||||
{"host unreachable on read is post-send", wrapDialErrno("read", syscall.EHOSTUNREACH), false},
|
||||
{"net unreachable on write is post-send", wrapDialErrno("write", syscall.ENETUNREACH), false},
|
||||
{"errno reset is post-send", wrapDialErrno("read", syscall.ECONNRESET), false},
|
||||
// A refused/unreachable error already flattened past its *net.OpError can't
|
||||
// be proven pre-send, so it is NOT retried (conservative direction).
|
||||
{"flattened refused, no opError", errors.New("dial tcp 127.0.0.1:1: connect: connection refused"), false},
|
||||
{"connection reset", errors.New("read tcp: connection reset by peer"), false},
|
||||
{"broken pipe", errors.New("write tcp: broken pipe"), false},
|
||||
{"unexpected eof", io.ErrUnexpectedEOF, false},
|
||||
{"eof", io.EOF, false},
|
||||
{"generic io timeout", errors.New("dial tcp: i/o timeout"), false},
|
||||
{"context deadline", context.DeadlineExceeded, false},
|
||||
// Exclusion is checked before inclusion, even for a dial OpError: an "i/o
|
||||
// timeout" in the message wins over the refused wording.
|
||||
{"exclusion wins over inclusion", &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused / i/o timeout")}, false},
|
||||
{"unrelated", errors.New("some other error"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isPreSendTransportError(c.err); got != c.want {
|
||||
t.Errorf("%s: isPreSendTransportError(%v) = %v, want %v", c.name, c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetryStatus(t *testing.T) {
|
||||
cases := map[int]bool{
|
||||
http.StatusOK: false,
|
||||
@@ -94,12 +259,17 @@ func TestBackoffWaitsThenReturnsTrue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// shrinkBackoff makes retry waits negligible for the duration of a test.
|
||||
// shrinkBackoff makes both retry schedules (429 and pre-send) negligible for the
|
||||
// duration of a test.
|
||||
func shrinkBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
saved := retryBackoffBase
|
||||
savedRetry, savedPreSend := retryBackoffBase, preSendBackoffBase
|
||||
retryBackoffBase = time.Millisecond
|
||||
t.Cleanup(func() { retryBackoffBase = saved })
|
||||
preSendBackoffBase = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
retryBackoffBase = savedRetry
|
||||
preSendBackoffBase = savedPreSend
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackoffWaitSchedule(t *testing.T) {
|
||||
@@ -126,6 +296,30 @@ func TestBackoffWaitSchedule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The pre-send schedule is sub-second and doubles, far shorter than the 429
|
||||
// schedule: a permanent dial failure fails in ~1.5s across preSendMaxAttempts
|
||||
// (500ms + 1s) instead of stalling the agent ~60s on 2/4/8/16/30s. This is the
|
||||
// second half of the fix for a mistyped host / dead local daemon hanging a turn.
|
||||
func TestPreSendBackoffWaitSchedule(t *testing.T) {
|
||||
cases := []struct {
|
||||
attempt int
|
||||
want time.Duration
|
||||
}{
|
||||
{1, 500 * time.Millisecond},
|
||||
{2, 1 * time.Second},
|
||||
{3, 2 * time.Second},
|
||||
// The exponent clamps at 5 (500ms<<5 = 16s) so a large attempt count can't
|
||||
// overflow; in practice preSendMaxAttempts caps retries at attempt 2, so
|
||||
// only the 500ms/1s rungs are ever reached.
|
||||
{50, 16 * time.Second},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := preSendBackoffWait(c.attempt); got != c.want {
|
||||
t.Errorf("preSendBackoffWait(%d) = %v, want %v", c.attempt, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithRetryRetriesThenSucceeds(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var hits int32
|
||||
@@ -193,3 +387,316 @@ func TestSendWithRetryReturnsLastResponseAfterMaxAttempts(t *testing.T) {
|
||||
t.Fatalf("server hit %d times, want 2 (maxAttempts)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Redirects are still followed, so a gateway that answers the completion endpoint
|
||||
// with a 307/308 keeps working: the caller gets the final response, not the 3xx.
|
||||
func TestSendWithRetryFollowsRedirects(t *testing.T) {
|
||||
var calls int32
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTemporaryRedirect,
|
||||
Header: http.Header{"Location": {"https://redirect-target.invalid/v1"}},
|
||||
Body: http.NoBody,
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: r}, nil
|
||||
})}
|
||||
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "https://origin.invalid/v1", []byte("{}"), nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("a redirected completion must succeed: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (the redirect should have been followed)", resp.StatusCode)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("transport called %d times, want 2 (origin then redirect target)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Once an attempt has entered a redirect, the original POST has already left this
|
||||
// host, so a dial failure on the redirect hop must NOT be replayed even though it
|
||||
// arrives as an Op=="dial" error that isPreSendTransportError would otherwise
|
||||
// treat as provably pre-send. Replaying it would re-bill a completion the first
|
||||
// host may already have processed.
|
||||
func TestSendWithRetryDoesNotReplayAfterRedirect(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var calls int32
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTemporaryRedirect,
|
||||
Header: http.Header{"Location": {"https://redirect-target.invalid/v1"}},
|
||||
Body: http.NoBody,
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
// The dial to the redirect target fails: pre-send in shape, but post-send
|
||||
// in fact, because the origin already received the POST.
|
||||
return nil, wrapDialErrno("dial", syscall.ECONNREFUSED)
|
||||
})}
|
||||
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "https://origin.invalid/v1", []byte("{}"), nil, 6)
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected the redirect-hop dial failure to surface as an error")
|
||||
}
|
||||
// Exactly two: the origin request and the single redirect hop. A third would
|
||||
// mean the redirected POST was replayed.
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("redirected POST was replayed: transport called %d times, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller that supplies its own CheckRedirect keeps it: the retry path observes
|
||||
// redirects, it does not take the decision away from the caller.
|
||||
func TestSendWithRetryPreservesCallerRedirectPolicy(t *testing.T) {
|
||||
var policyCalls int32
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
atomic.AddInt32(&policyCalls, 1)
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTemporaryRedirect,
|
||||
Header: http.Header{"Location": {"https://redirect-target.invalid/v1"}},
|
||||
Body: http.NoBody,
|
||||
Request: r,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "https://origin.invalid/v1", []byte("{}"), nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("caller policy asked to stop at the 3xx, so it must surface: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("status = %d, want the 307 surfaced per the caller's policy", resp.StatusCode)
|
||||
}
|
||||
if got := atomic.LoadInt32(&policyCalls); got != 1 {
|
||||
t.Fatalf("caller CheckRedirect called %d times, want 1 (it must not be discarded)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The pre-send retry budget is independent of the 429/503 status retries: two
|
||||
// rate-limit responses must not consume the pre-send allowance, so the first
|
||||
// refused dial after them still gets its own preSendMaxAttempts tries.
|
||||
func TestSendWithRetryPreSendBudgetSurvivesStatusRetries(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var calls int32
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if atomic.AddInt32(&calls, 1) <= 2 {
|
||||
return &http.Response{StatusCode: http.StatusTooManyRequests, Body: http.NoBody, Request: r}, nil
|
||||
}
|
||||
return nil, wrapDialErrno("dial", syscall.ECONNREFUSED)
|
||||
})}
|
||||
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "https://x.invalid/v1", []byte("{}"), nil, 6)
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected the exhausted pre-send failure to surface as an error")
|
||||
}
|
||||
// Two status responses (calls 1-2), then the pre-send failure gets its own
|
||||
// budget: preSendMaxAttempts=3 means it retries twice more before returning.
|
||||
if got := atomic.LoadInt32(&calls); got != 2+preSendMaxAttempts {
|
||||
t.Fatalf("pre-send budget eaten by status retries: transport called %d times, want %d (2 status + %d pre-send)", got, 2+preSendMaxAttempts, preSendMaxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// The inverse sequence: pre-send retries must not consume the 429/503 budget or
|
||||
// advance its backoff rung either. Two safely replayed refused dials followed by
|
||||
// rate limiting must still leave the status path its full maxAttempts window,
|
||||
// otherwise a couple of dial blips silently shorten every later rate-limit wait.
|
||||
func TestSendWithRetryStatusBudgetSurvivesPreSendRetries(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
const maxAttempts = 3
|
||||
var calls int32
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if atomic.AddInt32(&calls, 1) <= 2 {
|
||||
return nil, wrapDialErrno("dial", syscall.ECONNREFUSED)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusTooManyRequests, Body: http.NoBody, Request: r}, nil
|
||||
})}
|
||||
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "https://x.invalid/v1", []byte("{}"), nil, maxAttempts)
|
||||
if err != nil {
|
||||
t.Fatalf("exhausted status retries surface the response, not an error: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want 429", resp.StatusCode)
|
||||
}
|
||||
// Calls 1-2 are the retried dial failures; the status path then gets its own
|
||||
// full window (maxAttempts requests) before the 429 is surfaced.
|
||||
if got := atomic.LoadInt32(&calls); got != 2+maxAttempts {
|
||||
t.Fatalf("status budget eaten by pre-send retries: transport called %d times, want %d (2 pre-send + %d status)", got, 2+maxAttempts, maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// A redirect followed by a RETRYABLE STATUS is still retried, and deliberately
|
||||
// so. This is the case a reviewer flagged as a possible replay of billable work,
|
||||
// and the asymmetry with the transport-error guard above is intentional rather
|
||||
// than an oversight, so it is pinned here.
|
||||
//
|
||||
// The two situations differ in what is known about the request:
|
||||
//
|
||||
// - Transport error after a redirect: the original POST reached the origin and
|
||||
// the dial to the redirect target then failed, so whether the target received
|
||||
// and began processing it is UNKNOWN. Replaying could duplicate work, which
|
||||
// is why that path refuses to retry.
|
||||
// - Retryable status after a redirect: every hop answered. A 307/308 is the
|
||||
// origin declining to process and delegating, and a 429/503/529 is the target
|
||||
// declining as well. Nothing accepted the request, so replaying duplicates
|
||||
// nothing.
|
||||
//
|
||||
// The risk here is also exactly the risk of a 429 without any redirect, which
|
||||
// this package has always retried; the redirect adds no new ambiguity. Refusing
|
||||
// to retry would instead make a rate-limited completion behind a redirecting
|
||||
// gateway fail outright rather than back off.
|
||||
func TestSendWithRetryRetriesRedirectedRetryableStatus(t *testing.T) {
|
||||
var calls int32
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch atomic.AddInt32(&calls, 1) {
|
||||
case 1, 3:
|
||||
// The origin declines and delegates, both times.
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTemporaryRedirect,
|
||||
Header: http.Header{"Location": {"https://redirect-target.invalid/v1"}},
|
||||
Body: http.NoBody,
|
||||
Request: r,
|
||||
}, nil
|
||||
case 2:
|
||||
// The target declines too: rate limited, so nothing was accepted.
|
||||
return &http.Response{StatusCode: http.StatusTooManyRequests, Body: http.NoBody, Request: r}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: r}, nil
|
||||
}
|
||||
})}
|
||||
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "https://origin.invalid/v1", []byte("{}"), nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("a rate-limited completion behind a redirect must still retry: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 after the retry succeeded", resp.StatusCode)
|
||||
}
|
||||
// origin, target(429), origin, target(200).
|
||||
if got := atomic.LoadInt32(&calls); got != 4 {
|
||||
t.Fatalf("transport called %d times, want 4 (redirect, 429, redirect, 200)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A CONNECT-phase timeout is provably pre-send: the connection was never
|
||||
// established, so no request bytes left this host. It reaches the classifier
|
||||
// carrying "i/o timeout", which the blanket wording exclusion rejects, so
|
||||
// admitting it depends on the connect gate running first.
|
||||
//
|
||||
// The post-send counterpart is the point of the table: a read timeout is also
|
||||
// Timeout(), and must stay excluded, or a reply that timed out mid-generation
|
||||
// would replay a completion the server already billed.
|
||||
func TestPreSendClassifiesConnectTimeouts(t *testing.T) {
|
||||
timeoutErr := &timeoutError{}
|
||||
for name, testCase := range map[string]struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
"dial timeout": {
|
||||
err: &net.OpError{Op: "dial", Net: "tcp", Err: timeoutErr},
|
||||
want: true,
|
||||
},
|
||||
"proxy connect timeout": {
|
||||
err: &net.OpError{Op: "proxyconnect", Net: "tcp", Err: timeoutErr},
|
||||
want: true,
|
||||
},
|
||||
"read timeout stays post-send": {
|
||||
err: &net.OpError{Op: "read", Net: "tcp", Err: timeoutErr},
|
||||
want: false,
|
||||
},
|
||||
"write timeout stays post-send": {
|
||||
err: &net.OpError{Op: "write", Net: "tcp", Err: timeoutErr},
|
||||
want: false,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := isPreSendTransportError(testCase.err); got != testCase.want {
|
||||
t.Fatalf("isPreSendTransportError(%v) = %v, want %v", testCase.err, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A failed CONNECT through an HTTP proxy is pre-send for the same structural
|
||||
// reason a refused dial is, but net tags it Op "proxyconnect", so a gate
|
||||
// matching only "dial" left every pre-send failure behind a corporate proxy
|
||||
// unretried.
|
||||
func TestPreSendClassifiesProxyConnectRefusal(t *testing.T) {
|
||||
err := &net.OpError{Op: "proxyconnect", Net: "tcp", Err: syscall.ECONNREFUSED}
|
||||
if !isPreSendTransportError(err) {
|
||||
t.Fatal("a refused proxy CONNECT was not classified pre-send; no request bytes reached the model host")
|
||||
}
|
||||
// The op still has to be a connect phase. The same errno on an established
|
||||
// connection is post-send and must not replay a POST.
|
||||
post := &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNREFUSED}
|
||||
if isPreSendTransportError(post) {
|
||||
t.Fatal("a read-phase error was classified pre-send")
|
||||
}
|
||||
}
|
||||
|
||||
// timeoutError is a net.Error whose Timeout() is true, matching the shape
|
||||
// net/http surfaces for a connect deadline.
|
||||
type timeoutError struct{}
|
||||
|
||||
func (timeoutError) Error() string { return "i/o timeout" }
|
||||
func (timeoutError) Timeout() bool { return true }
|
||||
func (timeoutError) Temporary() bool { return true }
|
||||
|
||||
// The pre-send budget bounds CONSECUTIVE connect failures, not the lifetime of
|
||||
// the call. Without a reset on a successful hop, an early run of dial blips
|
||||
// permanently spends the budget, so a later blip surfaces as a transport error
|
||||
// while the status budget still has room. That is the coupling the two counters
|
||||
// were split apart to remove, in the one ordering the earlier tests did not
|
||||
// cover: pre-send, then status, then pre-send again.
|
||||
func TestPreSendBudgetResetsAfterSuccessfulHop(t *testing.T) {
|
||||
refused := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}
|
||||
var calls int32
|
||||
client := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch atomic.AddInt32(&calls, 1) {
|
||||
case 1, 2:
|
||||
// Two connect failures, spending the pre-send budget under the old
|
||||
// lifetime accounting.
|
||||
return nil, refused
|
||||
case 3:
|
||||
// A hop that reaches the server. It declines, so the request was not
|
||||
// accepted and retrying stays safe.
|
||||
return &http.Response{StatusCode: http.StatusTooManyRequests, Body: http.NoBody, Request: r}, nil
|
||||
case 4:
|
||||
// One more connect blip. With a lifetime cap this surfaces as an
|
||||
// error; the call should still recover.
|
||||
return nil, refused
|
||||
default:
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: r}, nil
|
||||
}
|
||||
})}
|
||||
|
||||
resp, err := SendWithRetry(context.Background(), client, http.MethodPost, "https://origin.invalid/v1", []byte("{}"), nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("interleaved connect and status failures returned %v; the pre-send budget did not reset after the hop that reached the server", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 5 {
|
||||
t.Fatalf("transport called %d times, want 5 (dial, dial, 429, dial, 200)", got)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func writePromptLine(builder *strings.Builder, format string, args ...any) {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf(format, args...))
|
||||
fmt.Fprintf(builder, format, args...)
|
||||
}
|
||||
|
||||
func clampBudget(text string, budget int) string {
|
||||
|
||||
@@ -225,7 +225,7 @@ func (backend Backend) SandboxEnvMarkers(policy Policy) []string {
|
||||
if policy.Mode == ModeDisabled {
|
||||
return nil
|
||||
}
|
||||
if !(backend.CommandWrapping && backend.Available) && backend.Name != BackendWSL {
|
||||
if (!backend.CommandWrapping || !backend.Available) && backend.Name != BackendWSL {
|
||||
return nil
|
||||
}
|
||||
name := backend.Name
|
||||
|
||||
@@ -27,11 +27,22 @@ var destructivePrograms = map[string]bool{
|
||||
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
|
||||
}
|
||||
|
||||
var powerShellRemoveItemPrograms = map[string]bool{
|
||||
"remove-item": true,
|
||||
"ri": true,
|
||||
"rd": true,
|
||||
"rmdir": true,
|
||||
"del": true,
|
||||
"erase": true,
|
||||
"rm": true,
|
||||
}
|
||||
|
||||
// networkPrograms are commands that perform network egress/ingress.
|
||||
var networkPrograms = map[string]bool{
|
||||
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
|
||||
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
|
||||
"ftp": true,
|
||||
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
|
||||
"invoke-restmethod": true,
|
||||
}
|
||||
|
||||
var localServerPrograms = map[string]bool{
|
||||
@@ -172,7 +183,10 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
|
||||
if commandUsesNetwork(prog, rest) {
|
||||
result.Network = true
|
||||
}
|
||||
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
|
||||
if destructivePrograms[prog] ||
|
||||
(prog == "rm" && hasRecursiveForce(rest)) ||
|
||||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
|
||||
(prog == "find" && hasFindDelete(rest)) {
|
||||
result.Destructive = true
|
||||
}
|
||||
return true
|
||||
@@ -479,6 +493,19 @@ func hasRecursiveForce(args []*syntax.Word) bool {
|
||||
return recursive && force
|
||||
}
|
||||
|
||||
func hasPowerShellRecursiveForce(args []*syntax.Word) bool {
|
||||
recursive, force := false, false
|
||||
for _, arg := range args {
|
||||
switch strings.ToLower(wordText(arg)) {
|
||||
case "-recurse", "-r":
|
||||
recursive = true
|
||||
case "-force":
|
||||
force = true
|
||||
}
|
||||
}
|
||||
return recursive && force
|
||||
}
|
||||
|
||||
func hasFindDelete(args []*syntax.Word) bool {
|
||||
for _, arg := range args {
|
||||
if wordText(arg) == "-delete" {
|
||||
|
||||
@@ -45,6 +45,27 @@ func TestAnalyzeCommand(t *testing.T) {
|
||||
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
|
||||
|
||||
{name: "curl", script: "curl https://example.com", network: true},
|
||||
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
|
||||
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
|
||||
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
|
||||
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
|
||||
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
|
||||
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
|
||||
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
|
||||
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
|
||||
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
|
||||
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
|
||||
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
|
||||
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
|
||||
{name: "python http server", script: "python3 -m http.server 8000", network: true},
|
||||
{name: "python pip install", script: "python3 -m pip install requests", network: true},
|
||||
|
||||
@@ -153,10 +153,7 @@ func unsafeCommandPrefix(prefix []string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if unsafeCommandPrefixLauncher(prefix[0]) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return unsafeCommandPrefixLauncher(prefix[0])
|
||||
}
|
||||
|
||||
func unsafeCommandPrefixPart(part string) bool {
|
||||
|
||||
@@ -249,7 +249,7 @@ func (engine *Engine) shellSandboxActive(policy Policy) bool {
|
||||
return false
|
||||
}
|
||||
backend := engine.backend
|
||||
if !(backend.Available && backend.Executable != "" && backend.CommandWrapping && backend.NativeIsolation) {
|
||||
if !backend.Available || backend.Executable == "" || !backend.CommandWrapping || !backend.NativeIsolation {
|
||||
return false
|
||||
}
|
||||
// On Windows the command is only actually wrapped once `zero sandbox setup`
|
||||
|
||||
@@ -74,6 +74,25 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineClassifiesPowerShellCurlCommandAsNetwork(t *testing.T) {
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: DefaultPolicy(),
|
||||
})
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "bash",
|
||||
SideEffect: SideEffectShell,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Args: map[string]any{
|
||||
"command": `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`,
|
||||
},
|
||||
})
|
||||
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked || !HasRiskCategory(decision.Risk, "network") {
|
||||
t.Fatalf("PowerShell curl command decision = %#v, want network prompt", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := DefaultPolicy()
|
||||
|
||||
+155
-7
@@ -16,7 +16,11 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
const grantSchemaVersion = 3
|
||||
const (
|
||||
grantSchemaVersion = 3
|
||||
grantLockTimeout = 5 * time.Second
|
||||
grantLockRetry = 10 * time.Millisecond
|
||||
)
|
||||
|
||||
type Grant struct {
|
||||
ToolName string `json:"toolName"`
|
||||
@@ -139,7 +143,40 @@ func (store *GrantStore) ConsumeMigrationNotice() (string, error) {
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
// Look for a pending notice WITHOUT taking the interprocess lock. Acquiring
|
||||
// that lock creates the grants directory and opens <grants>.lockfile, so doing
|
||||
// it unconditionally would make this call require a WRITABLE grants directory
|
||||
// even when it has nothing to write. Every startup and every `zero exec` calls
|
||||
// this, so a user with no grants file whose configured grants path (or its
|
||||
// parent) sits on a read-only mount would fail outright with "failed to
|
||||
// migrate sandbox grants", where reading alone reports an empty state and
|
||||
// writes nothing.
|
||||
state, needsMigration, err := store.readStateFile(false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !needsMigration && (state.Migration == nil || !state.Migration.NoticePending) {
|
||||
return "", nil
|
||||
}
|
||||
// Past here a write is genuinely required — either a schema/policy migration,
|
||||
// which only a lock holder may perform, or clearing NoticePending — so the
|
||||
// lock is warranted and a read-only grants path legitimately fails (as it did
|
||||
// before this lock existed, via readState's own migration path).
|
||||
//
|
||||
// Clearing the pending flag is a read-modify-write like any other mutator, so
|
||||
// it takes the same interprocess lock: two frontends starting at once must not
|
||||
// both read NoticePending, both print the notice, and clobber each other's
|
||||
// write of the rest of the state.
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer unlock()
|
||||
// Re-read under the lock. The unlocked peek above is only a snapshot: another
|
||||
// process may have consumed the notice or migrated the file while this one
|
||||
// waited, and when needsMigration was set that snapshot's contents are
|
||||
// meaningless by readStateFile's contract.
|
||||
state, err = store.readStateLocked()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -161,7 +198,13 @@ func (store *GrantStore) Grant(input GrantInput) (Grant, error) {
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
@@ -193,7 +236,13 @@ func (store *GrantStore) GrantCommandPrefix(input CommandPrefixInput) (CommandPr
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return CommandPrefixGrant{}, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return CommandPrefixGrant{}, err
|
||||
}
|
||||
@@ -337,7 +386,13 @@ func (store *GrantStore) Revoke(toolName string) (int, error) {
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -372,7 +427,13 @@ func (store *GrantStore) RevokePath(toolName string, scopePath string) (int, err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -407,7 +468,13 @@ func (store *GrantStore) RevokePath(toolName string, scopePath string) (int, err
|
||||
func (store *GrantStore) Clear() (int, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -537,7 +604,52 @@ func reconcileScope(scope string, kind ScopeKind) (string, ScopeKind) {
|
||||
return scope, kind
|
||||
}
|
||||
|
||||
// readState reads the grant file for a caller that does NOT hold the
|
||||
// interprocess lock (the lookup/list paths). A schema or policy migration has to
|
||||
// write — a backup plus the migrated state — so it is performed under the lock,
|
||||
// and the read is retaken there because another process may have migrated the
|
||||
// file while this one waited.
|
||||
func (store *GrantStore) readState() (grantFile, error) {
|
||||
state, needsMigration, err := store.readStateFile(false)
|
||||
if err != nil || !needsMigration {
|
||||
return state, err
|
||||
}
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return grantFile{}, err
|
||||
}
|
||||
defer unlock()
|
||||
return store.readStateLocked()
|
||||
}
|
||||
|
||||
// readStateLocked reads the grant file for a caller that already holds the
|
||||
// interprocess lock, so a migration writes through directly instead of
|
||||
// re-acquiring it (which would deadlock against the caller's own lock).
|
||||
func (store *GrantStore) readStateLocked() (grantFile, error) {
|
||||
state, _, err := store.readStateFile(true)
|
||||
return state, err
|
||||
}
|
||||
|
||||
// readStateFile decodes the grant file. needsMigration reports that the state on
|
||||
// disk must be rewritten (legacy schema or a changed policy version) and that the
|
||||
// caller has to retake this read while holding the interprocess lock; the
|
||||
// returned state is meaningless in that case.
|
||||
func (store *GrantStore) readStateFile(locked bool) (grantFile, bool, error) {
|
||||
state, err := store.decodeState(locked)
|
||||
if err != nil {
|
||||
if errors.Is(err, errGrantMigrationNeedsLock) {
|
||||
return grantFile{}, true, nil
|
||||
}
|
||||
return grantFile{}, false, err
|
||||
}
|
||||
return state, false, nil
|
||||
}
|
||||
|
||||
// errGrantMigrationNeedsLock is the internal signal from decodeState that the
|
||||
// file needs a migration write, which only a lock holder may perform.
|
||||
var errGrantMigrationNeedsLock = errors.New("sandbox grants file needs a migration write")
|
||||
|
||||
func (store *GrantStore) decodeState(locked bool) (grantFile, error) {
|
||||
data, err := os.ReadFile(store.filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -559,12 +671,18 @@ func (store *GrantStore) readState() (grantFile, error) {
|
||||
return grantFile{}, store.invalidGrantFile(err)
|
||||
}
|
||||
if head.SchemaVersion == 1 || head.SchemaVersion == 2 {
|
||||
if !locked {
|
||||
return grantFile{}, errGrantMigrationNeedsLock
|
||||
}
|
||||
return store.migrateLegacyState(data, head.SchemaVersion, head.Grants, head.CommandPrefixes)
|
||||
}
|
||||
if head.SchemaVersion != grantSchemaVersion {
|
||||
return grantFile{}, fmt.Errorf("invalid sandbox grants file at %s: unsupported schemaVersion", store.filePath)
|
||||
}
|
||||
if head.PolicyVersion != execution.PolicyVersion {
|
||||
if !locked {
|
||||
return grantFile{}, errGrantMigrationNeedsLock
|
||||
}
|
||||
return store.migrateChangedPolicy(data, head.PolicyVersion, head.Grants, head.CommandPrefixes)
|
||||
}
|
||||
buckets := map[string][]Grant{}
|
||||
@@ -800,6 +918,36 @@ func (store *GrantStore) writeState(state grantFile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *GrantStore) lockStateFile() (func(), error) {
|
||||
lockPath := store.filePath + ".lockfile"
|
||||
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(grantLockTimeout)
|
||||
for {
|
||||
locked, err := tryLockGrantFile(file)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if locked {
|
||||
return func() {
|
||||
_ = unlockGrantFile(file)
|
||||
_ = file.Close()
|
||||
}, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("timed out waiting for sandbox grants lock at %s", lockPath)
|
||||
}
|
||||
time.Sleep(grantLockRetry)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStoredGrant(name string, grant Grant) (Grant, error) {
|
||||
if strings.TrimSpace(grant.ToolName) == "" {
|
||||
grant.ToolName = name
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func tryLockGrantFile(file *os.File) (bool, error) {
|
||||
err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func unlockGrantFile(file *os.File) error {
|
||||
return syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
grantLockfileFailImmediately = 0x00000001
|
||||
grantLockfileExclusiveLock = 0x00000002
|
||||
grantErrorLockViolation = syscall.Errno(33)
|
||||
grantErrorSharingViolation = syscall.Errno(32)
|
||||
)
|
||||
|
||||
var (
|
||||
grantKernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
grantProcLockFileEx = grantKernel32.NewProc("LockFileEx")
|
||||
grantProcUnlockFileEx = grantKernel32.NewProc("UnlockFileEx")
|
||||
)
|
||||
|
||||
func tryLockGrantFile(file *os.File) (bool, error) {
|
||||
var overlapped syscall.Overlapped
|
||||
result, _, err := grantProcLockFileEx.Call(
|
||||
file.Fd(),
|
||||
uintptr(grantLockfileExclusiveLock|grantLockfileFailImmediately),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&overlapped)),
|
||||
)
|
||||
if result != 0 {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, grantErrorLockViolation) || errors.Is(err, grantErrorSharingViolation) {
|
||||
return false, nil
|
||||
}
|
||||
if err == syscall.Errno(0) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func unlockGrantFile(file *os.File) error {
|
||||
var overlapped syscall.Overlapped
|
||||
result, _, err := grantProcUnlockFileEx.Call(
|
||||
file.Fd(),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&overlapped)),
|
||||
)
|
||||
if result != 0 || err == syscall.Errno(0) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
|
||||
@@ -70,6 +73,246 @@ func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStoreSerializesWritesAcrossStores(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
|
||||
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore A returned error: %v", err)
|
||||
}
|
||||
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore B returned error: %v", err)
|
||||
}
|
||||
|
||||
unlock, err := storeA.lockStateFile()
|
||||
if err != nil {
|
||||
t.Fatalf("lockStateFile returned error: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := storeB.Grant(GrantInput{ToolName: "write_file", Decision: GrantAllow})
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
unlock()
|
||||
t.Fatalf("Grant completed while another store held the file lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Expected: storeB is waiting for storeA to release the lock.
|
||||
}
|
||||
|
||||
unlock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Grant returned error after file lock release: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Grant did not complete after file lock release")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantStoreSerializesMigrationNoticeAcrossStores covers the other
|
||||
// read-modify-write on the state file: clearing the pending flag. Two frontends
|
||||
// starting at once must not both read it as pending and clobber each other.
|
||||
func TestGrantStoreSerializesMigrationNoticeAcrossStores(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
|
||||
if err := writeText(path, `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}}}`); err != nil {
|
||||
t.Fatalf("write v1 grants: %v", err)
|
||||
}
|
||||
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore A returned error: %v", err)
|
||||
}
|
||||
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore B returned error: %v", err)
|
||||
}
|
||||
// Migrate first, so the notice is pending and the lock contention below is
|
||||
// about consuming it rather than about the migration write.
|
||||
if _, err := storeA.List(); err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
|
||||
unlock, err := storeA.lockStateFile()
|
||||
if err != nil {
|
||||
t.Fatalf("lockStateFile returned error: %v", err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
notice, err := storeB.ConsumeMigrationNotice()
|
||||
if err == nil && notice == "" {
|
||||
err = errors.New("expected the pending migration notice")
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
unlock()
|
||||
t.Fatalf("ConsumeMigrationNotice completed while another store held the file lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Expected: storeB is waiting for storeA to release the lock.
|
||||
}
|
||||
|
||||
unlock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeMigrationNotice after lock release: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ConsumeMigrationNotice did not complete after file lock release")
|
||||
}
|
||||
// The flag write landed, so the notice is not reported twice.
|
||||
if again, err := storeA.ConsumeMigrationNotice(); err != nil || again != "" {
|
||||
t.Fatalf("second ConsumeMigrationNotice = %q err=%v, want empty", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsumeMigrationNoticeWritesNothingWithoutAPendingNotice is the regression
|
||||
// test for jatmn's #755 finding: ConsumeMigrationNotice took the interprocess
|
||||
// lock unconditionally, and acquiring that lock MkdirAlls the grants directory
|
||||
// and creates <grants>.lockfile. Every startup and every `zero exec` calls this,
|
||||
// so it turned "read the grants state" into "require a writable grants
|
||||
// directory" — a user with no grants file whose grants path sat on a read-only
|
||||
// mount failed outright with "failed to migrate sandbox grants".
|
||||
//
|
||||
// Asserting that nothing is created is the portable form of that requirement: it
|
||||
// holds identically on every OS and as any user, unlike a read-only-directory
|
||||
// test (see the sibling test, which cannot run everywhere).
|
||||
func TestConsumeMigrationNoticeWritesNothingWithoutAPendingNotice(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "config")
|
||||
path := filepath.Join(dir, "sandbox-grants.json")
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
|
||||
notice, err := store.ConsumeMigrationNotice()
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeMigrationNotice with no grants file: %v", err)
|
||||
}
|
||||
if notice != "" {
|
||||
t.Fatalf("notice = %q, want empty when nothing was migrated", notice)
|
||||
}
|
||||
// Nothing at all may be created: not the lock file, not the grants file, not
|
||||
// even the directory that would hold them.
|
||||
if _, err := os.Lstat(dir); !os.IsNotExist(err) {
|
||||
entries, _ := os.ReadDir(dir)
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("the grants directory was created (contents: %v); reading a state with no pending notice must not require a writable directory", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsumeMigrationNoticeToleratesAReadOnlyGrantsDirectory exercises the
|
||||
// actual failure from the same finding, rather than only its portable proxy
|
||||
// above: with the grants directory read-only and no grants file, the pre-fix
|
||||
// code failed at MkdirAll/OpenFile for the lock file.
|
||||
//
|
||||
// It cannot run everywhere — Windows ignores the mode bits used here, and root
|
||||
// bypasses directory permissions entirely — so it skips instead of pretending to
|
||||
// cover those platforms.
|
||||
func TestConsumeMigrationNoticeToleratesAReadOnlyGrantsDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix directory mode bits do not deny writes on Windows")
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root bypasses directory permissions, so a read-only directory cannot be simulated")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sandbox-grants.json")
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o500); err != nil {
|
||||
t.Skipf("cannot make the grants directory read-only: %v", err)
|
||||
}
|
||||
// Restore write permission so t.TempDir's cleanup can remove the directory.
|
||||
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
|
||||
// Confirm the directory really is read-only here; otherwise this test would
|
||||
// pass without exercising anything.
|
||||
if probe, err := os.OpenFile(filepath.Join(dir, ".probe"), os.O_CREATE|os.O_RDWR, 0o600); err == nil {
|
||||
_ = probe.Close()
|
||||
t.Skip("this filesystem still allows writes to a 0500 directory")
|
||||
}
|
||||
|
||||
notice, err := store.ConsumeMigrationNotice()
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeMigrationNotice on a read-only grants directory: %v", err)
|
||||
}
|
||||
if notice != "" {
|
||||
t.Fatalf("notice = %q, want empty when nothing was migrated", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantStoreMigrationTakesTheFileLock covers the migrations that write
|
||||
// through readState: a legacy file must not be rewritten (nor its backup written)
|
||||
// while another process holds the lock, or two processes could both migrate and
|
||||
// one would clobber the other's result.
|
||||
func TestGrantStoreMigrationTakesTheFileLock(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
|
||||
original := `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}}}`
|
||||
if err := writeText(path, original); err != nil {
|
||||
t.Fatalf("write v1 grants: %v", err)
|
||||
}
|
||||
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore A returned error: %v", err)
|
||||
}
|
||||
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore B returned error: %v", err)
|
||||
}
|
||||
|
||||
unlock, err := storeA.lockStateFile()
|
||||
if err != nil {
|
||||
t.Fatalf("lockStateFile returned error: %v", err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := storeB.List()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
unlock()
|
||||
t.Fatalf("the migration ran while another store held the file lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
if raw, err := os.ReadFile(path); err != nil {
|
||||
unlock()
|
||||
t.Fatalf("ReadFile grants: %v", err)
|
||||
} else if string(raw) != original {
|
||||
unlock()
|
||||
t.Fatalf("the grant file was rewritten while the lock was held:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
unlock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("List after lock release: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the migration did not complete after file lock release")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile migrated grants: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"schemaVersion": 3`) {
|
||||
t.Fatalf("grant file was not migrated after the lock was released:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "sandbox-grants.json")
|
||||
|
||||
@@ -38,7 +38,7 @@ func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string) error
|
||||
}
|
||||
writeRoots := landlockWritableRoots(profile.FileSystem, cwd)
|
||||
if len(writeRoots) == 0 {
|
||||
return errors.New("Landlock requires at least one writable root")
|
||||
return errors.New("Landlock requires at least one writable root") //nolint:staticcheck // Preserve the proper name and established user-facing text.
|
||||
}
|
||||
return installLandlockFilesystemRules(writeRoots)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ package sandbox
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrLandlockUnsupported = errors.New("Landlock is only supported on Linux")
|
||||
var ErrLandlockUnsupported = errors.New("Landlock is only supported on Linux") //nolint:staticcheck // Preserve the exported sentinel's established text.
|
||||
|
||||
func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string) error {
|
||||
return ErrLandlockUnsupported
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -378,7 +379,31 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
awsDir := filepath.Join(home, ".aws")
|
||||
gcloudDir := filepath.Join(home, ".config", "gcloud")
|
||||
if err := mkdirAll(awsDir, gcloudDir); err != nil {
|
||||
npmrc := filepath.Join(home, ".npmrc")
|
||||
ghHosts := filepath.Join(home, ".config", "gh", "hosts.yml")
|
||||
netrc := filepath.Join(home, ".netrc")
|
||||
dockerConfig := filepath.Join(home, ".docker", "config.json")
|
||||
kubeConfig := filepath.Join(home, ".kube", "config")
|
||||
zeroConfig := filepath.Join(home, ".config", "zero")
|
||||
if err := mkdirAll(
|
||||
awsDir,
|
||||
gcloudDir,
|
||||
filepath.Dir(ghHosts),
|
||||
filepath.Dir(dockerConfig),
|
||||
filepath.Dir(kubeConfig),
|
||||
filepath.Dir(zeroConfig),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{npmrc, ghHosts, netrc, dockerConfig, kubeConfig, zeroConfig} {
|
||||
if path == zeroConfig {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(zeroConfig, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyFile := filepath.Join(home, "sa-key.json")
|
||||
@@ -387,7 +412,17 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
}
|
||||
|
||||
paths := credentialDenyReadPathsIn(home, keyFile, nil)
|
||||
for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) {
|
||||
for _, want := range normalizeProfilePaths([]string{
|
||||
awsDir,
|
||||
gcloudDir,
|
||||
keyFile,
|
||||
npmrc,
|
||||
ghHosts,
|
||||
netrc,
|
||||
dockerConfig,
|
||||
kubeConfig,
|
||||
zeroConfig,
|
||||
}) {
|
||||
if !stringSliceContains(paths, want) {
|
||||
t.Errorf("credential deny paths = %#v, want %q included", paths, want)
|
||||
}
|
||||
@@ -418,3 +453,58 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
t.Errorf("credential deny paths without home = %#v, want key file included", homeless)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configHome := filepath.Join(root, "xdg")
|
||||
npmrc := filepath.Join(root, "npm", "userconfig")
|
||||
ghConfigDir := filepath.Join(root, "gh")
|
||||
cloudSDKConfig := filepath.Join(root, "gcloud")
|
||||
netrc := filepath.Join(root, "netrc")
|
||||
dockerConfigDir := filepath.Join(root, "docker")
|
||||
kubeConfigA := filepath.Join(root, "kube", "a")
|
||||
kubeConfigB := filepath.Join(root, "kube", "b")
|
||||
zeroConfig := filepath.Join(configHome, "zero")
|
||||
filePaths := []string{
|
||||
npmrc,
|
||||
filepath.Join(ghConfigDir, "hosts.yml"),
|
||||
netrc,
|
||||
filepath.Join(dockerConfigDir, "config.json"),
|
||||
kubeConfigA,
|
||||
kubeConfigB,
|
||||
zeroConfig,
|
||||
}
|
||||
if err := os.MkdirAll(cloudSDKConfig, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range filePaths {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path == zeroConfig {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got := credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
ConfigHome: configHome,
|
||||
CloudSDKConfig: cloudSDKConfig,
|
||||
NPMUserConfig: npmrc,
|
||||
GHConfigDir: ghConfigDir,
|
||||
Netrc: netrc,
|
||||
DockerConfigDir: dockerConfigDir,
|
||||
KubeConfig: strings.Join([]string{kubeConfigA, kubeConfigB}, string(filepath.ListSeparator)),
|
||||
}, nil)
|
||||
wantPaths := append(filePaths, cloudSDKConfig)
|
||||
for _, want := range normalizeProfilePaths(wantPaths) {
|
||||
if !stringSliceContains(got, want) {
|
||||
t.Errorf("credential deny paths = %#v, want override %q included", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
-12
@@ -145,9 +145,10 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
|
||||
}
|
||||
|
||||
// credentialDenyReadPaths returns default deny-read entries for well-known
|
||||
// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file
|
||||
// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read
|
||||
// cloud secrets under the read-all workspace posture. Two deliberate limits:
|
||||
// credential stores so sandboxed commands cannot read secrets under the
|
||||
// read-all workspace posture. This includes tool configuration files that can
|
||||
// carry credentials and are now discoverable through the preserved caller
|
||||
// environment. Two deliberate limits:
|
||||
//
|
||||
// - Windows is skipped: a non-empty profile DenyRead switches the Windows
|
||||
// runner onto the capability-SID/ACL deny path and away from the
|
||||
@@ -166,23 +167,114 @@ func credentialDenyReadPaths(policy Policy) []string {
|
||||
// A failed home lookup only drops the home-based candidates; the
|
||||
// GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless.
|
||||
home, _ := os.UserHomeDir()
|
||||
return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead)
|
||||
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
Home: home,
|
||||
ConfigHome: os.Getenv("XDG_CONFIG_HOME"),
|
||||
CloudSDKConfig: os.Getenv("CLOUDSDK_CONFIG"),
|
||||
GoogleCredentials: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
|
||||
NPMUserConfig: firstNonEmpty(os.Getenv("NPM_CONFIG_USERCONFIG"), os.Getenv("npm_config_userconfig")),
|
||||
GHConfigDir: os.Getenv("GH_CONFIG_DIR"),
|
||||
Netrc: os.Getenv("NETRC"),
|
||||
DockerConfigDir: os.Getenv("DOCKER_CONFIG"),
|
||||
KubeConfig: os.Getenv("KUBECONFIG"),
|
||||
}, policy.AllowRead)
|
||||
}
|
||||
|
||||
// credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths,
|
||||
// separated so tests can exercise it against a synthetic home directory.
|
||||
func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string {
|
||||
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
Home: home,
|
||||
GoogleCredentials: googleCredentials,
|
||||
}, allowRead)
|
||||
}
|
||||
|
||||
type credentialPathEnvironment struct {
|
||||
Home string
|
||||
ConfigHome string
|
||||
CloudSDKConfig string
|
||||
GoogleCredentials string
|
||||
NPMUserConfig string
|
||||
GHConfigDir string
|
||||
Netrc string
|
||||
DockerConfigDir string
|
||||
KubeConfig string
|
||||
}
|
||||
|
||||
func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowRead []string) []string {
|
||||
var candidates []string
|
||||
if home = strings.TrimSpace(home); home != "" {
|
||||
home := strings.TrimSpace(env.Home)
|
||||
configHome := strings.TrimSpace(env.ConfigHome)
|
||||
if configHome == "" && home != "" {
|
||||
configHome = filepath.Join(home, ".config")
|
||||
}
|
||||
if home != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".aws"),
|
||||
filepath.Join(home, ".config", "gcloud"),
|
||||
filepath.Join(home, ".azure"),
|
||||
// git's credential store backend, which holds host passwords and
|
||||
// personal access tokens in cleartext. Denied rather than the whole
|
||||
// of ~/.ssh, because these cost nothing functionally: git reads them
|
||||
// through a credential helper for authentication, not for identity,
|
||||
// so a sandboxed git still works and simply cannot authenticate as
|
||||
// the user. SSH key material is a harder trade and is tracked
|
||||
// separately (#815).
|
||||
filepath.Join(home, ".git-credentials"),
|
||||
)
|
||||
}
|
||||
if target := strings.TrimSpace(googleCredentials); target != "" {
|
||||
npmUserConfig := strings.TrimSpace(env.NPMUserConfig)
|
||||
if npmUserConfig == "" && home != "" {
|
||||
npmUserConfig = filepath.Join(home, ".npmrc")
|
||||
}
|
||||
netrc := strings.TrimSpace(env.Netrc)
|
||||
if netrc == "" && home != "" {
|
||||
netrc = filepath.Join(home, ".netrc")
|
||||
}
|
||||
dockerConfigDir := strings.TrimSpace(env.DockerConfigDir)
|
||||
if dockerConfigDir == "" && home != "" {
|
||||
dockerConfigDir = filepath.Join(home, ".docker")
|
||||
}
|
||||
candidates = append(candidates, npmUserConfig, netrc)
|
||||
if dockerConfigDir != "" {
|
||||
candidates = append(candidates, filepath.Join(dockerConfigDir, "config.json"))
|
||||
}
|
||||
if configHome != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(configHome, "zero"),
|
||||
// The XDG location of the same git credential store. Denying the file
|
||||
// rather than the directory is deliberate: ~/.config/git also holds
|
||||
// the global git config, which userGitConfigReadPaths grants on
|
||||
// purpose so a sandboxed git can read user.name and aliases instead
|
||||
// of failing outright.
|
||||
filepath.Join(configHome, "git", "credentials"),
|
||||
)
|
||||
}
|
||||
cloudSDKConfig := strings.TrimSpace(env.CloudSDKConfig)
|
||||
if cloudSDKConfig == "" && configHome != "" {
|
||||
cloudSDKConfig = filepath.Join(configHome, "gcloud")
|
||||
}
|
||||
if cloudSDKConfig != "" {
|
||||
candidates = append(candidates, cloudSDKConfig)
|
||||
}
|
||||
ghConfigDir := strings.TrimSpace(env.GHConfigDir)
|
||||
if ghConfigDir == "" && configHome != "" {
|
||||
ghConfigDir = filepath.Join(configHome, "gh")
|
||||
}
|
||||
if ghConfigDir != "" {
|
||||
candidates = append(candidates, filepath.Join(ghConfigDir, "hosts.yml"))
|
||||
}
|
||||
if target := strings.TrimSpace(env.GoogleCredentials); target != "" {
|
||||
candidates = append(candidates, target)
|
||||
}
|
||||
kubeConfig := strings.TrimSpace(env.KubeConfig)
|
||||
if kubeConfig == "" && home != "" {
|
||||
kubeConfig = filepath.Join(home, ".kube", "config")
|
||||
}
|
||||
for _, path := range filepath.SplitList(kubeConfig) {
|
||||
if path = strings.TrimSpace(path); path != "" {
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
}
|
||||
allowRoots := normalizeProfilePaths(allowRead)
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, path := range normalizeProfilePaths(candidates) {
|
||||
@@ -206,11 +298,12 @@ func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead
|
||||
|
||||
// userGitConfigReadPaths returns the user's global git config FILES so a
|
||||
// sandboxed git can read identity and config (user.name/email, aliases) instead
|
||||
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
|
||||
// files only — not the ~/.config/git directory, which can hold an XDG credential
|
||||
// store — so credentials and the rest of HOME stay unreadable. Granted at the
|
||||
// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the
|
||||
// HOME-dependent paths don't leak into the platform-agnostic policy snapshot.
|
||||
// of failing with "unable to access ~/.gitconfig". On macOS, where reads are
|
||||
// allow-listed, granting only these files avoids exposing the surrounding
|
||||
// configuration directory. Linux uses a read-all profile with explicit
|
||||
// credential deny rules. The paths are granted at the macOS seatbelt read rule
|
||||
// rather than the cross-platform PermissionProfile so HOME-dependent paths
|
||||
// don't leak into the platform-agnostic policy snapshot.
|
||||
func userGitConfigReadPaths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user