mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
945eb06d2b | ||
|
|
d82b94defe | ||
|
|
88fb7b6192 | ||
|
|
0125e70d0f | ||
|
|
f5fa4ffc64 | ||
|
|
9fbdc34155 | ||
|
|
583e6530bd | ||
|
|
ac50a5a840 | ||
|
|
a4a805a6c8 | ||
|
|
9fe02ae52e | ||
|
|
9d5c82d441 | ||
|
|
97ef90d27a | ||
|
|
5c58256abc | ||
|
|
fa8734f179 | ||
|
|
5bb1bc11c3 | ||
|
|
d9b882eb0a | ||
|
|
a50574f673 | ||
|
|
c00a4734b5 | ||
|
|
2479884dea | ||
|
|
ab2f9fbd3e | ||
|
|
baa4be13ac | ||
|
|
ddfdf2878e | ||
|
|
96859c9bd1 | ||
|
|
3524f795e5 | ||
|
|
b1f41735a7 | ||
|
|
4945684fa2 | ||
|
|
5140d4bf7b | ||
|
|
3967d49d64 | ||
|
|
89bdc6719a | ||
|
|
722bb31216 | ||
|
|
a21a052a4a | ||
|
|
f079b90f82 | ||
|
|
b30c3971b4 | ||
|
|
da9fb50f54 | ||
|
|
684901165d | ||
|
|
e1975c1b39 | ||
|
|
8c6d3022bd | ||
|
|
974fc036c2 | ||
|
|
4cbd144d11 | ||
|
|
c0b6b9e5c9 | ||
|
|
d74ceb1127 | ||
|
|
fbf85984f6 | ||
|
|
9242b9e037 |
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.4.0"}
|
||||
{".":"0.5.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).
|
||||
|
||||
|
||||
@@ -6,6 +6,53 @@ 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.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
|
||||
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
|
||||
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
|
||||
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
|
||||
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
|
||||
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
|
||||
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
|
||||
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
|
||||
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
|
||||
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
|
||||
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
|
||||
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
|
||||
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
|
||||
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
|
||||
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
|
||||
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
|
||||
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
|
||||
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
|
||||
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
|
||||
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
|
||||
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
|
||||
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
|
||||
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
|
||||
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
|
||||
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
|
||||
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
|
||||
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
|
||||
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
|
||||
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
|
||||
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
|
||||
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,28 @@ An approved issue means only that the specific issue has been accepted for work.
|
||||
It does not approve unrelated future work, future pull requests, or future issues
|
||||
from the same author. Each issue is reviewed case by case.
|
||||
|
||||
## Contribution Volume and Batching
|
||||
|
||||
To keep triage and review sustainable while the project stabilizes, please keep
|
||||
your contribution flow to a size the core team can realistically work through:
|
||||
|
||||
- One issue per bug. File each defect as its own issue with its own
|
||||
reproduction. Please do not bundle several unrelated findings into a single
|
||||
"audit" issue; separate issues are far easier to triage, approve, and close
|
||||
individually.
|
||||
- Keep only a few items open at a time. If you already have several issues or
|
||||
pull requests awaiting review, let those move through the queue before opening
|
||||
more. A large simultaneous burst from one contributor slows review for
|
||||
everyone, even when each item is valid.
|
||||
- AI-assisted contributions are welcome, held to the same bar. Automated audits
|
||||
and generated patches are fine, but each issue and pull request must be
|
||||
individually verified, scoped, and reproducible before you open it. Please do
|
||||
not post bulk machine-generated findings unfiltered.
|
||||
|
||||
These apply to all contributors and are about review bandwidth, not the value of
|
||||
any single report. Maintainers may ask you to consolidate, split, or hold
|
||||
submissions to keep the queue manageable.
|
||||
|
||||
## Pull Request Requirements
|
||||
|
||||
Before opening a community pull request, make sure all of the following are true:
|
||||
|
||||
@@ -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"
|
||||
@@ -367,21 +367,24 @@ 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:
|
||||
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
|
||||
run them directly. If it isn't, add it:
|
||||
|
||||
```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]
|
||||
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
|
||||
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
|
||||
```
|
||||
|
||||
### Cross-Compile Examples
|
||||
|
||||
+3
-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/
|
||||
@@ -289,6 +289,7 @@ Install and manage:
|
||||
```bash
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
|
||||
@@ -749,7 +749,7 @@ flowchart TD
|
||||
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
||||
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
||||
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
||||
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
|
||||
`LoadFromRoots`; single-root `Load` remains for install/write.
|
||||
- **Hooks** can observe or block tool lifecycle events.
|
||||
|
||||
## End-to-End Data Flow
|
||||
|
||||
+178
-27
@@ -11,6 +11,9 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -23,9 +26,10 @@ import (
|
||||
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
|
||||
// operation.
|
||||
type Deps struct {
|
||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
|
||||
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
|
||||
// exactly like the exec surface — never run unconfined on the host.
|
||||
@@ -60,13 +64,16 @@ type acpSession struct {
|
||||
// turnMu serializes prompt turns for one session: concurrent session/prompt
|
||||
// calls run one at a time so they can't interleave history or clobber the
|
||||
// single cancel slot.
|
||||
turnMu sync.Mutex
|
||||
turnMu sync.Mutex
|
||||
modelMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string // override; "" => config default
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string
|
||||
models []SessionConfigOptionValue
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
@@ -83,9 +90,6 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
|
||||
return a
|
||||
}
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -120,7 +124,7 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
||||
|
||||
// ---- session lifecycle ----
|
||||
|
||||
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
|
||||
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p NewSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
||||
@@ -129,18 +133,23 @@ func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root, ModelID: model})
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, nil)
|
||||
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
Modes: a.modeState(sess),
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
|
||||
func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p LoadSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
||||
@@ -161,7 +170,17 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
if persistedModel := strings.TrimSpace(meta.ModelID); persistedModel != "" && (!restrictModels || modelChoiceExists(models, persistedModel)) {
|
||||
model = persistedModel
|
||||
if !modelChoiceExists(models, persistedModel) {
|
||||
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||
}
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
"load session history",
|
||||
@@ -169,7 +188,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
historyErr,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
Modes: a.modeState(sess),
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -354,11 +374,27 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
if p.ConfigID != configIDModel {
|
||||
switch p.ConfigID {
|
||||
case configIDModel:
|
||||
model := strings.TrimSpace(p.Value)
|
||||
if err := a.updateModel(sess, model, sess.restrictModels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case configIDMode:
|
||||
mode := agent.PermissionMode(p.Value)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
case agent.PermissionModeUnsafe:
|
||||
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
|
||||
}
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
|
||||
}
|
||||
sess.setModel(p.Value)
|
||||
return SetSessionConfigOptionResult{}, nil
|
||||
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -370,8 +406,24 @@ func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (a
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
sess.setModel(p.Model)
|
||||
return ZeroSetModelResult{Model: p.Model}, nil
|
||||
model := strings.TrimSpace(p.Model)
|
||||
if err := a.updateModel(sess, model, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ZeroSetModelResult{Model: model}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool) error {
|
||||
sess.modelMu.Lock()
|
||||
defer sess.modelMu.Unlock()
|
||||
if restrictModels && !sess.hasModel(model) {
|
||||
return RPCError(codeInvalidParams, "unknown model: "+model)
|
||||
}
|
||||
if _, err := a.deps.Store.UpdateModel(sess.id, model); err != nil {
|
||||
return RPCError(codeInternalError, "save model selection: "+err.Error())
|
||||
}
|
||||
sess.setModel(model)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||
@@ -398,6 +450,76 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveModelChoices advertises authenticated live models when discovery is
|
||||
// available. The configured model is always retained as the sole fallback.
|
||||
func (a *Agent) resolveModelChoices(ctx context.Context, cwd string) (string, []SessionConfigOptionValue, bool, error) {
|
||||
resolved, err := a.deps.ResolveConfig(cwd, config.Overrides{})
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
selected := strings.TrimSpace(resolved.Provider.Model)
|
||||
options := make([]SessionConfigOptionValue, 0, 8)
|
||||
seen := make(map[string]bool)
|
||||
add := func(id, description string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
options = append(options, SessionConfigOptionValue{Value: id, Name: id, Description: description})
|
||||
}
|
||||
add(selected, "")
|
||||
descriptor, knownProvider := providercatalog.Get(resolved.Provider.CatalogID)
|
||||
restrictModels := knownProvider && !descriptor.Custom
|
||||
if a.deps.DiscoverModels != nil {
|
||||
discovered, discoverErr := a.deps.DiscoverModels(ctx, resolved.Provider)
|
||||
if ctx.Err() != nil {
|
||||
return "", nil, false, ctx.Err()
|
||||
}
|
||||
if discoverErr == nil && len(discovered) > 0 {
|
||||
for _, model := range discovered {
|
||||
if providermodelcatalog.ModelIDAllowedForProvider(resolved.Provider.CatalogID, model.ID) {
|
||||
add(model.ID, model.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected, options, restrictModels, nil
|
||||
}
|
||||
|
||||
func modelChoiceExists(models []SessionConfigOptionValue, model string) bool {
|
||||
for _, option := range models {
|
||||
if option.Value == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
|
||||
model, models, mode := s.configState()
|
||||
return []SessionConfigOption{{
|
||||
ID: configIDModel,
|
||||
Name: "Model",
|
||||
Description: "Model used for this session.",
|
||||
Category: configCategoryModel,
|
||||
Type: configOptionTypeSelect,
|
||||
CurrentValue: model,
|
||||
Options: models,
|
||||
}, {
|
||||
ID: configIDMode,
|
||||
Name: "Mode",
|
||||
Description: "Permission mode used for this session.",
|
||||
Category: configCategoryMode,
|
||||
Type: configOptionTypeSelect,
|
||||
CurrentValue: string(mode),
|
||||
Options: []SessionConfigOptionValue{
|
||||
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
@@ -525,13 +647,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
// session is returned unchanged rather than orphaning its turn or resetting its
|
||||
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
||||
// half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord) *acpSession {
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, history: history}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
a.sessions[id] = sess
|
||||
return sess
|
||||
}
|
||||
@@ -570,7 +692,18 @@ func (s *acpSession) currentMode() agent.PermissionMode {
|
||||
}
|
||||
|
||||
func (s *acpSession) setModel(model string) {
|
||||
model = strings.TrimSpace(model)
|
||||
s.mu.Lock()
|
||||
found := false
|
||||
for _, option := range s.models {
|
||||
if option.Value == model {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && model != "" {
|
||||
s.models = append(s.models, SessionConfigOptionValue{Value: model, Name: model})
|
||||
}
|
||||
s.model = model
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -581,6 +714,24 @@ func (s *acpSession) currentModel() string {
|
||||
return s.model
|
||||
}
|
||||
|
||||
func (s *acpSession) hasModel(model string) bool {
|
||||
model = strings.TrimSpace(model)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, option := range s.models {
|
||||
if option.Value == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *acpSession) configState() (string, []SessionConfigOptionValue, agent.PermissionMode) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.model, append([]SessionConfigOptionValue(nil), s.models...), s.mode
|
||||
}
|
||||
|
||||
func (s *acpSession) appendHistory(rec turnRecord) {
|
||||
s.mu.Lock()
|
||||
s.history = append(s.history, rec)
|
||||
|
||||
@@ -3,6 +3,7 @@ package acp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -137,6 +139,12 @@ func TestACPEndToEndPrompt(t *testing.T) {
|
||||
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
|
||||
}
|
||||
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
|
||||
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
|
||||
}
|
||||
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
|
||||
}
|
||||
|
||||
// session/prompt
|
||||
var promptRes PromptResult
|
||||
@@ -156,6 +164,201 @@ func TestACPEndToEndPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "gpt-5.5"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "ChatGPT", CatalogID: "chatgpt", Model: model,
|
||||
}}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: " gpt-5.4-mini ", Description: "Fast"},
|
||||
{ID: "gpt-5.5", Description: "duplicate configured model"},
|
||||
}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
option := created.ConfigOptions[0]
|
||||
if option.CurrentValue != "gpt-5.5" || len(option.Options) < 2 {
|
||||
t.Fatalf("new model option = %+v", option)
|
||||
}
|
||||
for _, choice := range option.Options {
|
||||
if choice.Name != choice.Value {
|
||||
t.Fatalf("model choice name = %q, want model id %q", choice.Name, choice.Value)
|
||||
}
|
||||
}
|
||||
var selected SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: " gpt-5.4-mini ",
|
||||
}, &selected); err != nil {
|
||||
t.Fatalf("set_config_option: %v", err)
|
||||
}
|
||||
if got := selected.ConfigOptions[0].CurrentValue; got != "gpt-5.4-mini" {
|
||||
t.Fatalf("selected model = %q", got)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: "not-advertised",
|
||||
}, &SetSessionConfigOptionResult{}); err == nil {
|
||||
t.Fatal("unknown standard model selection was accepted")
|
||||
}
|
||||
h.stop()
|
||||
h = newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var loaded LoadSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
|
||||
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
|
||||
}
|
||||
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryFailureUsesConfiguredFallbackOnly(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "ChatGPT", CatalogID: "chatgpt", Model: " configured-model ",
|
||||
}}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, fmt.Errorf("discovery unavailable")
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
option := created.ConfigOptions[0]
|
||||
if option.CurrentValue != "configured-model" || len(option.Options) != 1 || option.Options[0].Value != "configured-model" {
|
||||
t.Fatalf("fallback model option = %+v", option)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPCustomProviderAllowsUnadvertisedModel(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "configured-model"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "Custom", CatalogID: "custom-openai-compatible", Model: model,
|
||||
}}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
var selected SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: " vendor/model ",
|
||||
}, &selected); err != nil {
|
||||
t.Fatalf("set custom model: %v", err)
|
||||
}
|
||||
if got := selected.ConfigOptions[0].CurrentValue; got != "vendor/model" {
|
||||
t.Fatalf("custom model = %q", got)
|
||||
}
|
||||
h.stop()
|
||||
h = newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var loaded LoadSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
option := loaded.ConfigOptions[0]
|
||||
if option.CurrentValue != "vendor/model" || !modelChoiceExists(option.Options, "vendor/model") {
|
||||
t.Fatalf("loaded custom model option = %+v", option)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryFiltersProviderIncompatibleModels(t *testing.T) {
|
||||
a := &Agent{deps: Deps{
|
||||
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
CatalogID: "opencode-go-anthropic-compatible", Model: "minimax-m3",
|
||||
}}, nil
|
||||
},
|
||||
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{{ID: "qwen3.7-plus"}, {ID: "claude-sonnet-4.5"}}, nil
|
||||
},
|
||||
}}
|
||||
_, options, restricted, err := a.resolveModelChoices(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !restricted || !modelChoiceExists(options, "qwen3.7-plus") || modelChoiceExists(options, "claude-sonnet-4.5") {
|
||||
t.Fatalf("filtered model options = %+v, restricted=%v", options, restricted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
a := &Agent{deps: Deps{
|
||||
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{Model: "configured-model"}}, nil
|
||||
},
|
||||
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, context.Canceled
|
||||
},
|
||||
}}
|
||||
if _, _, _, err := a.resolveModelChoices(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("resolve error = %v, want context canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPConfigOptionWireSchema(t *testing.T) {
|
||||
b, err := json.Marshal(SessionConfigOption{
|
||||
ID: "model", Name: "Model", Description: "desc", Category: "model",
|
||||
Type: configOptionTypeSelect, CurrentValue: "m1",
|
||||
Options: []SessionConfigOptionValue{{Value: "m1", Name: "m1", Description: "choice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(b, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"type", "category", "currentValue", "options"} {
|
||||
if _, ok := wire[key]; !ok {
|
||||
t.Errorf("wire field %q absent: %s", key, b)
|
||||
}
|
||||
}
|
||||
if _, ok := wire["value"]; ok {
|
||||
t.Errorf("obsolete option value field present: %s", b)
|
||||
}
|
||||
if _, ok := wire["values"]; ok {
|
||||
t.Errorf("obsolete values field present: %s", b)
|
||||
}
|
||||
choice := wire["options"].([]any)[0].(map[string]any)
|
||||
if choice["value"] != "m1" {
|
||||
t.Errorf("options[].value = %#v", choice["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPUnknownSessionPromptErrors(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
@@ -181,6 +384,15 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
|
||||
t.Fatalf("set_mode ask: %v", err)
|
||||
}
|
||||
var configured SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAuto),
|
||||
}, &configured); err != nil {
|
||||
t.Fatalf("set_config_option mode: %v", err)
|
||||
}
|
||||
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("configured mode = %q", got)
|
||||
}
|
||||
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
|
||||
t.Fatal("expected Unsafe mode to be rejected over ACP")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package acp
|
||||
|
||||
import "context"
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
+19
-19
@@ -103,10 +103,6 @@ type ContentBlock struct {
|
||||
|
||||
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
|
||||
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
||||
@@ -233,10 +229,6 @@ func ToolContent(block ContentBlock) ToolCallContent {
|
||||
return ToolCallContent{Type: "content", Content: &block}
|
||||
}
|
||||
|
||||
func ToolDiff(path, oldText, newText string) ToolCallContent {
|
||||
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
|
||||
}
|
||||
|
||||
type ToolCallLocation struct {
|
||||
Path string `json:"path"`
|
||||
Line *int `json:"line,omitempty"`
|
||||
@@ -337,19 +329,22 @@ type SetSessionModeParams struct {
|
||||
|
||||
type SetSessionModeResult struct{}
|
||||
|
||||
// ---- session config options (model selection) ----
|
||||
// ---- session config options ----
|
||||
|
||||
type SessionConfigOptionValue struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type SessionConfigOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Values []SessionConfigOptionValue `json:"values,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Type string `json:"type"`
|
||||
CurrentValue string `json:"currentValue"`
|
||||
Options []SessionConfigOptionValue `json:"options"`
|
||||
}
|
||||
|
||||
type SetSessionConfigOptionParams struct {
|
||||
@@ -373,6 +368,11 @@ type ZeroSetModelResult struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
|
||||
// through the standard session/set_config_option method.
|
||||
const configIDModel = "model"
|
||||
const (
|
||||
configIDModel = "model"
|
||||
configIDMode = "mode"
|
||||
|
||||
configOptionTypeSelect = "select"
|
||||
configCategoryModel = "model"
|
||||
configCategoryMode = "mode"
|
||||
)
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
@@ -51,7 +51,7 @@ func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testi
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
@@ -88,7 +88,7 @@ func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *t
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
@@ -117,7 +117,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
|
||||
@@ -501,15 +501,3 @@ func hasStringPrefix(values []string, prefix []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -610,15 +610,6 @@ func formatPreservedState(plan string, task *preservedTaskState, edits, tools, s
|
||||
return preservedStateLabel + "\n" + string(encoded)
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
func parsePreservedStateBlock(summaryContent string) preservedState {
|
||||
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -336,7 +336,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
|
||||
}
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -364,7 +364,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
||||
},
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -427,7 +427,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// the compaction closure. read_file also returns a sizeable result that
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -491,7 +491,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
|
||||
finalText: "recovered",
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
var deltas []string
|
||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||
|
||||
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
@@ -305,8 +305,8 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
||||
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
||||
DisabledTools: []string{"read_file"},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and optional
|
||||
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
||||
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
||||
// through tool_search. Non-deferred tools (including tool_search) are always
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -169,6 +169,37 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of #702's fix: the unknown-session error's normalized
|
||||
// signature must not vary with the session id, so a model probing ids 1, 2,
|
||||
// 3, … produces one repeated signature the failure guard can count. If the id
|
||||
// leaked into the first 80 normalized chars, each probe would reset the streak
|
||||
// and the halt would never fire.
|
||||
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
|
||||
a := errorSignature(tools.UnknownExecSessionError(1))
|
||||
b := errorSignature(tools.UnknownExecSessionError(999999))
|
||||
if a != b {
|
||||
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: with an id-invariant signature, probing a different unknown id
|
||||
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
|
||||
// before the fix it never would.
|
||||
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
|
||||
var state guardState
|
||||
var stoppedAt int
|
||||
for i := 1; i <= toolFailureStopAt; i++ {
|
||||
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
|
||||
if out.Stop {
|
||||
stoppedAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if stoppedAt != toolFailureStopAt {
|
||||
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
||||
var state guardState
|
||||
toolOnly := zeroruntime.CollectedStream{
|
||||
@@ -229,7 +260,7 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -260,7 +291,7 @@ func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -288,7 +319,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{
|
||||
@@ -318,7 +349,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
||||
@@ -352,7 +383,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
turns := [][]zeroruntime.StreamEvent{
|
||||
@@ -387,7 +418,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
||||
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
||||
|
||||
+82
-57
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -266,10 +267,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
|
||||
// Build the per-turn tool list first so proactive compaction can include
|
||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||
// the messages, so computing it before compaction is safe.
|
||||
// Build the per-turn tool list first so proactive compaction can include
|
||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||
@@ -1170,6 +1167,17 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
decisionCommandPrefix = grant.Prefix
|
||||
}
|
||||
}
|
||||
// An explicit additional-permissions request is an elevation only when it
|
||||
// expands the current effective policy. If the same capability was already
|
||||
// approved for this session, reuse that grant across equivalent commands
|
||||
// instead of prompting again merely because their command prefixes differ.
|
||||
if toolFound && !permissionGranted && isShellCommandTool(call.Name) && options.Sandbox != nil {
|
||||
if profile, requested, profileErr := inlineAdditionalPermissionsProfile(args, options.Cwd); profileErr == nil && requested && options.Sandbox.CoversRequestPermissions(profile) {
|
||||
permissionGranted = true
|
||||
decisionAction = PermissionDecisionAllowForSession
|
||||
decisionReason = "requested sandbox capabilities already granted for this session"
|
||||
}
|
||||
}
|
||||
|
||||
var preflightDecision *sandbox.Decision
|
||||
if toolFound && options.Sandbox != nil {
|
||||
@@ -1416,17 +1424,18 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// the agent loop and the MCP server pass through), so result.Output is
|
||||
// already redacted here and result.Redacted reflects whether it changed.
|
||||
return ToolResult{
|
||||
Risk: executedRisk,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: result.Display,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
Risk: executedRisk,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
// A tool may signal a mid-run model escalation by carrying the target id
|
||||
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
||||
// the Run turn loop performs the actual provider switch. Empty for every
|
||||
@@ -1546,8 +1555,15 @@ func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, resu
|
||||
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
|
||||
return false
|
||||
}
|
||||
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
|
||||
return false
|
||||
if result.ExecutionOutcome != nil {
|
||||
denial := result.ExecutionOutcome.Denial
|
||||
if denial == nil || denial.Capability.Kind != execution.CapabilityExternalNetwork {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return false
|
||||
@@ -1600,6 +1616,11 @@ 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
|
||||
}
|
||||
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
|
||||
}
|
||||
|
||||
@@ -1728,17 +1749,18 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
}
|
||||
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
RequestedModel: meta["escalate_to_model"],
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
RequestedModel: meta["escalate_to_model"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1998,15 +2020,16 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
Cwd: options.Cwd,
|
||||
})
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: result.Display,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
}
|
||||
}
|
||||
return ToolResult{
|
||||
@@ -2570,7 +2593,8 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
|
||||
scopeText = requestPermissionsScope(profile)
|
||||
}
|
||||
reason = userFacingPermissionReason(call.Name, args, reason)
|
||||
decisionReason := strings.TrimSpace(reason)
|
||||
reason = userFacingPermissionReason(call.Name, args, reason, safety.Reason)
|
||||
|
||||
return PermissionEvent{
|
||||
ToolCallID: call.ID,
|
||||
@@ -2583,6 +2607,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
Autonomy: options.Autonomy,
|
||||
SideEffect: string(safety.SideEffect),
|
||||
Reason: reason,
|
||||
DecisionReason: decisionReason,
|
||||
Scope: scopeText,
|
||||
Risk: risk,
|
||||
Block: block,
|
||||
@@ -2592,18 +2617,31 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}, true
|
||||
}
|
||||
|
||||
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
|
||||
func userFacingPermissionReason(toolName string, args map[string]any, reason string, fallback string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
|
||||
if !isShellCommandTool(toolName) {
|
||||
return reason
|
||||
}
|
||||
if justification, ok := firstStringArg(args, "justification"); ok {
|
||||
return justification
|
||||
|
||||
// Policy and sandbox explanations are authoritative. The tool's static
|
||||
// safety copy is only an internal fallback; presenting it as the reason for
|
||||
// every ordinary shell approval makes prompts generic and obscures the cases
|
||||
// where policy has an actionable explanation.
|
||||
fallback = strings.TrimSpace(fallback)
|
||||
if reason != "" && reason != fallback && reason != "tool requires approval before execution" && reason != sandbox.ReasonEscalatedSandboxRequired {
|
||||
return reason
|
||||
}
|
||||
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
|
||||
|
||||
if shellCommandRequiresEscalated(args) {
|
||||
if justification, ok := firstStringArg(args, "justification"); ok {
|
||||
return justification
|
||||
}
|
||||
return "This command needs to run outside the sandbox."
|
||||
}
|
||||
return reason
|
||||
|
||||
// Ordinary shell approvals need no invented explanation. The raw fallback is
|
||||
// retained separately as DecisionReason for traces and policy auditing.
|
||||
return ""
|
||||
}
|
||||
|
||||
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
|
||||
@@ -2824,19 +2862,6 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
||||
}
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and optional
|
||||
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
||||
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
||||
// through tool_search. Non-deferred tools (including tool_search) are always
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
|
||||
// partitionToolsCached is partitionTools with an optional per-tool definition
|
||||
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
|
||||
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
|
||||
|
||||
+90
-39
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
@@ -26,6 +27,59 @@ type mockProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
|
||||
call := ToolCall{Name: tools.ExecCommandToolName}
|
||||
options := Options{Sandbox: engine}
|
||||
legacyMeta := map[string]string{
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
|
||||
}
|
||||
|
||||
applicationFailure := tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Meta: legacyMeta,
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateFailed,
|
||||
Kind: execution.OutcomeApplicationFailure,
|
||||
Exit: &execution.Exit{Code: 1},
|
||||
},
|
||||
}
|
||||
if sandboxDeniedNetworkRetryCandidate(call, nil, applicationFailure, options) || sandboxRestrictedShellRetryCandidate(call, nil, applicationFailure, options) {
|
||||
t.Fatal("typed application failure must not be retried from legacy sandbox-like text")
|
||||
}
|
||||
|
||||
protectedDenial := applicationFailure
|
||||
protectedDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
|
||||
Source: execution.DenialSourceConfiguredPolicy,
|
||||
Reason: "protected metadata",
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if sandboxRestrictedShellRetryCandidate(call, nil, protectedDenial, options) {
|
||||
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
networkDenial := protectedDenial
|
||||
networkDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityExternalNetwork},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "external network denied",
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if !sandboxDeniedNetworkRetryCandidate(call, nil, networkDenial, options) {
|
||||
t.Fatal("typed external-network denial should use the narrow network approval path")
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.requests = append(provider.requests, request)
|
||||
|
||||
@@ -809,9 +863,6 @@ func TestRunReportsTruncationFinishReason(t *testing.T) {
|
||||
if result.FinishReason != zeroruntime.FinishReasonLength {
|
||||
t.Fatalf("FinishReason = %q, want %q", result.FinishReason, zeroruntime.FinishReasonLength)
|
||||
}
|
||||
if !result.Truncated() {
|
||||
t.Fatal("Truncated() = false, want true for a length-capped response")
|
||||
}
|
||||
if result.TruncationNotice() == "" {
|
||||
t.Fatal("TruncationNotice() empty for a truncated response")
|
||||
}
|
||||
@@ -829,7 +880,7 @@ func TestRunNormalCompletionIsNotTruncated(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Truncated() || result.TruncationNotice() != "" {
|
||||
if result.TruncationNotice() != "" {
|
||||
t.Fatalf("normal completion reported as truncated: reason=%q", result.FinishReason)
|
||||
}
|
||||
}
|
||||
@@ -906,7 +957,7 @@ func TestRunEmitsUsageEvents(t *testing.T) {
|
||||
func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1113,9 +1164,9 @@ func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewGrepTool(root))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedGrepTool(root, nil))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1143,7 +1194,7 @@ func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1180,7 +1231,7 @@ func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
func TestRunRejectsToolCallsOutsideEnabledList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1218,7 +1269,7 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1302,7 +1353,7 @@ func TestRunPreservesRequestPrefixAcrossTurns(t *testing.T) {
|
||||
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1370,7 +1421,7 @@ func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1417,8 +1468,8 @@ func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1493,7 +1544,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
// after a later call in the same turn supersedes it.
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1550,7 +1601,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1593,7 +1644,7 @@ func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1649,7 +1700,7 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1698,7 +1749,7 @@ func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing
|
||||
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1746,7 +1797,7 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write should not continue")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1842,7 +1893,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
@@ -1914,7 +1965,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1995,7 +2046,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2197,7 +2248,7 @@ func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
|
||||
@@ -2304,7 +2355,7 @@ func TestRunPersistentCommandPrefixStillPromptsForNetwork(t *testing.T) {
|
||||
t.Fatalf("seed command prefix: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2365,7 +2416,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2432,7 +2483,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2475,7 +2526,7 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2544,7 +2595,7 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
// prior code denied it because persistPermissionGrant errors when Sandbox==nil.
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2608,7 +2659,7 @@ func TestRunCancellationPreservesContextCanceledIdentity(t *testing.T) {
|
||||
func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2660,7 +2711,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2839,7 +2890,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2888,7 +2939,7 @@ func TestRunStopsAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
@@ -2918,7 +2969,7 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3177,7 +3228,7 @@ func TestBuildSystemPromptAllowsSpecModeOverride(t *testing.T) {
|
||||
func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
specmode.RegisterDraftTools(registry, root, nil)
|
||||
@@ -3214,7 +3265,7 @@ func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("done")
|
||||
|
||||
result, err := Run(context.Background(), "draft", provider, Options{
|
||||
@@ -3247,7 +3298,7 @@ func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3388,7 +3439,7 @@ func TestRunSurfacesDroppedToolCallAlongsideValidCall(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
@@ -68,3 +68,61 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "npm install",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
|
||||
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
|
||||
t.Fatal("an already-covered capability must not trigger another prompt")
|
||||
}
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("covered capability should still produce an auditable allow event")
|
||||
}
|
||||
if event.Action != PermissionActionAllow {
|
||||
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
|
||||
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "make test"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("generic shell approval must produce an event")
|
||||
}
|
||||
if event.Reason != "" {
|
||||
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
|
||||
}
|
||||
if event.DecisionReason != "runs a shell command" {
|
||||
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
|
||||
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "curl https://example.test",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
|
||||
"justification": "Run without sandboxing.",
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("policy approval must produce an event")
|
||||
}
|
||||
if event.Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
|
||||
// the auto-mode advertised set, including exec_command: auto mode must expose both
|
||||
// process creation and write_stdin process interaction or the latter is unusable
|
||||
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
maxEagerToolSchemaTokens = 3550
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
|
||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ work.
|
||||
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
||||
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -71,7 +72,10 @@ type ToolResult struct {
|
||||
Meta map[string]string
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
Display tools.Display
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
Display tools.Display
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
@@ -423,13 +427,6 @@ type Result struct {
|
||||
IncompleteReason string
|
||||
}
|
||||
|
||||
// Truncated reports whether the final response ended abnormally (cut off at the
|
||||
// output token cap or withheld by a content filter) rather than completing
|
||||
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
|
||||
func (result Result) Truncated() bool {
|
||||
return result.FinishReason != ""
|
||||
}
|
||||
|
||||
// TruncationNotice returns a user-facing warning when the final response was
|
||||
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
||||
// wording stays consistent.
|
||||
|
||||
@@ -31,10 +31,6 @@ type AgentRunner interface {
|
||||
|
||||
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
|
||||
|
||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
|
||||
// runaway agent cannot exhaust memory or bloat the benchmark report.
|
||||
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agenteval
|
||||
|
||||
import "context"
|
||||
|
||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// terminationGracePeriod is how long a process has to exit after SIGTERM before
|
||||
@@ -22,10 +21,7 @@ var (
|
||||
// the negative PID (the group), so any process the child forks dies with it
|
||||
// instead of being orphaned. Must be called before cmd.Start.
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
execution.ConfigureProcessGroup(cmd)
|
||||
}
|
||||
|
||||
// terminateProcess stops a background process. It first asks politely with
|
||||
@@ -41,71 +37,5 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
// back to signalling only the individual PID, which also avoids reporting a false
|
||||
// success when a non-leader group-signal returns ESRCH.
|
||||
func terminateProcess(pid int) error {
|
||||
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
|
||||
// every process we can signal. A real child PID is always > 1, so never let a
|
||||
// bogus 0/1 expand into either.
|
||||
if pid <= 1 {
|
||||
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
|
||||
}
|
||||
|
||||
// Signal the whole group only when pid leads its own group; otherwise the
|
||||
// individual process. See the doc comment for why a non-leader is signalled
|
||||
// directly rather than via its (foreign) group.
|
||||
target := pid
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else if processGoneError(err) {
|
||||
return nil // already gone
|
||||
}
|
||||
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if processGoneError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Poll liveness so we return promptly once it exits, rather than always
|
||||
// waiting out the full grace period.
|
||||
deadline := time.Now().Add(terminationGracePeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(terminationPollInterval)
|
||||
}
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Still alive after the grace period: force-kill.
|
||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
|
||||
// so this helper only reports success once the target is actually gone —
|
||||
// otherwise the caller gets nil while descendants are still racing.
|
||||
deadline = time.Now().Add(terminationGracePeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(terminationPollInterval)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processGoneError reports whether an error means the process group has already
|
||||
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
|
||||
// no process in the target group remains.
|
||||
func processGoneError(err error) bool {
|
||||
return errors.Is(err, syscall.ESRCH)
|
||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -3,36 +3,16 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
|
||||
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
|
||||
// delegated to execution.TerminateProcessTree, so no launch-time process-group
|
||||
// setup is required (the POSIX build sets Setpgid here instead).
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
|
||||
|
||||
func terminateProcess(pid int) error {
|
||||
taskkill := taskkillPath()
|
||||
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return process.Kill()
|
||||
}
|
||||
|
||||
func taskkillPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = os.Getenv("windir")
|
||||
}
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
return filepath.Join(systemRoot, "System32", "taskkill.exe")
|
||||
return execution.TerminateProcessTree(pid, 0, 0)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/acp"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
@@ -43,6 +45,9 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
conn := acp.NewConn(deps.stdin, stdout)
|
||||
acp.NewAgent(conn, acp.Deps{
|
||||
ResolveConfig: deps.resolveConfig,
|
||||
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
||||
},
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
||||
// so ACP is authenticated for apiKeyStored profiles like every other
|
||||
// surface — no ACP-specific credential handling needed.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+34
-16
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
@@ -58,7 +59,11 @@ type appDeps struct {
|
||||
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
||||
// by fillAppDeps, so tests never mutate the process environment unless they
|
||||
// inject it). nil ⇒ no export.
|
||||
exportActiveProvider func(providerName string)
|
||||
exportActiveProvider func(providerName string)
|
||||
// getenv reads a process environment variable (production: os.Getenv, set in
|
||||
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
|
||||
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
|
||||
getenv func(string) string
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
@@ -118,6 +123,7 @@ func defaultAppDeps() appDeps {
|
||||
stdin: os.Stdin,
|
||||
userConfigPath: config.DefaultUserConfigPath,
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
getenv: os.Getenv,
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
options, err := config.DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
@@ -368,6 +374,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
case "exec":
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
||||
case "completions":
|
||||
return runCompletions(args[1:], stdout, stderr)
|
||||
case "daemon":
|
||||
return runDaemon(args[1:], stdout, stderr, deps)
|
||||
case "config":
|
||||
@@ -678,6 +686,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
executionRunner := execution.NewRunner(nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
if notice, err := sandboxStore.ConsumeMigrationNotice(); err != nil {
|
||||
return writeAppError(stderr, "failed to migrate sandbox grants: "+err.Error(), 1)
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
@@ -712,6 +740,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
@@ -738,7 +768,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// The interactive TUI is not worktree-reassigned, so the trust root is the
|
||||
// launch directory itself.
|
||||
trustRoot := workspaceRoot
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
||||
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
||||
// apply_patch) would never be offered to the model — the TUI could neither edit
|
||||
@@ -760,19 +790,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// unchanged. The interactive surface applies no operator tool filters, so
|
||||
// enabled/disabled are nil — matching the AgentOptions below.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
var scratchBaseline scratchFileBaseline
|
||||
@@ -789,7 +806,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
|
||||
// report can be combined with the plugin activation's, and emit at most one
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
@@ -1157,6 +1174,7 @@ Usage:
|
||||
|
||||
Commands:
|
||||
exec Run a one-shot prompt through the Go agent runtime
|
||||
completions Generate shell completion scripts
|
||||
daemon Manage the local background worker daemon (start/stop/status/run/attach)
|
||||
setup Guide first-run provider setup
|
||||
config Inspect resolved Go configuration without leaking secrets
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type completionNode struct {
|
||||
names []string
|
||||
flags []string
|
||||
children []completionNode
|
||||
}
|
||||
|
||||
type completionContext struct {
|
||||
path string
|
||||
candidates []string
|
||||
transitions map[string]string
|
||||
}
|
||||
|
||||
var completionRoot = completionNode{
|
||||
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--theme", "--skip-permissions-unsafe"},
|
||||
children: []completionNode{
|
||||
{names: []string{"exec"}, flags: []string{
|
||||
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
|
||||
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--max-turns", "--exec-profile",
|
||||
"--auto", "--enabled-tools", "--disabled-tools", "--list-tools", "--profile", "-r",
|
||||
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
|
||||
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
|
||||
"--calling-session-id", "--calling-tool-use-id", "--tag", "--depth", "--session-title",
|
||||
"--init-session-id", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
|
||||
"--notify", "--no-notify", "--no-completion-gate",
|
||||
}},
|
||||
{
|
||||
names: []string{"completions"},
|
||||
flags: []string{"-h", "--help"},
|
||||
children: leafNodes("bash", "zsh", "fish", "powershell", "elvish"),
|
||||
},
|
||||
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
|
||||
{names: []string{"setup"}},
|
||||
{names: []string{"config"}},
|
||||
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
|
||||
{names: []string{"providers"}, children: []completionNode{
|
||||
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
|
||||
{names: []string{"add"}}, {names: []string{"check"}}, {names: []string{"use"}},
|
||||
{names: []string{"remove", "rm"}}, {names: []string{"rename"}}, {names: []string{"setup"}},
|
||||
{names: []string{"detect"}}, {names: []string{"models"}},
|
||||
}},
|
||||
{names: []string{"doctor"}},
|
||||
{names: []string{"context"}},
|
||||
{names: []string{"repo-map", "repomap"}},
|
||||
{names: []string{"search", "find"}},
|
||||
{names: []string{"sessions", "session"}, children: leafNodes("list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan")},
|
||||
{names: []string{"spec"}, children: leafNodes("show", "approve", "reject")},
|
||||
{names: []string{"init"}},
|
||||
{names: []string{"specialists", "specialist"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"show"}}, {names: []string{"create"}},
|
||||
{names: []string{"delete", "rm"}}, {names: []string{"edit"}}, {names: []string{"path"}},
|
||||
}},
|
||||
{names: []string{"plugins", "plugin"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
|
||||
}},
|
||||
{names: []string{"backends", "backend"}, children: leafNodes("doctor")},
|
||||
{names: []string{"skills", "skill"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
|
||||
}},
|
||||
{names: []string{"tools", "tool"}, children: leafNodes("make", "list")},
|
||||
{names: []string{"hooks"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"remove", "rm"}},
|
||||
{names: []string{"enable"}}, {names: []string{"disable"}},
|
||||
}},
|
||||
{names: []string{"mcp"}, children: []completionNode{
|
||||
{names: []string{"add"}}, {names: []string{"remove", "rm"}}, {names: []string{"enable"}},
|
||||
{names: []string{"disable"}}, {names: []string{"check"}}, {names: []string{"list"}},
|
||||
{names: []string{"permissions"}, children: leafNodes("list", "revoke", "clear")},
|
||||
{names: []string{"tools"}, children: leafNodes("list")},
|
||||
{names: []string{"oauth"}, children: leafNodes("login", "logout", "status")},
|
||||
}},
|
||||
{names: []string{"auth"}, children: leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh")},
|
||||
{names: []string{"sandbox"}, children: []completionNode{
|
||||
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
|
||||
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
|
||||
}},
|
||||
{names: []string{"update"}},
|
||||
{names: []string{"upgrade"}},
|
||||
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare")},
|
||||
{names: []string{"verify"}},
|
||||
{names: []string{"trust"}, children: leafNodes("list", "remove")},
|
||||
{names: []string{"eval"}, children: leafNodes("validate", "run", "bench")},
|
||||
{names: []string{"changes", "change"}, children: []completionNode{
|
||||
{names: []string{"inspect", "status"}}, {names: []string{"commit"}}, {names: []string{"push"}},
|
||||
{names: []string{"pr", "pull-request"}},
|
||||
}},
|
||||
{names: []string{"usage"}, children: leafNodes("report")},
|
||||
{names: []string{"cron"}, children: leafNodes("add", "list", "rm", "pause", "resume", "run")},
|
||||
{names: []string{"repo-info", "repoinfo"}},
|
||||
{names: []string{"serve"}},
|
||||
{names: []string{"acp"}},
|
||||
{names: []string{"help"}},
|
||||
{names: []string{"version"}},
|
||||
},
|
||||
}
|
||||
|
||||
func leafNodes(names ...string) []completionNode {
|
||||
nodes := make([]completionNode, 0, len(names))
|
||||
for _, name := range names {
|
||||
nodes = append(nodes, completionNode{names: []string{name}})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func runCompletions(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if len(args) == 1 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
|
||||
if err := writeCompletionsHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "shell required. Use `zero completions <bash|zsh|fish|powershell|elvish>`.")
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unexpected completions argument %q", args[1]))
|
||||
}
|
||||
|
||||
contexts := completionContexts(completionRoot)
|
||||
var err error
|
||||
switch strings.ToLower(strings.TrimSpace(args[0])) {
|
||||
case "bash":
|
||||
err = writeBashCompletions(stdout, contexts)
|
||||
case "zsh":
|
||||
err = writeZshCompletions(stdout, contexts)
|
||||
case "fish":
|
||||
err = writeFishCompletions(stdout, contexts)
|
||||
case "powershell":
|
||||
err = writePowerShellCompletions(stdout, contexts)
|
||||
case "elvish":
|
||||
err = writeElvishCompletions(stdout, contexts)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unsupported shell %q; expected bash, zsh, fish, powershell, or elvish", args[0]))
|
||||
}
|
||||
if err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func writeCompletionsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Generate shell completion scripts.
|
||||
|
||||
Usage:
|
||||
zero completions <shell>
|
||||
|
||||
Arguments:
|
||||
<shell> Target shell: bash, zsh, fish, powershell, or elvish
|
||||
|
||||
Examples:
|
||||
source <(zero completions bash)
|
||||
source <(zero completions zsh)
|
||||
zero completions fish > ~/.config/fish/completions/zero.fish
|
||||
zero completions powershell >> $PROFILE
|
||||
eval (zero completions elvish | slurp)
|
||||
|
||||
Flags:
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func completionContexts(root completionNode) []completionContext {
|
||||
contexts := []completionContext{}
|
||||
var visit func(completionNode, []string)
|
||||
visit = func(node completionNode, paths []string) {
|
||||
candidates := append([]string{}, node.flags...)
|
||||
transitions := map[string]string{}
|
||||
for _, child := range node.children {
|
||||
candidates = append(candidates, child.names...)
|
||||
for _, path := range paths {
|
||||
for _, name := range child.names {
|
||||
childPath := strings.TrimSpace(path + " " + name)
|
||||
transitions[path+"|"+name] = childPath
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates = uniqueStrings(candidates)
|
||||
for _, path := range paths {
|
||||
contexts = append(contexts, completionContext{path: path, candidates: candidates, transitions: transitions})
|
||||
}
|
||||
for _, child := range node.children {
|
||||
childPaths := make([]string, 0, len(paths)*len(child.names))
|
||||
for _, path := range paths {
|
||||
for _, name := range child.names {
|
||||
childPaths = append(childPaths, strings.TrimSpace(path+" "+name))
|
||||
}
|
||||
}
|
||||
visit(child, childPaths)
|
||||
}
|
||||
}
|
||||
visit(root, []string{""})
|
||||
sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].path < contexts[j].path })
|
||||
return contexts
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func allTransitions(contexts []completionContext) map[string]string {
|
||||
transitions := map[string]string{}
|
||||
for _, context := range contexts {
|
||||
for key, value := range context.transitions {
|
||||
transitions[key] = value
|
||||
}
|
||||
}
|
||||
return transitions
|
||||
}
|
||||
|
||||
func sortedTransitionKeys(transitions map[string]string) []string {
|
||||
keys := make([]string, 0, len(transitions))
|
||||
for key := range transitions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func shellWords(values []string) string {
|
||||
return strings.Join(values, " ")
|
||||
}
|
||||
|
||||
func writeBashCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "# bash completion for zero\n_zero() {\n local cur context token i candidates\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n context=\"\""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePOSIXContextLoop(w, contexts, " for ((i = 1; i < COMP_CWORD; i++)); do", " token=\"${COMP_WORDS[i]}\"", " done"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePOSIXCandidateCase(w, contexts, " "); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, " COMPREPLY=( $(compgen -W \"$candidates\" -- \"$cur\") )\n}\ncomplete -F _zero zero")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeZshCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "#compdef zero\n_zero() {\n local context token i\n local -a candidates\n context=\"\""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePOSIXContextLoop(w, contexts, " for ((i = 2; i < CURRENT; i++)); do", " token=\"${words[i]}\"", " done"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " case \"$context\" in"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, context := range contexts {
|
||||
if _, err := fmt.Fprintf(w, " %q) candidates=(%s) ;;\n", context.path, quotedWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w, " esac\n compadd -- \"${candidates[@]}\"\n}\ncompdef _zero zero")
|
||||
return err
|
||||
}
|
||||
|
||||
func writePOSIXContextLoop(w io.Writer, contexts []completionContext, start string, token string, end string) error {
|
||||
if _, err := fmt.Fprintln(w, start); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, token); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " case \"$context|$token\" in"); err != nil {
|
||||
return err
|
||||
}
|
||||
transitions := allTransitions(contexts)
|
||||
for _, key := range sortedTransitionKeys(transitions) {
|
||||
if _, err := fmt.Fprintf(w, " %q) context=%q ;;\n", key, transitions[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " esac"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, end)
|
||||
return err
|
||||
}
|
||||
|
||||
func writePOSIXCandidateCase(w io.Writer, contexts []completionContext, indent string) error {
|
||||
if _, err := fmt.Fprintln(w, indent+"case \"$context\" in"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, context := range contexts {
|
||||
if _, err := fmt.Fprintf(w, "%s %q) candidates=%q ;;\n", indent, context.path, shellWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w, indent+"esac")
|
||||
return err
|
||||
}
|
||||
|
||||
func quotedWords(values []string) string {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", value))
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
|
||||
func writeFishCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "# fish completion for zero\nfunction __zero_completion_context\n set -l context ''\n set -l tokens (commandline -opc)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " for token in $tokens[2..-1]\n switch \"$context|$token\""); err != nil {
|
||||
return err
|
||||
}
|
||||
transitions := allTransitions(contexts)
|
||||
for _, key := range sortedTransitionKeys(transitions) {
|
||||
if _, err := fmt.Fprintf(w, " case %q\n set context %q\n", key, transitions[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " end\n end\n if test -z \"$context\"\n echo __root__\n else\n echo \"$context\"\n end\nend"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, context := range contexts {
|
||||
path := context.path
|
||||
if path == "" {
|
||||
path = "__root__"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "complete -c zero -f -n 'test (__zero_completion_context) = %q' -a %q\n", path, shellWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePowerShellCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "# PowerShell completion for zero\nRegister-ArgumentCompleter -Native -CommandName zero -ScriptBlock {\n param($wordToComplete, $commandAst, $cursorPosition)\n $context = ''\n $elements = @($commandAst.CommandElements)\n $limit = $elements.Count\n if ($wordToComplete -ne '' -and $limit -gt 1) { $limit-- }\n for ($i = 1; $i -lt $limit; $i++) {\n $token = $elements[$i].Extent.Text\n switch (\"$context|$token\") {"); err != nil {
|
||||
return err
|
||||
}
|
||||
transitions := allTransitions(contexts)
|
||||
for _, key := range sortedTransitionKeys(transitions) {
|
||||
if _, err := fmt.Fprintf(w, " '%s' { $context = '%s'; break }\n", key, transitions[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " }\n }\n $candidates = switch ($context) {"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, context := range contexts {
|
||||
if _, err := fmt.Fprintf(w, " '%s' { @(%s) }\n", context.path, powershellWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w, " }\n $candidates | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {\n [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)\n }\n}")
|
||||
return err
|
||||
}
|
||||
|
||||
func powershellWords(values []string) string {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(value, "'", "''")+"'")
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func writeElvishCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "# Elvish completion for zero\nset edit:completion:arg-completer[zero] = {|@args|\n var context = ''\n for token $args[1..-1] {"); err != nil {
|
||||
return err
|
||||
}
|
||||
transitions := allTransitions(contexts)
|
||||
for index, key := range sortedTransitionKeys(transitions) {
|
||||
keyword := "if"
|
||||
if index > 0 {
|
||||
keyword = "} elif"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, " %s (eq $context'|'$token %q) {\n set context = %q\n", keyword, key, transitions[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " }\n }"); err != nil {
|
||||
return err
|
||||
}
|
||||
for index, context := range contexts {
|
||||
keyword := "if"
|
||||
if index > 0 {
|
||||
keyword = "} elif"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, " %s (eq $context %q) {\n put %s\n", keyword, context.path, shellWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w, " }\n}")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompletionsHelpAndRootHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"completions", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("completion help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"zero completions <shell>",
|
||||
"bash, zsh, fish, powershell, or elvish",
|
||||
"source <(zero completions bash)",
|
||||
"~/.config/fish/completions/zero.fish",
|
||||
"eval (zero completions elvish | slurp)",
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Errorf("completion help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("root help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "completions Generate shell completion scripts") {
|
||||
t.Fatalf("root help does not list completions command:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionsRejectsMissingUnknownAndExtraShellArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "missing", args: []string{"completions"}, want: "shell required"},
|
||||
{name: "unknown", args: []string{"completions", "nu"}, want: "unsupported shell"},
|
||||
{name: "extra", args: []string{"completions", "bash", "extra"}, want: "unexpected completions argument"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runWithDeps(test.args, &stdout, &stderr, appDeps{}); code != exitUsage {
|
||||
t.Fatalf("exit code = %d, want %d: %s", code, exitUsage, stderr.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionsGeneratesEverySupportedShell(t *testing.T) {
|
||||
tests := []struct {
|
||||
shell string
|
||||
marker string
|
||||
syntaxShell string
|
||||
}{
|
||||
{shell: "bash", marker: "complete -F _zero zero", syntaxShell: "bash"},
|
||||
{shell: "zsh", marker: "#compdef zero", syntaxShell: "zsh"},
|
||||
{shell: "fish", marker: "complete -c zero"},
|
||||
{shell: "powershell", marker: "Register-ArgumentCompleter -Native -CommandName zero"},
|
||||
{shell: "elvish", marker: "edit:completion:arg-completer[zero]"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.shell, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"completions", test.shell}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), test.marker) {
|
||||
t.Fatalf("%s completion missing marker %q:\n%s", test.shell, test.marker, stdout.String())
|
||||
}
|
||||
for _, want := range []string{"daemon", "mcp oauth", "sandbox grants", "--output-format"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Errorf("%s completion missing %q", test.shell, want)
|
||||
}
|
||||
}
|
||||
script := stdout.String()
|
||||
switch test.shell {
|
||||
case "fish":
|
||||
assertBalancedFishBlocks(t, script)
|
||||
case "powershell", "elvish":
|
||||
assertBalancedBraces(t, script)
|
||||
}
|
||||
if test.syntaxShell != "" {
|
||||
assertNativeShellSyntax(t, test.syntaxShell, script)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertBalancedBraces(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
if opens, closes := strings.Count(script, "{"), strings.Count(script, "}"); opens != closes {
|
||||
t.Errorf("unbalanced braces: %d opening, %d closing", opens, closes)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBalancedFishBlocks(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
depth := 0
|
||||
for _, line := range strings.Split(script, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "function", "for", "switch", "if":
|
||||
depth++
|
||||
case "end":
|
||||
depth--
|
||||
if depth < 0 {
|
||||
t.Fatal("fish completion closes a block before one is opened")
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth != 0 {
|
||||
t.Errorf("fish completion has %d unclosed blocks", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNativeShellSyntax(t *testing.T, shell, script string) {
|
||||
t.Helper()
|
||||
path, err := exec.LookPath(shell)
|
||||
if err != nil {
|
||||
t.Skipf("%s is not installed", shell)
|
||||
}
|
||||
command := exec.Command(path, "-n")
|
||||
command.Stdin = strings.NewReader(script)
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s syntax check failed: %v\n%s", shell, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
contexts := completionContexts(completionRoot)
|
||||
byPath := make(map[string][]string, len(contexts))
|
||||
for _, context := range contexts {
|
||||
byPath[context.path] = context.candidates
|
||||
}
|
||||
|
||||
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
|
||||
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
|
||||
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
|
||||
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
|
||||
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
|
||||
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
|
||||
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
|
||||
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
|
||||
}
|
||||
|
||||
func assertCandidates(t *testing.T, got []string, wants ...string) {
|
||||
t.Helper()
|
||||
set := make(map[string]bool, len(got))
|
||||
for _, candidate := range got {
|
||||
set[candidate] = true
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !set[want] {
|
||||
t.Errorf("candidates %v do not contain %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -138,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 {
|
||||
@@ -252,6 +254,115 @@ func runPluginRemove(args []string, dir string, stdout io.Writer, stderr io.Writ
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
id, options, help, err := parsePluginInfoArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writePluginInfoHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if id == "" {
|
||||
return writeExecUsageError(stderr, "usage: zero plugins info <id> [--user] [--json]")
|
||||
}
|
||||
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
||||
}
|
||||
info, err := plugins.Info(plugins.InfoOptions{
|
||||
LoadOptions: pluginLoadOptions(deps, cwd, options.user),
|
||||
}, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, plugins.ErrNotInstalled) {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(info, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
plugin := info.Plugin
|
||||
lines := []string{fmt.Sprintf("%s (%s) %s", plugin.ID, plugin.Name, plugin.Version)}
|
||||
state := "enabled"
|
||||
if !plugin.Enabled {
|
||||
state = "disabled"
|
||||
}
|
||||
lines = append(lines, " state: "+state)
|
||||
lines = append(lines, " source: "+string(plugin.Source))
|
||||
if len(plugin.Tools) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" tools: %d", len(plugin.Tools)))
|
||||
}
|
||||
if len(plugin.Hooks) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" hooks: %d", len(plugin.Hooks)))
|
||||
}
|
||||
if len(plugin.Skills) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" skills: %d", len(plugin.Skills)))
|
||||
}
|
||||
if len(plugin.Prompts) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" prompts: %d", len(plugin.Prompts)))
|
||||
}
|
||||
if info.LockSource != "" {
|
||||
lines = append(lines, " lock source: "+info.LockSource)
|
||||
}
|
||||
if info.LockHash != "" {
|
||||
lines = append(lines, " lock hash: "+info.LockHash)
|
||||
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
||||
}
|
||||
lines = append(lines, " manifest: "+plugin.ManifestPath)
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type pluginInfoOptions struct {
|
||||
json bool
|
||||
user bool
|
||||
}
|
||||
|
||||
func parsePluginInfoArgs(args []string) (string, pluginInfoOptions, bool, error) {
|
||||
options := pluginInfoOptions{}
|
||||
id := ""
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return "", options, true, nil
|
||||
case "--json":
|
||||
options.json = true
|
||||
case "--user":
|
||||
options.user = true
|
||||
default:
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
return "", options, false, execUsageError{fmt.Sprintf("unknown plugins info flag %q", arg)}
|
||||
}
|
||||
if id != "" {
|
||||
return "", options, false, execUsageError{"plugins info takes a single id"}
|
||||
}
|
||||
id = arg
|
||||
}
|
||||
}
|
||||
return id, options, false, nil
|
||||
}
|
||||
|
||||
func pluginLoadOptions(deps appDeps, cwd string, userOnly bool) plugins.LoadOptions {
|
||||
opts := plugins.LoadOptions{Cwd: cwd, ExcludeProject: userOnly}
|
||||
roots := []plugins.Root{{Source: plugins.SourceUser, Path: deps.pluginsDir()}}
|
||||
if !userOnly {
|
||||
roots = append(roots, plugins.Root{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")})
|
||||
}
|
||||
opts.Roots = roots
|
||||
return opts
|
||||
}
|
||||
|
||||
// --- tools make / list ---
|
||||
|
||||
func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
@@ -567,6 +678,18 @@ Removes an installed plugin directory and its plugins.lock entry.
|
||||
return err
|
||||
}
|
||||
|
||||
func writePluginInfoHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero plugins info <id> [flags]
|
||||
|
||||
Flags:
|
||||
--user Target only user plugins (ignore ./.zero/plugins)
|
||||
--json Print plugin info as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeToolsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero tools <command>
|
||||
|
||||
@@ -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()
|
||||
@@ -177,6 +219,15 @@ func TestRunPluginAddListRemove(t *testing.T) {
|
||||
t.Fatalf("add output missing id:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"plugin", "info", "zero.demo"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("plugin info exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "zero.demo") || !strings.Contains(stdout.String(), "manifest:") {
|
||||
t.Fatalf("info output missing plugin details:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
// remove
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
|
||||
+35
-29
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -206,6 +207,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
executionRunner := execution.NewRunner(nil)
|
||||
// Register the escalate_model tool only when the run opted into mid-run
|
||||
// escalation. It is registered before --list-tools formatting and
|
||||
// validateExecToolFilters so the tool is both listable and filter-validatable.
|
||||
@@ -238,16 +240,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.useSpec {
|
||||
permissionMode = agent.PermissionModeSpecDraft
|
||||
}
|
||||
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
var mcpRuntime mcpToolRuntime
|
||||
var mcpSkip trustSkip
|
||||
// Make local plugins live for this run: register their declared tools into the
|
||||
// registry and collect their hooks + skill roots for the dispatcher and skill
|
||||
// tool below. Done before --list-tools and filter validation so plugin tools
|
||||
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
var pluginActivation pluginActivation
|
||||
if options.useSpec {
|
||||
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
|
||||
}
|
||||
@@ -290,6 +289,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
var displacedMaxTurns int
|
||||
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
if notice, err := sandboxEngine.ConsumeGrantMigrationNotice(); err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", "migrate sandbox grants: "+err.Error())
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
@@ -357,28 +379,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
images = nil
|
||||
}
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
// Re-register the core tools with the run scope, OVERWRITING the nil-scope
|
||||
// instances registered before config resolve (the registry must exist that
|
||||
// early for --list-tools and tool-filter validation, which run without a
|
||||
// provider). This is safe only while two invariants hold:
|
||||
// 1. Registry.Register replaces by NAME, so every path-confining core
|
||||
// tool is swapped wholesale; and
|
||||
// 2. nothing between the initial registration and this point captures a
|
||||
// core-tool INSTANCE (tool_search holds the *Registry* and resolves
|
||||
// names lazily; --list-tools and filter validation use names only).
|
||||
// A new wrapper that snapshots a core tool before this line would silently
|
||||
// ship nil-scope enforcement — add it below this re-registration instead.
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
runReasoningEffort := options.reasoningEffort
|
||||
if options.useSpec && options.specReasoningEffort != "" {
|
||||
runReasoningEffort = options.specReasoningEffort
|
||||
@@ -624,7 +624,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Build the hooks dispatcher out of the struct literal so its trust skip report
|
||||
// can be combined with the plugin activation's, and emit at most one notice when
|
||||
// project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
@@ -939,6 +939,12 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
// An explicit `"sandbox": {"enabled": false}` disables the sandbox: every
|
||||
// request is allowed and commands run unwrapped. Only reachable from global
|
||||
// config / CLI — project config never sets Enabled (see mergeProjectConfig).
|
||||
if cfg.Enabled != nil && !*cfg.Enabled {
|
||||
policy.Mode = sandbox.ModeDisabled
|
||||
}
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -114,7 +115,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
|
||||
// repo trusted. Emit at most one notice when project hooks or the project MCP
|
||||
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
|
||||
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitSuccess
|
||||
case "add":
|
||||
return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr)
|
||||
case "info":
|
||||
return runPluginInfo(args[1:], stdout, stderr, deps)
|
||||
case "remove", "rm":
|
||||
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
|
||||
default:
|
||||
@@ -561,6 +563,7 @@ func writePluginsHelp(w io.Writer) error {
|
||||
|
||||
Commands:
|
||||
list List local Zero plugins
|
||||
info <id> Show plugin details and lockfile metadata
|
||||
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
|
||||
remove <id> Remove an installed plugin and its lockfile entry
|
||||
`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
@@ -56,8 +57,8 @@ func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool
|
||||
//
|
||||
// trustRoot is the original launch directory (resolved before any --worktree
|
||||
// reassignment); the project layer loads only when that root is trusted.
|
||||
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
|
||||
func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot, runners...)
|
||||
}
|
||||
|
||||
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
|
||||
@@ -72,7 +73,7 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
|
||||
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
|
||||
// returned trustSkip lets the caller emit one combined notice; the notice itself
|
||||
// is NOT emitted here.
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
|
||||
@@ -106,10 +107,15 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
|
||||
}
|
||||
config.Hooks = merged
|
||||
}
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
return hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: config,
|
||||
Audit: audit,
|
||||
Cwd: workspaceRoot,
|
||||
Config: config,
|
||||
Audit: audit,
|
||||
Cwd: workspaceRoot,
|
||||
Execution: runner,
|
||||
}), skip
|
||||
}
|
||||
|
||||
|
||||
@@ -734,14 +734,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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
@@ -29,7 +30,7 @@ type mcpToolListItem struct {
|
||||
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
|
||||
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
|
||||
// otherwise a workspace whose only project config is MCP would be gated silently.
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
|
||||
@@ -46,9 +47,15 @@ func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, reg
|
||||
if err != nil {
|
||||
return nil, skip, err
|
||||
}
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
|
||||
PermissionStore: store,
|
||||
Autonomy: autonomy,
|
||||
Execution: runner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
return runtime, skip, err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -42,7 +43,7 @@ type pluginActivation struct {
|
||||
// trustRoot is the original launch directory (resolved before any --worktree
|
||||
// reassignment). The returned activation carries the skip report so the caller can
|
||||
// emit one combined notice; the notice itself is NOT emitted here.
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string, runners ...*execution.Runner) pluginActivation {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
|
||||
@@ -65,7 +66,11 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
||||
writePluginActivationWarning(stderr, formatLoadDiagnostic(diagnostic))
|
||||
}
|
||||
|
||||
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot})
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot, Execution: runner})
|
||||
for _, warning := range result.Warnings {
|
||||
writePluginActivationWarning(stderr, warning)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
)
|
||||
|
||||
type providerModelsOptions struct {
|
||||
@@ -137,7 +138,12 @@ func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderP
|
||||
// the provider's model-listing endpoint with no curated-catalog merge or
|
||||
// coding-model filtering, so a custom provider's full model list is returned.
|
||||
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
|
||||
resolver, loginKey := oauthLoginForProfile(profile)
|
||||
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{
|
||||
OAuthResolver: resolver,
|
||||
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
|
||||
UserAgent: userAgent(),
|
||||
})
|
||||
}
|
||||
|
||||
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
|
||||
|
||||
@@ -53,16 +53,39 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
// SetActiveProvider only ever matches profiles persisted in config.json
|
||||
// (see config.ProviderPersisted), but a provider can be visible in
|
||||
// `zero providers list`/the TUI picker purely because Resolve()
|
||||
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
|
||||
// without ever writing a row to disk. Without this check, switching to
|
||||
// that provider by name always fails with a confusing "not found" even
|
||||
// though it is genuinely usable this session (issue #707).
|
||||
persisted, err := config.ProviderPersisted(configPath, options.name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderUse(stdout, stderr, deps, options, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.SetActiveProvider(configPath, options.name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
override := activeProviderEnvOverride(deps.getenv, cfg.ActiveProvider)
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
payload := map[string]any{
|
||||
"activeProvider": cfg.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
}); err != nil {
|
||||
}
|
||||
if override != "" {
|
||||
// A JSON consumer must not read this as an effective switch either.
|
||||
payload["effectiveProvider"] = override
|
||||
payload["overriddenByEnv"] = config.ActiveProviderEnv
|
||||
}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
@@ -70,9 +93,32 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if _, err := fmt.Fprintf(stdout, "Active provider set to %s\nnext: %s\n", cfg.ActiveProvider, providerCheckCommand(cfg.ActiveProvider, false)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if override != "" {
|
||||
if _, err := fmt.Fprintf(stderr, "Note: %s=%s is set and overrides config.json, so %s stays the active provider until you unset %s.\n", config.ActiveProviderEnv, override, override, config.ActiveProviderEnv); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// activeProviderEnvOverride returns the ZERO_PROVIDER value when it is set and
|
||||
// names a DIFFERENT provider than the one just selected, meaning the saved
|
||||
// `providers use` selection will NOT be the effective active provider until the
|
||||
// env var is unset. applyEnv (resolver.go) makes ZERO_PROVIDER win over
|
||||
// config.json unconditionally, so reporting the write as a plain success reads as
|
||||
// a switch that silently has no effect (issue #721). Empty when nothing overrides
|
||||
// (including when getenv is nil, e.g. a test that did not inject the environment).
|
||||
func activeProviderEnvOverride(getenv func(string) string, selected string) string {
|
||||
if getenv == nil {
|
||||
return ""
|
||||
}
|
||||
override := strings.TrimSpace(getenv(config.ActiveProviderEnv))
|
||||
if override == "" || strings.EqualFold(override, strings.TrimSpace(selected)) {
|
||||
return ""
|
||||
}
|
||||
return override
|
||||
}
|
||||
|
||||
func runProvidersSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderSetupArgs(args)
|
||||
if err != nil {
|
||||
@@ -352,6 +398,22 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
name := options.names[0]
|
||||
// RemoveProvider only ever matches profiles persisted in config.json (see
|
||||
// config.ProviderPersisted), but a provider can be visible in
|
||||
// `zero providers list`/the TUI picker purely because Resolve()
|
||||
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
|
||||
// without ever writing a row to disk. Without this check, deleting that
|
||||
// provider by name always fails with a confusing "not found" even though
|
||||
// it is genuinely visible/usable this session (issue #707).
|
||||
persisted, err := config.ProviderPersisted(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderRemove(stdout, stderr, deps, name, options.json, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.RemoveProvider(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -428,6 +490,16 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
oldName := options.names[0]
|
||||
persisted, err := config.ProviderPersisted(configPath, oldName)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderRename(stdout, stderr, deps, oldName, options.json, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -447,3 +519,120 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// providerResolvedByName reports whether name matches a provider in a
|
||||
// resolved provider list — used to tell a genuinely unknown name (a typo)
|
||||
// apart from a real, env-derived provider that just has no config.json row.
|
||||
func providerResolvedByName(providers []config.ProviderProfile, name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, provider := range providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reportUnpersistedProviderUse handles `zero providers use <name>` for a
|
||||
// provider that is not persisted in config.json. If it's not resolvable at
|
||||
// all (an unknown/misspelled name), it returns handled=false so the caller
|
||||
// falls through to SetActiveProvider's real "not found" error. If it IS
|
||||
// resolvable — an env-derived profile (e.g. ambient OPENAI_API_KEY) —
|
||||
// SetActiveProvider would only ever fail "not found" against it, so this
|
||||
// reports the situation plainly instead of that confusing error (issue
|
||||
// #707).
|
||||
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
// resolveCommandCenterConfig already wrote its own error to stderr;
|
||||
// stop here instead of letting the caller try SetActiveProvider too.
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, options.name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to switch to.\nIt is available whenever its environment variable is set, but is only active when selected (for example via ZERO_PROVIDER); unset its environment variable to stop Zero from detecting it automatically.",
|
||||
options.name,
|
||||
)
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"activeProvider": resolved.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
|
||||
// reportUnpersistedProviderRemove handles `zero providers remove <name>` for
|
||||
// a provider that is not persisted in config.json, mirroring the TUI
|
||||
// provider manager's delete handling (internal/tui/provider_manager.go). If
|
||||
// name isn't resolvable at all, it returns handled=false so the caller falls
|
||||
// through to RemoveProvider's real "not found" error.
|
||||
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable) — nothing to remove there.\nUnset its environment variable to stop Zero from detecting it automatically.",
|
||||
name,
|
||||
)
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"removed": "",
|
||||
"keyRemoved": false,
|
||||
"activeProvider": resolved.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
|
||||
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to rename.",
|
||||
name,
|
||||
)
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"renamed": nil,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
@@ -75,6 +75,207 @@ func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func providersUseOverrideConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{
|
||||
ActiveProvider: "work",
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, Model: "gpt-4.1"},
|
||||
{Name: "fast", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", Model: "llama-3.3-70b-versatile"},
|
||||
},
|
||||
})
|
||||
return configPath
|
||||
}
|
||||
|
||||
// The write to config.json still succeeds, but when ZERO_PROVIDER names a
|
||||
// different provider the saved selection is NOT effective, so the command must
|
||||
// warn instead of reporting a silent success (issue #721).
|
||||
func TestRunProvidersUseWarnsWhenEnvOverrides(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := providersUseOverrideConfig(t)
|
||||
deps := providerSetupDeps(configPath)
|
||||
deps.getenv = func(key string) string {
|
||||
if key == config.ActiveProviderEnv {
|
||||
return "work" // ZERO_PROVIDER=work overrides the switch to fast
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if code := runWithDeps([]string{"providers", "use", "fast"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
if cfg := readFileConfig(t, configPath); cfg.ActiveProvider != "fast" {
|
||||
t.Fatalf("ActiveProvider = %q, want fast (the write still lands)", cfg.ActiveProvider)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Active provider set to fast") {
|
||||
t.Fatalf("stdout missing the success line: %q", stdout.String())
|
||||
}
|
||||
note := stderr.String()
|
||||
for _, want := range []string{config.ActiveProviderEnv, "overrides config.json", "work"} {
|
||||
if !strings.Contains(note, want) {
|
||||
t.Fatalf("override note missing %q, got %q", want, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseJSONFlagsEnvOverride(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := providerSetupDeps(providersUseOverrideConfig(t))
|
||||
deps.getenv = func(key string) string {
|
||||
if key == config.ActiveProviderEnv {
|
||||
return "work"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if code := runWithDeps([]string{"providers", "use", "fast", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
EffectiveProvider string `json:"effectiveProvider"`
|
||||
OverriddenByEnv string `json:"overriddenByEnv"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.ActiveProvider != "fast" || payload.EffectiveProvider != "work" || payload.OverriddenByEnv != config.ActiveProviderEnv {
|
||||
t.Fatalf("JSON must flag the env override, got %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// No override note when ZERO_PROVIDER is unset or already names the selection.
|
||||
func TestRunProvidersUseNoWarnWithoutEnvOverride(t *testing.T) {
|
||||
cases := map[string]func(string) string{
|
||||
"env unset": func(string) string { return "" },
|
||||
"env matches fast": func(key string) string {
|
||||
if key == config.ActiveProviderEnv {
|
||||
return "fast"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}
|
||||
for name, getenv := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := providerSetupDeps(providersUseOverrideConfig(t))
|
||||
deps.getenv = getenv
|
||||
if code := runWithDeps([]string{"providers", "use", "fast"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("exit = %d: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected no override note, got %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte("{"), 0o600); err != nil {
|
||||
t.Fatalf("write malformed config: %v", err)
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("exit code = %d, want %d", exitCode, exitCrash)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid config JSON") {
|
||||
t.Fatalf("stderr = %q, want malformed-config error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
Persisted bool `json:"persisted"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.ConfigPath != configPath || payload.Persisted {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Removed string `json:"removed"`
|
||||
KeyRemoved bool `json:"keyRemoved"`
|
||||
Persisted bool `json:"persisted"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Removed != "" || payload.KeyRemoved || payload.Persisted || payload.ConfigPath != configPath {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no saved profile to rename") {
|
||||
t.Fatalf("stdout = %q, want unpersisted explanation", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Renamed *struct{} `json:"renamed"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
Persisted bool `json:"persisted"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Renamed != nil || payload.ConfigPath != configPath || payload.Persisted {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1001,3 +1001,18 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
|
||||
disabled := false
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
|
||||
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
|
||||
}
|
||||
// Omitted (nil) and explicit true keep the default enforcing mode.
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
|
||||
t.Fatal("omitted enabled must not disable the sandbox")
|
||||
}
|
||||
enabled := true
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
|
||||
t.Fatal("enabled:true must not disable the sandbox")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+137
-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,34 +45,150 @@ 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)
|
||||
configureCommandProcess(cmd)
|
||||
// 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
|
||||
|
||||
if err := cmd.Start(); 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()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return stdout.Bytes(), stderr.Bytes(), err
|
||||
case <-timer.C:
|
||||
terminateCommandProcess(cmd)
|
||||
<-done
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
if err != nil {
|
||||
// The shell exited (whether via ErrWaitDelay because a
|
||||
// background descendant kept the inherited stdout/stderr pipes
|
||||
// open, or via a nonzero exit status) but a leftover descendant
|
||||
// may still be running. Terminate is a no-op against an
|
||||
// already-dead tree, so always call it rather than gating on
|
||||
// the specific error to avoid leaking that descendant.
|
||||
proc.Terminate()
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
// 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.snapshot(), stderr.snapshot(), err
|
||||
case <-deadline.C:
|
||||
proc.Terminate()
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -67,7 +70,8 @@ func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10})
|
||||
pidFile := filepath.Join(t.TempDir(), "sleep.pid")
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10, PidFile: pidFile})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
@@ -85,6 +89,175 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
if elapsed > maxElapsed {
|
||||
t.Fatalf("timeout returned after %s, want roughly 5s", elapsed)
|
||||
}
|
||||
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
|
||||
// unblocks once WaitDelay elapses, and that path must still terminate the
|
||||
// leftover child instead of just returning and leaking it.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg.pid")
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
if err == nil {
|
||||
t.Fatal("runProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChildOnFailure covers the same
|
||||
// leaked-descendant scenario as TestLoadProviderCommandTerminatesBackgroundChild,
|
||||
// but with a shell that exits nonzero. Go's exec.Cmd.Wait only returns
|
||||
// exec.ErrWaitDelay when the command itself exited successfully; a nonzero
|
||||
// exit yields the bare *ExitError instead, so the leftover child must still
|
||||
// be terminated on that path.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "boom",
|
||||
ExitCode: 7,
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
_, _, 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)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error = %v, want *exec.ExitError from the nonzero exit", err)
|
||||
}
|
||||
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()
|
||||
|
||||
// The basic timeout fixture is intentionally unsynchronized. On a loaded
|
||||
// Windows runner, the cold PowerShell child can be terminated before it
|
||||
// records its PID; the synchronized background-child tests below retain
|
||||
// the strict PID and liveness checks.
|
||||
if _, err := os.Stat(pidFile); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
func assertProcessTerminated(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
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 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)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("sleeper process %d still alive after timeout", pid)
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandInvalidJSON(t *testing.T) {
|
||||
@@ -118,6 +291,14 @@ type commandScript struct {
|
||||
Stderr string
|
||||
ExitCode int
|
||||
SleepSeconds int
|
||||
PidFile string
|
||||
|
||||
// BackgroundSleepSeconds, if set, spawns a detached child that keeps the
|
||||
// inherited stdout/stderr handles open well after the script itself
|
||||
// exits, simulating a `sleep 600 & exit` style command. BackgroundPidFile
|
||||
// records the detached child's PID.
|
||||
BackgroundSleepSeconds int
|
||||
BackgroundPidFile string
|
||||
}
|
||||
|
||||
func writeCommand(t *testing.T, script commandScript) string {
|
||||
@@ -128,7 +309,11 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
path := filepath.Join(dir, "provider.cmd")
|
||||
lines := []string{"@echo off"}
|
||||
if script.SleepSeconds > 0 {
|
||||
lines = append(lines, "powershell -NoProfile -Command \"Start-Sleep -Seconds "+itoa(script.SleepSeconds)+"\"")
|
||||
sleep := "Start-Sleep -Seconds " + itoa(script.SleepSeconds)
|
||||
if script.PidFile != "" {
|
||||
sleep = "Set-Content -Path '" + psSingleQuote(script.PidFile) + "' -Value $PID -Encoding Ascii; " + sleep
|
||||
}
|
||||
lines = append(lines, "powershell -NoProfile -Command \""+sleep+"\"")
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
lines = append(lines, "echo "+script.Stdout)
|
||||
@@ -136,6 +321,40 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
if script.Stderr != "" {
|
||||
lines = append(lines, "echo "+script.Stderr+" 1>&2")
|
||||
}
|
||||
if script.BackgroundSleepSeconds > 0 {
|
||||
readyFile := script.BackgroundPidFile + ".ready"
|
||||
bgSleep := "Set-Content -LiteralPath '" + psSingleQuote(script.BackgroundPidFile) + "' -Value $PID -Encoding Ascii; " +
|
||||
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
|
||||
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
|
||||
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
|
||||
// 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 {
|
||||
t.Fatalf("write command: %v", err)
|
||||
@@ -146,6 +365,9 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
path := filepath.Join(dir, "provider.sh")
|
||||
lines := []string{"#!/bin/sh"}
|
||||
if script.SleepSeconds > 0 {
|
||||
if script.PidFile != "" {
|
||||
lines = append(lines, "echo $$ > '"+shSingleQuote(script.PidFile)+"'")
|
||||
}
|
||||
lines = append(lines, "sleep "+itoa(script.SleepSeconds))
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
@@ -154,6 +376,10 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
if script.Stderr != "" {
|
||||
lines = append(lines, "printf '%s\\n' '"+script.Stderr+"' >&2")
|
||||
}
|
||||
if script.BackgroundSleepSeconds > 0 {
|
||||
lines = append(lines, "sleep "+itoa(script.BackgroundSleepSeconds)+" &")
|
||||
lines = append(lines, "echo $! > '"+shSingleQuote(script.BackgroundPidFile)+"'")
|
||||
}
|
||||
lines = append(lines, "exit "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o700); err != nil {
|
||||
t.Fatalf("write command: %v", err)
|
||||
@@ -161,6 +387,19 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// psSingleQuote escapes a value for interpolation inside a PowerShell
|
||||
// single-quoted string literal, where a literal quote is doubled.
|
||||
func psSingleQuote(value string) string {
|
||||
return strings.ReplaceAll(value, "'", "''")
|
||||
}
|
||||
|
||||
// shSingleQuote escapes a value for interpolation inside a POSIX shell
|
||||
// single-quoted string literal, where a literal quote must close the
|
||||
// quoted section, emit an escaped quote, then reopen it.
|
||||
func shSingleQuote(value string) string {
|
||||
return strings.ReplaceAll(value, "'", `'\''`)
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
@@ -175,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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
|
||||
// threshold (including to 0, which disables deferral). Use this for programmatic
|
||||
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
|
||||
// "unset" and will not override.
|
||||
func ToolsOverride(deferThreshold int) ToolsConfig {
|
||||
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
|
||||
}
|
||||
|
||||
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
|
||||
// semantic provider/model rules used during resolution. It returns the parsed
|
||||
// config (zero value on parse failure) plus any structured issues. A parse
|
||||
// failure yields a single issue whose Message is the underlying JSON error's
|
||||
// text (already flattened via Error(), not chained — callers cannot recover
|
||||
// the original *json.SyntaxError / *json.UnmarshalTypeError via errors.As).
|
||||
func ValidateFile(path string) (FileConfig, []Issue) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
|
||||
}
|
||||
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Sprintf("invalid config JSON %s: %v", path, err)}}
|
||||
}
|
||||
|
||||
issues := validateSemantics(cfg)
|
||||
issues = append(issues, unknownFieldIssues(data)...)
|
||||
return cfg, issues
|
||||
}
|
||||
|
||||
// SetProviderDescription sets a provider's description VERBATIM — including to
|
||||
// empty. The generic UpsertProvider merge treats empty fields as "leave
|
||||
// unchanged", so clearing a description needs this dedicated setter.
|
||||
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
|
||||
cfg.Providers[index].Description = strings.TrimSpace(description)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
type commandProcess struct {
|
||||
groupID int
|
||||
anchor *exec.Cmd
|
||||
anchorInput io.WriteCloser
|
||||
}
|
||||
|
||||
func terminateCommandProcess(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
// startCommandProcess retains a live process-group leader until cleanup. The
|
||||
// provider shell may be reaped before Wait finishes draining descendant-held
|
||||
// pipes, so its PID cannot safely identify the group after Wait returns.
|
||||
func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) {
|
||||
anchor := exec.Command("sh", "-c", "read _")
|
||||
anchor.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
anchorInput, err := anchor.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
_ = cmd.Process.Kill()
|
||||
if err := anchor.Start(); err != nil {
|
||||
_ = anchorInput.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proc := &commandProcess{groupID: anchor.Process.Pid, anchor: anchor, anchorInput: anchorInput}
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
cmd.SysProcAttr.Pgid = proc.groupID
|
||||
if err := cmd.Start(); err != nil {
|
||||
proc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
func (p *commandProcess) Terminate() {
|
||||
if p.groupID != 0 {
|
||||
_ = syscall.Kill(-p.groupID, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *commandProcess) Close() {
|
||||
if p.anchorInput != nil {
|
||||
_ = p.anchorInput.Close()
|
||||
p.anchorInput = nil
|
||||
}
|
||||
if p.anchor != nil {
|
||||
_ = p.anchor.Wait()
|
||||
p.anchor = nil
|
||||
}
|
||||
p.groupID = 0
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandProcessPreservesSysProcAttr(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
attr := &syscall.SysProcAttr{}
|
||||
cmd.SysProcAttr = attr
|
||||
|
||||
proc, err := startCommandProcess(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("startCommandProcess: %v", err)
|
||||
}
|
||||
defer proc.Close()
|
||||
|
||||
if cmd.SysProcAttr != attr {
|
||||
t.Fatal("startCommandProcess replaced SysProcAttr")
|
||||
}
|
||||
if !cmd.SysProcAttr.Setpgid || cmd.SysProcAttr.Pgid != proc.groupID {
|
||||
t.Fatalf("process group = Setpgid %v, Pgid %d; want true, %d", cmd.SysProcAttr.Setpgid, cmd.SysProcAttr.Pgid, proc.groupID)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandProcessRetainsGroupIdentityAfterProviderWait(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 7")
|
||||
proc, err := startCommandProcess(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("startCommandProcess: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
proc.Terminate()
|
||||
proc.Close()
|
||||
}()
|
||||
|
||||
providerPID := cmd.Process.Pid
|
||||
anchorPID := proc.groupID
|
||||
if err := cmd.Wait(); err == nil {
|
||||
t.Fatal("Wait error = nil, want nonzero exit")
|
||||
}
|
||||
if anchorPID == providerPID {
|
||||
t.Fatalf("group identity reused provider PID %d", providerPID)
|
||||
}
|
||||
if !processAlive(anchorPID) {
|
||||
t.Fatalf("group leader %d exited before cleanup", anchorPID)
|
||||
}
|
||||
|
||||
proc.Terminate()
|
||||
proc.Close()
|
||||
if processAlive(anchorPID) {
|
||||
t.Fatalf("group leader %d still alive after cleanup", anchorPID)
|
||||
}
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || err == syscall.EPERM
|
||||
}
|
||||
@@ -3,23 +3,165 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {}
|
||||
// configureCommandProcess starts the process suspended so it can be
|
||||
// assigned to the job object before its main thread (and therefore any
|
||||
// code it runs) executes. Without this, a fast command can spawn and
|
||||
// detach a descendant before AssignProcessToJobObject runs, letting that
|
||||
// descendant escape the job and survive termination.
|
||||
func configureCommandProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
|
||||
}
|
||||
|
||||
func terminateCommandProcess(cmd *exec.Cmd) {
|
||||
func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) {
|
||||
configureCommandProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return attachCommandProcess(cmd), nil
|
||||
}
|
||||
|
||||
// commandProcess tracks a started provider command so its entire process
|
||||
// tree can be terminated on timeout. It prefers a job object: taskkill /T
|
||||
// walks the tree by parent PID in user space and can miss descendants,
|
||||
// letting them run to completion while Wait blocks on inherited pipes.
|
||||
type commandProcess struct {
|
||||
cmd *exec.Cmd
|
||||
job windows.Handle
|
||||
processHandle windows.Handle
|
||||
}
|
||||
|
||||
func attachCommandProcess(cmd *exec.Cmd) *commandProcess {
|
||||
proc := &commandProcess{cmd: cmd}
|
||||
if cmd.Process == nil {
|
||||
return proc
|
||||
}
|
||||
// The main thread is suspended (see configureCommandProcess); resume it
|
||||
// once we're done attaching, however that turns out. If resuming fails
|
||||
// outright (Toolhelp/OpenThread/ResumeThread errors are otherwise
|
||||
// swallowed), the process would sit suspended forever and never exit on
|
||||
// its own, making the failure look like an ordinary 5s provider-command
|
||||
// timeout. Terminate it immediately instead so the real cause surfaces
|
||||
// quickly and no suspended process is left behind.
|
||||
defer func() {
|
||||
if !resumeMainThread(cmd.Process.Pid) {
|
||||
proc.Terminate()
|
||||
}
|
||||
}()
|
||||
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(cmd.Process.Pid))
|
||||
if err != nil {
|
||||
return proc
|
||||
}
|
||||
proc.processHandle = handle
|
||||
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return proc
|
||||
}
|
||||
if err := windows.AssignProcessToJobObject(job, handle); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return proc
|
||||
}
|
||||
proc.job = job
|
||||
return proc
|
||||
}
|
||||
|
||||
// resumeMainThread resumes every (assumed suspended) thread of pid and
|
||||
// reports whether at least one was actually resumed. Callers must treat a
|
||||
// false result as a failed resume, not a benign no-op: a suspended process
|
||||
// that's never woken up will neither exit nor produce output on its own.
|
||||
func resumeMainThread(pid int) bool {
|
||||
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(snapshot) }()
|
||||
|
||||
var entry windows.ThreadEntry32
|
||||
entry.Size = uint32(unsafe.Sizeof(entry))
|
||||
resumed := false
|
||||
for err := windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) {
|
||||
if entry.OwnerProcessID != uint32(pid) {
|
||||
continue
|
||||
}
|
||||
thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := windows.ResumeThread(thread); err == nil {
|
||||
resumed = true
|
||||
}
|
||||
_ = windows.CloseHandle(thread)
|
||||
}
|
||||
return resumed
|
||||
}
|
||||
|
||||
func (p *commandProcess) Terminate() {
|
||||
if p.job != 0 {
|
||||
// A job handle means the process tree was contained from launch, so
|
||||
// TerminateJobObject alone kills every member atomically. Don't also
|
||||
// fall through to the taskkill/PID path below: by the time
|
||||
// Terminate runs on the ErrWaitDelay path, cmd.Wait has already
|
||||
// reaped the root process, so its PID may have been reused by an
|
||||
// unrelated process, and forcing /T /PID against a reused PID would
|
||||
// kill the wrong tree.
|
||||
_ = windows.TerminateJobObject(p.job, 1)
|
||||
return
|
||||
}
|
||||
taskkill := taskkillPath()
|
||||
_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
|
||||
_ = cmd.Process.Kill()
|
||||
if p.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
if p.processHandle == 0 {
|
||||
// Without a retained identity, never target a numeric PID that may
|
||||
// have been reaped and reused. os.Process.Kill uses Go's original
|
||||
// process state instead.
|
||||
_ = p.cmd.Process.Kill()
|
||||
return
|
||||
}
|
||||
// Fallback for the rare case where job creation or assignment failed:
|
||||
// only invoke taskkill while the identity-preserving handle confirms the
|
||||
// original root is still active. Keeping that handle open through Run
|
||||
// prevents Windows from reusing its PID for an unrelated process.
|
||||
var exitCode uint32
|
||||
if err := windows.GetExitCodeProcess(p.processHandle, &exitCode); err == nil && exitCode == stillActive {
|
||||
taskkill := taskkillPath()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_ = exec.CommandContext(ctx, taskkill, "/T", "/F", "/PID", strconv.Itoa(p.cmd.Process.Pid)).Run()
|
||||
cancel()
|
||||
}
|
||||
_ = windows.TerminateProcess(p.processHandle, 1)
|
||||
}
|
||||
|
||||
// Close releases the retained handles without touching any still-running
|
||||
// descendants: the job carries no KILL_ON_JOB_CLOSE limit, so on the success
|
||||
// path a provider command's detached helpers keep running exactly as they did
|
||||
// before job objects were introduced. Descendant termination happens
|
||||
// explicitly via Terminate, called only on timeout/error.
|
||||
func (p *commandProcess) Close() {
|
||||
if p.job != 0 {
|
||||
_ = windows.CloseHandle(p.job)
|
||||
p.job = 0
|
||||
}
|
||||
if p.processHandle != 0 {
|
||||
_ = windows.CloseHandle(p.processHandle)
|
||||
p.processHandle = 0
|
||||
}
|
||||
}
|
||||
|
||||
const stillActive = uint32(259) // STILL_ACTIVE
|
||||
|
||||
func taskkillPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// TestResumeMainThreadReportsFailureForUnknownPID covers the contract
|
||||
// attachCommandProcess relies on: when no thread belonging to pid can be
|
||||
// found (here, because the pid doesn't correspond to a live process),
|
||||
// resumeMainThread must report false rather than silently doing nothing,
|
||||
// so a caller can react (e.g. terminate the process) instead of leaving it
|
||||
// suspended indefinitely.
|
||||
func TestResumeMainThreadReportsFailureForUnknownPID(t *testing.T) {
|
||||
if resumeMainThread(0) {
|
||||
t.Fatal("resumeMainThread(0) = true, want false for a pid with no threads")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandProcessRetainsIdentityAfterWait(t *testing.T) {
|
||||
cmd := exec.Command("cmd", "/C", "exit /b 0")
|
||||
configureCommandProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
proc := attachCommandProcess(cmd)
|
||||
t.Cleanup(proc.Close)
|
||||
if proc.processHandle == 0 {
|
||||
t.Fatal("attachCommandProcess did not retain a process handle")
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(proc.processHandle, &code); err != nil {
|
||||
t.Fatalf("GetExitCodeProcess after Wait: %v", err)
|
||||
}
|
||||
if code == stillActive {
|
||||
t.Fatalf("exit code = STILL_ACTIVE after Wait")
|
||||
}
|
||||
|
||||
proc.Close()
|
||||
if proc.job != 0 || proc.processHandle != 0 {
|
||||
t.Fatalf("Close left handles open: job=%v process=%v", proc.job, proc.processHandle)
|
||||
}
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
|
||||
return false
|
||||
}
|
||||
return code == stillActive
|
||||
}
|
||||
@@ -93,6 +93,16 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
// Sandbox.Enabled is NOT accepted from a provider command, for the same
|
||||
// reason project config cannot set it (see mergeProjectConfig): only
|
||||
// global config and the CLI may turn the sandbox off. A provider command
|
||||
// is an arbitrary executable named in config, and LoadProviderCommand
|
||||
// parses its stdout into a full FileConfig — so without this, a command
|
||||
// returning a valid provider plus {"sandbox":{"enabled":false}} would
|
||||
// disable the very sandbox meant to constrain what it can do. Cleared
|
||||
// here rather than in mergeConfig because that helper is shared with the
|
||||
// trusted global-config merge, which must keep honouring the setting.
|
||||
commandConfig.Sandbox.Enabled = nil
|
||||
mergeConfig(&cfg, commandConfig)
|
||||
}
|
||||
|
||||
@@ -216,6 +226,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP, true)
|
||||
if src.Sandbox.Enabled != nil {
|
||||
dst.Sandbox.Enabled = src.Sandbox.Enabled
|
||||
}
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
@@ -273,6 +286,10 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
|
||||
return err
|
||||
}
|
||||
// Sandbox.Enabled is intentionally NOT merged from project config: a cloned
|
||||
// repo's .zero/config.json must not be able to disable the sandbox that
|
||||
// constrains it. Only global config and CLI can turn the sandbox off.
|
||||
//
|
||||
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
@@ -688,6 +705,9 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if overrides.MaxTurns > 0 {
|
||||
cfg.MaxTurns = overrides.MaxTurns
|
||||
}
|
||||
if overrides.Sandbox.Enabled != nil {
|
||||
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
|
||||
}
|
||||
if overrides.Sandbox.BlockUnixSockets {
|
||||
cfg.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
|
||||
@@ -1744,6 +1744,85 @@ func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledUserConfigDisables(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
|
||||
t.Fatalf("user config enabled:false must resolve to a false pointer, got %v", resolved.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) {
|
||||
// Security: a cloned repo's project config must NOT be able to disable the
|
||||
// sandbox that constrains it. Only global config / CLI can turn it off.
|
||||
userPath := writeConfig(t, `{}`)
|
||||
projectPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled != nil {
|
||||
t.Fatalf("project config enabled:false must be ignored, got %v — a repo could disable the sandbox", *resolved.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledIgnoredFromProviderCommand(t *testing.T) {
|
||||
// Security: a provider command is an arbitrary executable named in config, and
|
||||
// its stdout is parsed into a full FileConfig — so it must NOT be able to reach
|
||||
// Sandbox.Enabled, in either direction. Same rule as project config: only global
|
||||
// config / CLI may turn the sandbox off.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "cannot disable", value: "false"},
|
||||
{name: "cannot enable", value: "true"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"sandbox":{"enabled":` + tc.value + `}}`,
|
||||
})
|
||||
resolved, err := Resolve(ResolveOptions{ProviderCommand: command, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
// The provider itself must still be applied — only the sandbox toggle is dropped.
|
||||
if resolved.ActiveProvider != "cmd" {
|
||||
t.Fatalf("ActiveProvider = %q, want cmd (provider command must still apply)", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Sandbox.Enabled != nil {
|
||||
t.Fatalf("provider command set sandbox.enabled=%s, got %v — a provider command could toggle the sandbox", tc.value, *resolved.Sandbox.Enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledCLIOverride(t *testing.T) {
|
||||
// A CLI/programmatic override (applied after every config source) must be able
|
||||
// to disable the sandbox, even with no config file.
|
||||
disabled := false
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &disabled}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
|
||||
t.Fatalf("CLI override enabled:false must disable the sandbox, got %v", resolved.Sandbox.Enabled)
|
||||
}
|
||||
// An explicit true override must win over a lower-precedence global-config false.
|
||||
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
enabled := true
|
||||
resolved2, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &enabled}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved2.Sandbox.Enabled == nil || !*resolved2.Sandbox.Enabled {
|
||||
t.Fatalf("CLI override enabled:true must win over config false, got %v", resolved2.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyValid(t *testing.T) {
|
||||
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
|
||||
@@ -62,6 +62,12 @@ func HasProviderProfile(profile ProviderProfile) bool {
|
||||
}
|
||||
|
||||
type SandboxConfig struct {
|
||||
// Enabled turns the sandbox off when set to false (`"sandbox": {"enabled":
|
||||
// false}`). A pointer distinguishes an explicit false (disable) from an
|
||||
// omitted key (keep the default: enabled). Honored from the GLOBAL user
|
||||
// config and CLI only — deliberately NOT project config, so a cloned repo
|
||||
// cannot disable the sandbox that constrains it. nil / true keep enforcement.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// Network controls whether shell commands classified as network-touching
|
||||
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
|
||||
// Empty keeps the built-in default (deny). Without this knob the engine's
|
||||
@@ -316,14 +322,6 @@ type SwarmConfig struct {
|
||||
MaxTeamSize int `json:"maxTeamSize,omitempty"`
|
||||
}
|
||||
|
||||
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
|
||||
// threshold (including to 0, which disables deferral). Use this for programmatic
|
||||
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
|
||||
// "unset" and will not override.
|
||||
func ToolsOverride(deferThreshold int) ToolsConfig {
|
||||
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
|
||||
}
|
||||
|
||||
func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawTools struct {
|
||||
DeferThreshold *int `json:"deferThreshold"`
|
||||
|
||||
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Issue is a single structured problem found while validating a config file.
|
||||
@@ -13,28 +12,6 @@ type Issue struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
|
||||
// semantic provider/model rules used during resolution. It returns the parsed
|
||||
// config (zero value on parse failure) plus any structured issues. A parse
|
||||
// failure yields a single issue whose Message wraps the underlying JSON error
|
||||
// so callers can extract *json.SyntaxError / *json.UnmarshalTypeError offsets
|
||||
// via errors.As.
|
||||
func ValidateFile(path string) (FileConfig, []Issue) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
|
||||
}
|
||||
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Errorf("invalid config JSON %s: %w", path, err).Error()}}
|
||||
}
|
||||
|
||||
issues := validateSemantics(cfg)
|
||||
issues = append(issues, unknownFieldIssues(data)...)
|
||||
return cfg, issues
|
||||
}
|
||||
|
||||
// ValidateBytes parses data as a Zero FileConfig and runs the same semantic
|
||||
// provider/model rules as ValidateFile. It returns the parsed config (zero
|
||||
// value on parse failure) plus any structured issues. A parse failure yields a
|
||||
|
||||
+27
-33
@@ -177,6 +177,33 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// ProviderPersisted reports whether a provider profile named name actually has
|
||||
// a row in the config file at path. A provider can appear in the resolved/
|
||||
// in-memory provider list without ever being written to config.json — e.g.
|
||||
// applyProviderEnv synthesizes an "openai" profile purely from an ambient
|
||||
// OPENAI_API_KEY environment variable on every Resolve() call, without ever
|
||||
// persisting it. RemoveProvider/SetActiveProvider/SetProviderModel only ever
|
||||
// look at what's on disk, so a caller offering to mutate a provider by name
|
||||
// should check this first: "not on disk" needs different handling (nothing to
|
||||
// persist/remove there) than a name that doesn't exist anywhere at all.
|
||||
func ProviderPersisted(path string, name string) (bool, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
name = strings.TrimSpace(name)
|
||||
if path == "" || name == "" {
|
||||
return false, nil
|
||||
}
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, provider := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RemoveProvider deletes the named provider profile from the config at path.
|
||||
// When the removed profile was active, activeProvider hands off to the first
|
||||
// remaining provider (or clears when none remain) so the config never points at
|
||||
@@ -404,39 +431,6 @@ func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetProviderDescription sets a provider's description VERBATIM — including to
|
||||
// empty. The generic UpsertProvider merge treats empty fields as "leave
|
||||
// unchanged", so clearing a description needs this dedicated setter.
|
||||
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
|
||||
cfg.Providers[index].Description = strings.TrimSpace(description)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// migrateStoredProviderKey moves a credential-store entry to a new provider
|
||||
// name: write-new-then-delete-old, so an interruption can leave a duplicate but
|
||||
// never a missing key. A missing source entry is a no-op (the marker may be
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
|
||||
writeTestFile(t, root, "AGENTS.md", strings.Repeat("project rules\n", 200))
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestBuildHasStableJSONContractAndCategoryMath(t *testing.T) {
|
||||
writeTestFile(t, root, "ZERO.md", strings.Repeat("zero rules\n", 16))
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package cron
|
||||
|
||||
func (s Schedule) String() string { return s.expr }
|
||||
@@ -23,8 +23,6 @@ type Schedule struct {
|
||||
expr string
|
||||
}
|
||||
|
||||
func (s Schedule) String() string { return s.expr }
|
||||
|
||||
// full returns a bitset with every value in [min,max] set.
|
||||
func full(min, max int) uint64 {
|
||||
var m uint64
|
||||
|
||||
+42
-7
@@ -99,35 +99,60 @@ func (s *Store) Add(job Job) (Job, error) {
|
||||
job.Status = StatusActive
|
||||
}
|
||||
job.CreatedAt = s.now().UTC()
|
||||
id, err := s.allocID()
|
||||
id, unlock, err := s.reserveID()
|
||||
if err != nil {
|
||||
return Job{}, err
|
||||
}
|
||||
// Held from reservation through initial persistence: without this, Remove
|
||||
// (which takes the same per-ID lock before deleting) could delete the
|
||||
// bare reservation directory the instant after Mkdir and report success,
|
||||
// then writeJob's MkdirAll would silently resurrect it and persist the
|
||||
// job anyway — or a second Add could re-reserve the freed id while this
|
||||
// writer is still running, putting two writers on the same metadata path.
|
||||
defer unlock()
|
||||
job.ID = id
|
||||
if err := s.writeJob(job); err != nil {
|
||||
if err := s.writeReservedJob(job); err != nil {
|
||||
return Job{}, err
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) allocID() (string, error) {
|
||||
// reserveID atomically reserves an unused job id (by os.Mkdir-ing its
|
||||
// directory) and returns the per-ID lock held for that id, still acquired.
|
||||
// The caller must release it (via the returned unlock) only once the
|
||||
// reservation has been committed (metadata.json written) or torn down —
|
||||
// see Add.
|
||||
func (s *Store) reserveID() (string, func(), error) {
|
||||
if err := os.MkdirAll(s.root, 0o700); err != nil {
|
||||
return "", nil, fmt.Errorf("create cron store root: %w", err)
|
||||
}
|
||||
base := s.now().UTC().Format("20060102-150405")
|
||||
for n := 0; n < 100; n++ {
|
||||
id := base
|
||||
if n > 0 {
|
||||
id = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.root, id)); errors.Is(err, os.ErrNotExist) {
|
||||
return id, nil
|
||||
unlock, err := s.lockJob(id)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
mkErr := os.Mkdir(s.jobDir(id), 0o700)
|
||||
if mkErr == nil {
|
||||
return id, unlock, nil
|
||||
}
|
||||
unlock()
|
||||
if errors.Is(mkErr, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return "", nil, fmt.Errorf("reserve cron job id %q: %w", id, mkErr)
|
||||
}
|
||||
return "", errors.New("could not allocate a unique cron job id")
|
||||
return "", nil, errors.New("could not allocate a unique cron job id")
|
||||
}
|
||||
|
||||
func (s *Store) jobDir(id string) string { return filepath.Join(s.root, id) }
|
||||
|
||||
// validID rejects ids that could escape the store root (path separators or
|
||||
// traversal). allocID-generated timestamp ids always pass; this guards
|
||||
// traversal). reserveID-generated timestamp ids always pass; this guards
|
||||
// externally-supplied ids (get/update/remove/append).
|
||||
func validID(id string) bool {
|
||||
return id != "" && id != "." && id != ".." && !strings.ContainsAny(id, `/\`) && filepath.Base(id) == id
|
||||
@@ -153,6 +178,16 @@ func (s *Store) writeJob(job Job) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) writeReservedJob(job Job) error {
|
||||
if err := s.writeJob(job); err != nil {
|
||||
if cleanupErr := os.RemoveAll(s.jobDir(job.ID)); cleanupErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("remove failed cron job reservation: %w", cleanupErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (Job, error) {
|
||||
if !validID(id) {
|
||||
return Job{}, fmt.Errorf("invalid cron job id %q", id)
|
||||
|
||||
@@ -2,9 +2,11 @@ package cron
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -74,6 +76,160 @@ func TestStoreAddListGetRemove(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAddReservesUniqueIDsAcrossConcurrentStores(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
now := func() time.Time { return time.Unix(1000, 0).UTC() }
|
||||
const adds = 32
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan Job, adds)
|
||||
errs := make(chan error, adds)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < adds; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
store := NewStore(StoreOptions{RootDir: root, Now: now})
|
||||
<-start
|
||||
job, err := store.Add(Job{Expr: "* * * * *", Prompt: fmt.Sprintf("job-%d", index)})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
results <- job
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Errorf("concurrent Add: %v", err)
|
||||
}
|
||||
seen := make(map[string]struct{}, adds)
|
||||
reader := NewStore(StoreOptions{RootDir: root, Now: now})
|
||||
for job := range results {
|
||||
if _, duplicate := seen[job.ID]; duplicate {
|
||||
t.Errorf("concurrent Add returned duplicate ID %q", job.ID)
|
||||
}
|
||||
seen[job.ID] = struct{}{}
|
||||
persisted, err := reader.Get(job.ID)
|
||||
if err != nil {
|
||||
t.Errorf("Get(%q): %v", job.ID, err)
|
||||
continue
|
||||
}
|
||||
if persisted.Prompt != job.Prompt {
|
||||
t.Errorf("Get(%q).Prompt = %q, want %q", job.ID, persisted.Prompt, job.Prompt)
|
||||
}
|
||||
}
|
||||
if len(seen) != adds {
|
||||
t.Fatalf("unique jobs = %d, want %d", len(seen), adds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddDoesNotLeakReservationToConcurrentRemove reproduces the audit
|
||||
// finding that Remove could observe and delete an in-flight Add's bare
|
||||
// reservation directory (Mkdir'd but not yet holding metadata.json) and
|
||||
// report success — after which Add's writeJob would silently resurrect the
|
||||
// directory and persist the job anyway. Add now holds the same per-ID lock
|
||||
// Remove takes, from reservation through initial persistence, so a
|
||||
// concurrent Remove for that id must wait until the reservation is either
|
||||
// committed or torn down, never observing the bare reservation in between.
|
||||
func TestAddDoesNotLeakReservationToConcurrentRemove(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
// Simulate the in-flight window Add now holds the lock across: the id is
|
||||
// reserved (its directory exists) but metadata.json has not been written.
|
||||
id, unlock, err := store.reserveID()
|
||||
if err != nil {
|
||||
t.Fatalf("reserveID: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(store.jobDir(id)); statErr != nil {
|
||||
t.Fatalf("reservation directory missing: %v", statErr)
|
||||
}
|
||||
|
||||
removeErr := make(chan error, 1)
|
||||
go func() {
|
||||
removeErr <- store.Remove(id)
|
||||
}()
|
||||
|
||||
// Remove cannot proceed past the shared per-ID lock we're holding, so it's
|
||||
// safe to commit the job here, before releasing it: if Remove could race
|
||||
// ahead of this write, it would delete the bare reservation instead of
|
||||
// waiting to observe (and remove) the committed job.
|
||||
job := Job{ID: id, Expr: "* * * * *", Prompt: "job", Status: StatusActive}
|
||||
if err := store.writeJob(job); err != nil {
|
||||
t.Fatalf("writeJob: %v", err)
|
||||
}
|
||||
unlock()
|
||||
|
||||
if err := <-removeErr; err != nil {
|
||||
t.Fatalf("Remove of the committed job: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.jobDir(id)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("job directory should be gone after Remove: %v", err)
|
||||
}
|
||||
if _, err := store.Get(id); !errors.Is(err, ErrJobNotFound) {
|
||||
t.Fatalf("Get after Remove = %v, want ErrJobNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveIDSerializesOnPerIDLock covers the other half of the audit
|
||||
// finding: a second Add attempting the SAME candidate id while the first
|
||||
// reservation is still held must block on the shared per-ID lock (not just
|
||||
// race the directory Mkdir), so it never ends up writing metadata.json for
|
||||
// an id whose first writer is still in flight. It moves on to the next
|
||||
// candidate id instead.
|
||||
func TestReserveIDSerializesOnPerIDLock(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
id, unlock, err := store.reserveID()
|
||||
if err != nil {
|
||||
t.Fatalf("first reserveID: %v", err)
|
||||
}
|
||||
|
||||
type reservation struct {
|
||||
id string
|
||||
err error
|
||||
}
|
||||
second := make(chan reservation, 1)
|
||||
go func() {
|
||||
id2, unlock2, err := store.reserveID()
|
||||
if err == nil {
|
||||
unlock2()
|
||||
}
|
||||
second <- reservation{id: id2, err: err}
|
||||
}()
|
||||
|
||||
// The second reserveID cannot observe an id until this lock is released:
|
||||
// it contends on the same per-ID lock file before ever attempting Mkdir.
|
||||
unlock()
|
||||
|
||||
got := <-second
|
||||
if got.err != nil {
|
||||
t.Fatalf("second reserveID: %v", got.err)
|
||||
}
|
||||
if got.id == id {
|
||||
t.Fatalf("second reserveID returned the first id %q while its reservation was still held", got.id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReservedJobRemovesReservationOnFailure(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job := Job{ID: "reserved", Expr: "* * * * *", Prompt: "job"}
|
||||
if err := os.MkdirAll(filepath.Join(store.jobDir(job.ID), "metadata.json.tmp"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := store.writeReservedJob(job); err == nil {
|
||||
t.Fatal("writeReservedJob error = nil, want metadata write failure")
|
||||
}
|
||||
if _, err := os.Stat(store.jobDir(job.ID)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("failed reservation still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendRun(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
job, _ := s.Add(Job{Expr: "* * * * *", Prompt: "x", Status: StatusActive})
|
||||
|
||||
@@ -37,16 +37,6 @@ const (
|
||||
StreamProviderOpenAI = "openai"
|
||||
)
|
||||
|
||||
// BatchProviders lists valid stt.provider values, for config validation.
|
||||
func BatchProviders() []string {
|
||||
return []string{ProviderLocal, ProviderGroq, ProviderOpenAI}
|
||||
}
|
||||
|
||||
// StreamProviders lists valid stt.streamProvider values, for config validation.
|
||||
func StreamProviders() []string {
|
||||
return []string{StreamProviderLocal, StreamProviderDeepgram, StreamProviderOpenAI}
|
||||
}
|
||||
|
||||
// AudioFormat identifies a recorded container format, sniffed from the bytes —
|
||||
// desktop recorders produce WAV, Termux's recorder produces M4A/AAC.
|
||||
type AudioFormat string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxObservedFiles = 20_000
|
||||
maxHashedFileBytes = 1 << 20
|
||||
maxTotalHashedBytes = 32 << 20
|
||||
)
|
||||
|
||||
type ChangeKind string
|
||||
|
||||
const (
|
||||
ChangeCreated ChangeKind = "created"
|
||||
ChangeModified ChangeKind = "modified"
|
||||
ChangeDeleted ChangeKind = "deleted"
|
||||
)
|
||||
|
||||
type Change struct {
|
||||
Path string `json:"path"`
|
||||
Kind ChangeKind `json:"kind"`
|
||||
Aggregated bool `json:"aggregated,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type fileFingerprint struct {
|
||||
Mode fs.FileMode
|
||||
Size int64
|
||||
ModTime int64
|
||||
Digest [sha256.Size]byte
|
||||
Hashed bool
|
||||
// Aggregated marks a bounded fingerprint for a generated directory. The
|
||||
// observer never enumerates that tree into individual changes.
|
||||
Aggregated bool
|
||||
Count int
|
||||
}
|
||||
|
||||
// ChangeObserver records a bounded workspace snapshot around a command. It
|
||||
// deliberately skips Zero/repository control metadata and large generated
|
||||
// dependency trees; those paths must neither be read as command evidence nor
|
||||
// flood the Files panel.
|
||||
type ChangeObserver struct {
|
||||
root string
|
||||
before map[string]fileFingerprint
|
||||
valid bool
|
||||
}
|
||||
|
||||
func NewChangeObserver(root string) *ChangeObserver {
|
||||
root = filepath.Clean(strings.TrimSpace(root))
|
||||
before, ok := snapshotWorkspace(root)
|
||||
return &ChangeObserver{root: root, before: before, valid: ok}
|
||||
}
|
||||
|
||||
func (observer *ChangeObserver) Changes() []Change {
|
||||
if observer == nil || !observer.valid {
|
||||
return nil
|
||||
}
|
||||
after, ok := snapshotWorkspace(observer.root)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
changes := make([]Change, 0)
|
||||
for path, current := range after {
|
||||
previous, existed := observer.before[path]
|
||||
if !existed {
|
||||
changes = append(changes, changeFromFingerprint(path, ChangeCreated, current))
|
||||
continue
|
||||
}
|
||||
if previous != current {
|
||||
changes = append(changes, changeFromFingerprint(path, ChangeModified, current))
|
||||
}
|
||||
}
|
||||
for path, previous := range observer.before {
|
||||
if _, exists := after[path]; !exists {
|
||||
changes = append(changes, changeFromFingerprint(path, ChangeDeleted, previous))
|
||||
}
|
||||
}
|
||||
sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path })
|
||||
return changes
|
||||
}
|
||||
|
||||
func snapshotWorkspace(root string) (map[string]fileFingerprint, bool) {
|
||||
if root == "" || root == "." {
|
||||
return nil, false
|
||||
}
|
||||
files := make(map[string]fileFingerprint)
|
||||
hashedBytes := int64(0)
|
||||
ok := true
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return nil
|
||||
}
|
||||
if path == root {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if protectedObservationDirectory(entry.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if generatedObservationDirectory(entry.Name()) {
|
||||
files[filepath.ToSlash(relative)+"/"] = generatedTreeFingerprint(path)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !entry.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if len(files) >= maxObservedFiles {
|
||||
ok = false
|
||||
return fs.SkipAll
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fingerprint := fileFingerprint{Mode: info.Mode(), Size: info.Size(), ModTime: info.ModTime().UnixNano()}
|
||||
if info.Size() <= maxHashedFileBytes && hashedBytes+info.Size() <= maxTotalHashedBytes {
|
||||
if digest, err := hashObservedFile(path); err == nil {
|
||||
fingerprint.Digest = digest
|
||||
fingerprint.Hashed = true
|
||||
hashedBytes += info.Size()
|
||||
}
|
||||
}
|
||||
files[filepath.ToSlash(relative)] = fingerprint
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return files, ok
|
||||
}
|
||||
|
||||
func protectedObservationDirectory(name string) bool {
|
||||
switch name {
|
||||
case ".git", ".zero", ".agents":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func generatedObservationDirectory(name string) bool {
|
||||
switch name {
|
||||
case "node_modules", ".pnpm-store", ".yarn", "dist", "coverage", ".cache":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// generatedTreeFingerprint samples only direct children and caps the work. It
|
||||
// is intentionally qualitative: the UI needs to say that a generated tree
|
||||
// changed, not inventory dependency contents.
|
||||
func generatedTreeFingerprint(root string) fileFingerprint {
|
||||
directory, err := os.Open(root)
|
||||
if err != nil {
|
||||
return fileFingerprint{Aggregated: true, Count: -1}
|
||||
}
|
||||
defer directory.Close()
|
||||
const maxEntries = 256
|
||||
entries, err := directory.ReadDir(maxEntries + 1)
|
||||
if err != nil && err != io.EOF {
|
||||
return fileFingerprint{Aggregated: true, Count: -1}
|
||||
}
|
||||
hash := sha256.New()
|
||||
count := len(entries)
|
||||
if count > maxEntries {
|
||||
entries = entries[:maxEntries]
|
||||
count = -1
|
||||
}
|
||||
for _, entry := range entries {
|
||||
_, _ = io.WriteString(hash, entry.Name())
|
||||
if info, infoErr := entry.Info(); infoErr == nil {
|
||||
_, _ = io.WriteString(hash, info.Mode().String())
|
||||
_, _ = io.WriteString(hash, strconv.FormatInt(info.ModTime().UnixNano(), 10))
|
||||
}
|
||||
}
|
||||
var digest [sha256.Size]byte
|
||||
copy(digest[:], hash.Sum(nil))
|
||||
return fileFingerprint{Digest: digest, Hashed: true, Aggregated: true, Count: count}
|
||||
}
|
||||
|
||||
func changeFromFingerprint(path string, kind ChangeKind, fingerprint fileFingerprint) Change {
|
||||
return Change{Path: path, Kind: kind, Aggregated: fingerprint.Aggregated, Count: fingerprint.Count}
|
||||
}
|
||||
|
||||
func hashObservedFile(path string) ([sha256.Size]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
var digest [sha256.Size]byte
|
||||
copy(digest[:], hash.Sum(nil))
|
||||
return digest, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChangeObserverReportsCreateModifyAndDelete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
modified := filepath.Join(root, "modified.txt")
|
||||
deleted := filepath.Join(root, "deleted.txt")
|
||||
if err := os.WriteFile(modified, []byte("before"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(deleted, []byte("delete"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observer := NewChangeObserver(root)
|
||||
if err := os.WriteFile(modified, []byte("after!"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(deleted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "src", "created.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Change{
|
||||
{Path: "deleted.txt", Kind: ChangeDeleted},
|
||||
{Path: "modified.txt", Kind: ChangeModified},
|
||||
{Path: "src/created.ts", Kind: ChangeCreated},
|
||||
}
|
||||
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Changes() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeObserverSkipsControlMetadataAndSummarizesGeneratedTrees(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
observer := NewChangeObserver(root)
|
||||
for _, path := range []string{
|
||||
filepath.Join(root, ".zero", "config.json"),
|
||||
filepath.Join(root, ".agents", "skills", "x"),
|
||||
filepath.Join(root, ".git", "config"),
|
||||
filepath.Join(root, "node_modules", "pkg", "index.js"),
|
||||
filepath.Join(root, "dist", "bundle.js"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := []Change{
|
||||
{Path: "dist/", Kind: ChangeCreated, Aggregated: true, Count: 1},
|
||||
{Path: "node_modules/", Kind: ChangeCreated, Aggregated: true, Count: 1},
|
||||
}
|
||||
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Changes() = %#v, want generated summaries without control metadata %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Package execution defines the platform-neutral command execution contract.
|
||||
// Frontends and tools use these types without depending on native sandbox or
|
||||
// process-management details.
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const PolicyVersion = 1
|
||||
|
||||
type Origin string
|
||||
|
||||
const (
|
||||
OriginInteractiveCommand Origin = "interactive_command"
|
||||
OriginHook Origin = "hook"
|
||||
OriginPlugin Origin = "plugin"
|
||||
OriginMCPServer Origin = "mcp_server"
|
||||
OriginBackgroundTask Origin = "background_task"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeCaptured Mode = "captured"
|
||||
ModeInteractive Mode = "interactive"
|
||||
ModeDurable Mode = "durable"
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
Name string `json:"name"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
type CapabilityKind string
|
||||
|
||||
const (
|
||||
CapabilityRead CapabilityKind = "filesystem_read"
|
||||
CapabilityWorkspaceWrite CapabilityKind = "workspace_write"
|
||||
CapabilityProtectedMetadata CapabilityKind = "protected_metadata"
|
||||
CapabilityExternalNetwork CapabilityKind = "external_network"
|
||||
CapabilityIsolatedLoopback CapabilityKind = "isolated_loopback"
|
||||
CapabilityHostVisibleBinding CapabilityKind = "host_visible_binding"
|
||||
CapabilityProcessSpawn CapabilityKind = "process_spawn"
|
||||
CapabilityProcessControl CapabilityKind = "process_control"
|
||||
CapabilityEnvironment CapabilityKind = "environment"
|
||||
CapabilityUnrestricted CapabilityKind = "unrestricted"
|
||||
)
|
||||
|
||||
type Capability struct {
|
||||
Kind CapabilityKind `json:"kind"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
type ApprovalContext struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
PolicyVersion int `json:"policyVersion"`
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Origin Origin `json:"origin"`
|
||||
Mode Mode `json:"mode"`
|
||||
Command Command `json:"command"`
|
||||
WorkingDirectory string `json:"workingDirectory"`
|
||||
WorkspaceRoots []string `json:"workspaceRoots"`
|
||||
Capabilities []Capability `json:"capabilities,omitempty"`
|
||||
Approval ApprovalContext `json:"approval"`
|
||||
}
|
||||
|
||||
func (request Request) Validate() error {
|
||||
if !validOrigin(request.Origin) {
|
||||
return errors.New("execution request requires a valid origin")
|
||||
}
|
||||
if request.Mode != ModeCaptured && request.Mode != ModeInteractive && request.Mode != ModeDurable {
|
||||
return errors.New("execution request requires a valid mode")
|
||||
}
|
||||
if strings.TrimSpace(request.Command.Name) == "" {
|
||||
return errors.New("execution request requires a command")
|
||||
}
|
||||
if strings.TrimSpace(request.WorkingDirectory) == "" {
|
||||
return errors.New("execution request requires a working directory")
|
||||
}
|
||||
if len(request.WorkspaceRoots) == 0 {
|
||||
return errors.New("execution request requires at least one workspace root")
|
||||
}
|
||||
for _, root := range request.WorkspaceRoots {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return errors.New("execution request contains an empty workspace root")
|
||||
}
|
||||
}
|
||||
for _, capability := range request.Capabilities {
|
||||
if !validCapabilityKind(capability.Kind) {
|
||||
return fmt.Errorf("execution request contains invalid capability %q", capability.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateRequested State = "requested"
|
||||
StateEvaluated State = "evaluated"
|
||||
StateAwaitingApproval State = "awaiting_approval"
|
||||
StatePrepared State = "prepared"
|
||||
StateRunning State = "running"
|
||||
StateRetained State = "retained"
|
||||
StateCompleted State = "completed"
|
||||
StateDenied State = "denied"
|
||||
StateFailed State = "failed"
|
||||
StateCancelled State = "cancelled"
|
||||
)
|
||||
|
||||
type OutcomeKind string
|
||||
|
||||
const (
|
||||
OutcomeRunning OutcomeKind = "running"
|
||||
OutcomeSuccess OutcomeKind = "success"
|
||||
OutcomePolicyDenied OutcomeKind = "policy_denied"
|
||||
OutcomeEnforcementDenied OutcomeKind = "enforcement_denied"
|
||||
OutcomeApplicationFailure OutcomeKind = "application_failure"
|
||||
OutcomeSandboxSetupFailure OutcomeKind = "sandbox_setup_failure"
|
||||
OutcomeExecutableNotFound OutcomeKind = "executable_not_found"
|
||||
OutcomeCancelled OutcomeKind = "cancelled"
|
||||
OutcomeTimedOut OutcomeKind = "timed_out"
|
||||
OutcomeEnforcementDegraded OutcomeKind = "enforcement_degraded"
|
||||
)
|
||||
|
||||
type Exit struct {
|
||||
Code int `json:"code"`
|
||||
Signal string `json:"signal,omitempty"`
|
||||
}
|
||||
|
||||
type DenialSource string
|
||||
|
||||
const (
|
||||
DenialSourceConfiguredPolicy DenialSource = "configured_policy"
|
||||
DenialSourcePlatformSandbox DenialSource = "platform_sandbox"
|
||||
DenialSourceApproval DenialSource = "approval"
|
||||
)
|
||||
|
||||
type DenialNextAction string
|
||||
|
||||
const (
|
||||
DenialNextActionNone DenialNextAction = "none"
|
||||
DenialNextActionRequestApproval DenialNextAction = "request_approval"
|
||||
DenialNextActionChangeCommand DenialNextAction = "change_command"
|
||||
DenialNextActionEnableSandbox DenialNextAction = "enable_sandbox"
|
||||
)
|
||||
|
||||
type Denial struct {
|
||||
Capability Capability `json:"capability"`
|
||||
Source DenialSource `json:"source"`
|
||||
Reason string `json:"reason"`
|
||||
Recoverable bool `json:"recoverable"`
|
||||
NextAction DenialNextAction `json:"nextAction"`
|
||||
}
|
||||
|
||||
type Enforcement struct {
|
||||
Backend string `json:"backend,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Degraded bool `json:"degraded,omitempty"`
|
||||
DowngradeReason string `json:"downgradeReason,omitempty"`
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
State State `json:"state"`
|
||||
Kind OutcomeKind `json:"kind"`
|
||||
ProcessID string `json:"processId,omitempty"`
|
||||
Exit *Exit `json:"exit,omitempty"`
|
||||
Denial *Denial `json:"denial,omitempty"`
|
||||
Enforcement Enforcement `json:"enforcement"`
|
||||
Changes []Change `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
// AdapterReport is the structured, machine-readable result emitted by a
|
||||
// platform enforcement adapter. It is separate from stdout and stderr so
|
||||
// command text cannot impersonate a policy decision.
|
||||
type AdapterReport struct {
|
||||
Denial *Denial `json:"denial,omitempty"`
|
||||
}
|
||||
|
||||
func (outcome Outcome) Validate() error {
|
||||
if outcome.State == "" || outcome.Kind == "" {
|
||||
return errors.New("execution outcome requires state and kind")
|
||||
}
|
||||
switch outcome.State {
|
||||
case StateRetained, StateRunning:
|
||||
if outcome.Kind != OutcomeRunning || strings.TrimSpace(outcome.ProcessID) == "" {
|
||||
return errors.New("running execution outcome requires a process id")
|
||||
}
|
||||
case StateCompleted:
|
||||
if outcome.Kind != OutcomeSuccess || outcome.Exit == nil || outcome.Exit.Code != 0 {
|
||||
return errors.New("completed execution outcome requires a successful exit")
|
||||
}
|
||||
case StateDenied:
|
||||
if (outcome.Kind != OutcomePolicyDenied && outcome.Kind != OutcomeEnforcementDenied) || outcome.Denial == nil {
|
||||
return errors.New("denied execution outcome requires structured denial details")
|
||||
}
|
||||
if err := outcome.Denial.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
case StateFailed:
|
||||
if outcome.Kind == OutcomeRunning || outcome.Kind == OutcomeSuccess || outcome.Exit == nil {
|
||||
return errors.New("failed execution outcome requires a failure kind and exit")
|
||||
}
|
||||
case StateCancelled:
|
||||
if outcome.Kind != OutcomeCancelled {
|
||||
return errors.New("cancelled execution outcome requires cancelled kind")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("execution outcome state %q is not terminal or externally observable", outcome.State)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (denial Denial) Validate() error {
|
||||
if !validCapabilityKind(denial.Capability.Kind) {
|
||||
return errors.New("execution denial requires a valid capability")
|
||||
}
|
||||
if denial.Source == "" || strings.TrimSpace(denial.Reason) == "" || denial.NextAction == "" {
|
||||
return errors.New("execution denial requires source, reason, and next action")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validOrigin(origin Origin) bool {
|
||||
switch origin {
|
||||
case OriginInteractiveCommand, OriginHook, OriginPlugin, OriginMCPServer, OriginBackgroundTask:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validCapabilityKind(kind CapabilityKind) bool {
|
||||
switch kind {
|
||||
case CapabilityRead, CapabilityWorkspaceWrite, CapabilityProtectedMetadata,
|
||||
CapabilityExternalNetwork, CapabilityIsolatedLoopback, CapabilityHostVisibleBinding,
|
||||
CapabilityProcessSpawn, CapabilityProcessControl, CapabilityEnvironment, CapabilityUnrestricted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package execution
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRequestValidationRequiresConcreteExecutionContext(t *testing.T) {
|
||||
request := Request{
|
||||
Origin: OriginInteractiveCommand,
|
||||
Mode: ModeCaptured,
|
||||
Command: Command{Name: "/bin/sh", Args: []string{"-c", "true"}},
|
||||
WorkingDirectory: "/workspace",
|
||||
WorkspaceRoots: []string{"/workspace"},
|
||||
Capabilities: []Capability{{
|
||||
Kind: CapabilityWorkspaceWrite,
|
||||
Scope: "/workspace",
|
||||
}},
|
||||
}
|
||||
if err := request.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
|
||||
request.Origin = ""
|
||||
if err := request.Validate(); err == nil {
|
||||
t.Fatal("request without origin unexpectedly validated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutcomeValidationPinsTerminalStateContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outcome Outcome
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "retained process",
|
||||
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning, ProcessID: "1000"},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "successful completion",
|
||||
outcome: Outcome{State: StateCompleted, Kind: OutcomeSuccess, Exit: &Exit{Code: 0}},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "structured denial",
|
||||
outcome: Outcome{
|
||||
State: StateDenied,
|
||||
Kind: OutcomePolicyDenied,
|
||||
Denial: &Denial{
|
||||
Capability: Capability{Kind: CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
|
||||
Source: DenialSourceConfiguredPolicy,
|
||||
Reason: "protected workspace metadata cannot be created",
|
||||
NextAction: DenialNextActionRequestApproval,
|
||||
},
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "retained without process id",
|
||||
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning},
|
||||
},
|
||||
{
|
||||
name: "denied without denial",
|
||||
outcome: Outcome{State: StateDenied, Kind: OutcomePolicyDenied},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.outcome.Validate()
|
||||
if test.valid && err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if !test.valid && err == nil {
|
||||
t.Fatal("invalid outcome unexpectedly validated")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCompletedRetention = 30 * time.Second
|
||||
defaultMaxProcesses = 64
|
||||
maxPendingOutputBytes = 2 * 1024 * 1024
|
||||
recentOutputBytes = 4096
|
||||
processStopTimeout = 3 * time.Second
|
||||
maxInteractiveYield = 30 * time.Second
|
||||
maxEmptyPollYield = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProcessNotFound = errors.New("execution process not found")
|
||||
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
|
||||
)
|
||||
|
||||
type ProcessManagerOptions struct {
|
||||
CompletedRetention time.Duration
|
||||
MaxProcesses int
|
||||
}
|
||||
|
||||
// ProcessManager owns retained interactive-process identity, transport,
|
||||
// bounded output, continuation, cancellation, completion, and cleanup.
|
||||
type ProcessManager struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
processes map[int]*managedProcess
|
||||
completedRetention time.Duration
|
||||
maxProcesses int
|
||||
startTransport processTransportStarter
|
||||
}
|
||||
|
||||
type ProcessStart struct {
|
||||
Prepared PreparedCommand
|
||||
Request Request
|
||||
CommandText string
|
||||
RelativeCwd string
|
||||
TTY bool
|
||||
Metadata map[string]string
|
||||
// AfterWait may append adapter diagnostics collected by a platform monitor.
|
||||
AfterWait func() []byte
|
||||
}
|
||||
|
||||
type ProcessContinue struct {
|
||||
ProcessID int
|
||||
Input []byte
|
||||
Interrupt bool
|
||||
Wait time.Duration
|
||||
}
|
||||
|
||||
type ProcessResult struct {
|
||||
ProcessID int
|
||||
CommandText string
|
||||
RelativeCwd string
|
||||
TTY bool
|
||||
Output string
|
||||
OutputTruncated bool
|
||||
Exited bool
|
||||
ExitCode int
|
||||
Interrupted bool
|
||||
Request Request
|
||||
Enforcement Enforcement
|
||||
Report AdapterReport
|
||||
ReportErr error
|
||||
Changes []Change
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type ProcessSnapshot struct {
|
||||
ID int
|
||||
Command string
|
||||
Cwd string
|
||||
RelativeCwd string
|
||||
StartedAt time.Time
|
||||
LastUsedAt time.Time
|
||||
TTY bool
|
||||
Status string
|
||||
ExitCode *int
|
||||
RecentOutput string
|
||||
OutputTruncated bool
|
||||
}
|
||||
|
||||
func NewProcessManager(options ProcessManagerOptions) *ProcessManager {
|
||||
retention := options.CompletedRetention
|
||||
if retention == 0 {
|
||||
retention = defaultCompletedRetention
|
||||
}
|
||||
maxProcesses := options.MaxProcesses
|
||||
if maxProcesses == 0 {
|
||||
maxProcesses = defaultMaxProcesses
|
||||
}
|
||||
return &ProcessManager{
|
||||
nextID: 1000,
|
||||
processes: make(map[int]*managedProcess),
|
||||
completedRetention: retention,
|
||||
maxProcesses: maxProcesses,
|
||||
startTransport: startProcessTransport,
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wait time.Duration) (ProcessResult, error) {
|
||||
if manager == nil {
|
||||
return ProcessResult{}, errors.New("execution process manager is not configured")
|
||||
}
|
||||
if err := input.Request.Validate(); err != nil {
|
||||
return ProcessResult{}, fmt.Errorf("invalid execution request: %w", err)
|
||||
}
|
||||
if input.Prepared.Command == nil {
|
||||
return ProcessResult{}, errors.New("prepared execution has no command")
|
||||
}
|
||||
command := input.Prepared.Command
|
||||
buffer := newProcessOutputBuffer()
|
||||
request := input.Request
|
||||
observer := NewChangeObserver(request.WorkspaceRoots[0])
|
||||
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
|
||||
if err != nil {
|
||||
if input.Prepared.Cleanup != nil {
|
||||
input.Prepared.Cleanup()
|
||||
}
|
||||
return ProcessResult{}, err
|
||||
}
|
||||
if tty {
|
||||
request.Mode = ModeInteractive
|
||||
} else {
|
||||
request.Mode = ModeCaptured
|
||||
}
|
||||
now := time.Now()
|
||||
process := &managedProcess{
|
||||
id: manager.allocateID(),
|
||||
commandText: input.CommandText,
|
||||
cwd: request.WorkingDirectory,
|
||||
relativeCwd: input.RelativeCwd,
|
||||
startedAt: now,
|
||||
lastUsedAt: now,
|
||||
tty: tty,
|
||||
command: command,
|
||||
request: request,
|
||||
enforcement: input.Prepared.Enforcement,
|
||||
report: input.Prepared.Report,
|
||||
cleanup: input.Prepared.Cleanup,
|
||||
stdin: stdin,
|
||||
output: buffer,
|
||||
reaped: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
kill: KillProcessTree,
|
||||
metadata: cloneStringMap(input.Metadata),
|
||||
}
|
||||
manager.store(process)
|
||||
manager.removeCompletedLater(process)
|
||||
go func() {
|
||||
waitErr := command.Wait()
|
||||
close(process.reaped)
|
||||
if input.AfterWait != nil {
|
||||
if diagnostic := input.AfterWait(); len(diagnostic) > 0 {
|
||||
_, _ = buffer.Write(diagnostic)
|
||||
}
|
||||
}
|
||||
if transportCleanup != nil {
|
||||
transportCleanup()
|
||||
}
|
||||
adapterReport, reportErr := AdapterReport{}, error(nil)
|
||||
if process.report != nil {
|
||||
adapterReport, reportErr = process.report()
|
||||
}
|
||||
changes := observer.Changes()
|
||||
if process.cleanup != nil {
|
||||
process.cleanup()
|
||||
}
|
||||
process.markDone(waitErr, commandExitCode(waitErr), adapterReport, reportErr, changes)
|
||||
}()
|
||||
result := process.collectResult(ctx, clampInitialProcessWait(wait), false)
|
||||
if ctx != nil && ctx.Err() != nil && !result.Exited {
|
||||
process.terminate()
|
||||
more := process.collectResult(context.Background(), time.Second, true)
|
||||
var mergeTruncated bool
|
||||
result.Output, mergeTruncated = appendBoundedProcessOutputString(result.Output, more.Output)
|
||||
result.OutputTruncated = result.OutputTruncated || more.OutputTruncated
|
||||
result.OutputTruncated = result.OutputTruncated || mergeTruncated
|
||||
result.Exited = more.Exited
|
||||
result.ExitCode = more.ExitCode
|
||||
result.Interrupted = result.Interrupted || more.Interrupted
|
||||
result.Report = more.Report
|
||||
result.ReportErr = more.ReportErr
|
||||
result.Changes = more.Changes
|
||||
}
|
||||
if result.Exited {
|
||||
manager.Remove(process.id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContinue) (ProcessResult, error) {
|
||||
process, ok := manager.get(input.ProcessID)
|
||||
if !ok {
|
||||
return ProcessResult{}, ErrProcessNotFound
|
||||
}
|
||||
process.touch()
|
||||
if input.Interrupt {
|
||||
process.terminate()
|
||||
} else if len(input.Input) > 0 {
|
||||
if !process.tty || process.stdin == nil {
|
||||
return ProcessResult{}, ErrProcessStdinDisabled
|
||||
}
|
||||
if _, err := process.stdin.Write(input.Input); err != nil && !process.doneClosed() {
|
||||
return ProcessResult{}, err
|
||||
}
|
||||
}
|
||||
result := process.collectResult(ctx, clampContinuationWait(input.Wait, len(input.Input) == 0), input.Interrupt)
|
||||
if result.Exited {
|
||||
manager.Remove(process.id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func clampInitialProcessWait(wait time.Duration) time.Duration {
|
||||
return min(wait, maxInteractiveYield)
|
||||
}
|
||||
|
||||
func clampContinuationWait(wait time.Duration, emptyPoll bool) time.Duration {
|
||||
if emptyPoll {
|
||||
return min(wait, maxEmptyPollYield)
|
||||
}
|
||||
return min(wait, maxInteractiveYield)
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) List() []ProcessSnapshot {
|
||||
manager.mu.Lock()
|
||||
processes := make([]*managedProcess, 0, len(manager.processes))
|
||||
for _, process := range manager.processes {
|
||||
if !process.doneClosed() {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
snapshots := make([]ProcessSnapshot, 0, len(processes))
|
||||
for _, process := range processes {
|
||||
snapshots = append(snapshots, process.snapshot())
|
||||
}
|
||||
sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].ID < snapshots[j].ID })
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Snapshot(id int) (ProcessSnapshot, bool) {
|
||||
process, ok := manager.get(id)
|
||||
if !ok {
|
||||
return ProcessSnapshot{}, false
|
||||
}
|
||||
return process.snapshot(), true
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Stop(id int) bool {
|
||||
process, ok := manager.get(id)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
process.terminate()
|
||||
return true
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) StopAll() []int {
|
||||
manager.mu.Lock()
|
||||
processes := make([]*managedProcess, 0, len(manager.processes))
|
||||
for _, process := range manager.processes {
|
||||
if !process.doneClosed() {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
ids := make([]int, 0, len(processes))
|
||||
for _, process := range processes {
|
||||
process.terminate()
|
||||
ids = append(ids, process.id)
|
||||
}
|
||||
waitForProcesses(processes, processStopTimeout)
|
||||
sort.Ints(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Remove(id int) {
|
||||
manager.mu.Lock()
|
||||
delete(manager.processes, id)
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Len() int {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
return len(manager.processes)
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) allocateID() int {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
id := manager.nextID
|
||||
manager.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
process, ok := manager.processes[id]
|
||||
return process, ok
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) store(process *managedProcess) {
|
||||
manager.mu.Lock()
|
||||
var evicted *managedProcess
|
||||
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
|
||||
evicted = manager.processToPruneLocked()
|
||||
if evicted != nil && evicted.doneClosed() {
|
||||
delete(manager.processes, evicted.id)
|
||||
evicted = nil
|
||||
}
|
||||
}
|
||||
manager.processes[process.id] = process
|
||||
manager.mu.Unlock()
|
||||
if evicted != nil {
|
||||
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
|
||||
evicted.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
|
||||
var processes []*managedProcess
|
||||
for _, process := range manager.processes {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
sort.Slice(processes, func(i, j int) bool { return processes[i].lastUsed().Before(processes[j].lastUsed()) })
|
||||
for _, process := range processes {
|
||||
if process.doneClosed() {
|
||||
return process
|
||||
}
|
||||
}
|
||||
if len(processes) > 8 {
|
||||
return processes[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
|
||||
go func() {
|
||||
<-process.done
|
||||
if manager.completedRetention > 0 {
|
||||
timer := time.NewTimer(manager.completedRetention)
|
||||
<-timer.C
|
||||
}
|
||||
manager.Remove(process.id)
|
||||
}()
|
||||
}
|
||||
|
||||
type managedProcess struct {
|
||||
id int
|
||||
commandText string
|
||||
cwd string
|
||||
relativeCwd string
|
||||
startedAt time.Time
|
||||
lastUsedAt time.Time
|
||||
tty bool
|
||||
command *exec.Cmd
|
||||
request Request
|
||||
enforcement Enforcement
|
||||
report func() (AdapterReport, error)
|
||||
cleanup func()
|
||||
stdin io.WriteCloser
|
||||
output *processOutputBuffer
|
||||
reaped chan struct{}
|
||||
doneOnce sync.Once
|
||||
done chan struct{}
|
||||
kill func(int) error
|
||||
mu sync.Mutex
|
||||
exitCode *int
|
||||
waitErr error
|
||||
resultReport AdapterReport
|
||||
reportErr error
|
||||
changes []Change
|
||||
metadata map[string]string
|
||||
}
|
||||
|
||||
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
|
||||
process.mu.Lock()
|
||||
process.waitErr = err
|
||||
process.exitCode = &exitCode
|
||||
process.resultReport = report
|
||||
process.reportErr = reportErr
|
||||
process.changes = append([]Change(nil), changes...)
|
||||
process.mu.Unlock()
|
||||
process.doneOnce.Do(func() { close(process.done) })
|
||||
}
|
||||
|
||||
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
|
||||
output, truncated := process.collect(ctx, wait)
|
||||
process.mu.Lock()
|
||||
exitCode := 0
|
||||
exited := process.exitCode != nil
|
||||
if exited {
|
||||
exitCode = *process.exitCode
|
||||
}
|
||||
result := ProcessResult{
|
||||
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
|
||||
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
|
||||
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
|
||||
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
|
||||
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
|
||||
}
|
||||
process.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
func (process *managedProcess) collect(ctx context.Context, wait time.Duration) (string, bool) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
deadline := time.Now().Add(wait)
|
||||
var output []byte
|
||||
truncated := false
|
||||
finish := func() (string, bool) {
|
||||
truncated = truncated || process.output.consumeTruncated()
|
||||
return string(output), truncated
|
||||
}
|
||||
for {
|
||||
if chunk := process.output.drain(); len(chunk) > 0 {
|
||||
var chunkTruncated bool
|
||||
output, chunkTruncated = appendBoundedProcessOutput(output, chunk)
|
||||
truncated = truncated || chunkTruncated
|
||||
truncated = truncated || process.output.consumeTruncated()
|
||||
if time.Now().After(deadline) {
|
||||
return finish()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if process.doneClosed() {
|
||||
var chunkTruncated bool
|
||||
output, chunkTruncated = appendBoundedProcessOutput(output, process.output.drain())
|
||||
truncated = truncated || chunkTruncated
|
||||
return finish()
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return finish()
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
case <-process.output.notify:
|
||||
case <-process.done:
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return finish()
|
||||
case <-timer.C:
|
||||
return finish()
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendBoundedProcessOutput(output []byte, chunk []byte) ([]byte, bool) {
|
||||
if len(chunk) == 0 {
|
||||
return output, false
|
||||
}
|
||||
if len(chunk) >= maxPendingOutputBytes {
|
||||
bounded := make([]byte, maxPendingOutputBytes)
|
||||
copy(bounded, chunk[len(chunk)-maxPendingOutputBytes:])
|
||||
return bounded, len(output) > 0 || len(chunk) > maxPendingOutputBytes
|
||||
}
|
||||
if len(output)+len(chunk) <= maxPendingOutputBytes {
|
||||
return append(output, chunk...), false
|
||||
}
|
||||
drop := len(output) + len(chunk) - maxPendingOutputBytes
|
||||
copy(output, output[drop:])
|
||||
output = output[:len(output)-drop]
|
||||
return append(output, chunk...), true
|
||||
}
|
||||
|
||||
func appendBoundedProcessOutputString(output string, chunk string) (string, bool) {
|
||||
bounded, truncated := appendBoundedProcessOutput([]byte(output), []byte(chunk))
|
||||
return string(bounded), truncated
|
||||
}
|
||||
|
||||
func (process *managedProcess) doneClosed() bool {
|
||||
select {
|
||||
case <-process.done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (process *managedProcess) touch() {
|
||||
process.mu.Lock()
|
||||
process.lastUsedAt = time.Now()
|
||||
process.mu.Unlock()
|
||||
}
|
||||
func (process *managedProcess) lastUsed() time.Time {
|
||||
process.mu.Lock()
|
||||
defer process.mu.Unlock()
|
||||
return process.lastUsedAt
|
||||
}
|
||||
func (process *managedProcess) terminate() {
|
||||
if process.reapedClosed() || process.command.Process == nil {
|
||||
return
|
||||
}
|
||||
kill := process.kill
|
||||
if kill == nil {
|
||||
kill = KillProcessTree
|
||||
}
|
||||
_ = kill(process.command.Process.Pid)
|
||||
}
|
||||
|
||||
func (process *managedProcess) reapedClosed() bool {
|
||||
if process.reaped == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-process.reaped:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (process *managedProcess) snapshot() ProcessSnapshot {
|
||||
process.mu.Lock()
|
||||
defer process.mu.Unlock()
|
||||
var exit *int
|
||||
if process.exitCode != nil {
|
||||
value := *process.exitCode
|
||||
exit = &value
|
||||
}
|
||||
status := "running"
|
||||
if exit != nil {
|
||||
status = "exited"
|
||||
}
|
||||
return ProcessSnapshot{ID: process.id, Command: process.commandText, Cwd: process.cwd, RelativeCwd: process.relativeCwd,
|
||||
StartedAt: process.startedAt, LastUsedAt: process.lastUsedAt, TTY: process.tty, Status: status,
|
||||
ExitCode: exit, RecentOutput: process.output.recentString(), OutputTruncated: process.output.peekTruncated()}
|
||||
}
|
||||
|
||||
func waitForProcesses(processes []*managedProcess, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for _, process := range processes {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
case <-process.done:
|
||||
case <-timer.C:
|
||||
return
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type processOutputBuffer struct {
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
recent []byte
|
||||
truncated bool
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
func newProcessOutputBuffer() *processOutputBuffer {
|
||||
return &processOutputBuffer{notify: make(chan struct{}, 1)}
|
||||
}
|
||||
func (buffer *processOutputBuffer) Write(data []byte) (int, error) {
|
||||
buffer.mu.Lock()
|
||||
buffer.data = append(buffer.data, data...)
|
||||
if len(buffer.data) > maxPendingOutputBytes {
|
||||
buffer.data = buffer.data[len(buffer.data)-maxPendingOutputBytes:]
|
||||
buffer.truncated = true
|
||||
}
|
||||
buffer.recent = append(buffer.recent, data...)
|
||||
if len(buffer.recent) > recentOutputBytes {
|
||||
buffer.recent = buffer.recent[len(buffer.recent)-recentOutputBytes:]
|
||||
}
|
||||
buffer.mu.Unlock()
|
||||
select {
|
||||
case buffer.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
func (buffer *processOutputBuffer) drain() []byte {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
data := append([]byte(nil), buffer.data...)
|
||||
buffer.data = nil
|
||||
return data
|
||||
}
|
||||
func (buffer *processOutputBuffer) recentString() string {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
return string(buffer.recent)
|
||||
}
|
||||
func (buffer *processOutputBuffer) consumeTruncated() bool {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
value := buffer.truncated
|
||||
buffer.truncated = false
|
||||
return value
|
||||
}
|
||||
func (buffer *processOutputBuffer) peekTruncated() bool {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
return buffer.truncated
|
||||
}
|
||||
|
||||
func cloneStringMap(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
output := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func processManagerRequest(root string, command *exec.Cmd) Request {
|
||||
return Request{
|
||||
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
||||
Command: Command{Name: command.Path, Args: command.Args[1:]},
|
||||
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Second})
|
||||
command := exec.Command("/bin/sh", "-c", "printf first; sleep 0.05; printf second")
|
||||
request := Request{
|
||||
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
||||
Command: Command{Name: "/bin/sh", Args: []string{"-c", "printf first; sleep 0.05; printf second"}},
|
||||
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}
|
||||
started, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: request, CommandText: "test",
|
||||
}, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if started.Exited || started.ProcessID < 1000 {
|
||||
t.Fatalf("initial result = %#v, want retained process", started)
|
||||
}
|
||||
continued, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Wait: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if !continued.Exited || continued.ProcessID != started.ProcessID {
|
||||
t.Fatalf("continued result = %#v, want same completed process", continued)
|
||||
}
|
||||
combined := started.Output + continued.Output
|
||||
if strings.Count(combined, "first") != 1 || strings.Count(combined, "second") != 1 {
|
||||
t.Fatalf("output was lost or duplicated: %q", combined)
|
||||
}
|
||||
if manager.Len() != 0 {
|
||||
t.Fatalf("completed process still retained: %d", manager.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
||||
request := Request{
|
||||
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
||||
Command: Command{Name: "/bin/sh", Args: []string{"-c", "sleep 30"}},
|
||||
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
||||
}
|
||||
started, err := manager.Start(context.Background(), ProcessStart{Prepared: PreparedCommand{Command: command}, Request: request}, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
stopped, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Interrupt: true, Wait: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue interrupt: %v", err)
|
||||
}
|
||||
if !stopped.Exited || !stopped.Interrupted {
|
||||
t.Fatalf("interrupt result = %#v", stopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
|
||||
reaped := make(chan struct{})
|
||||
close(reaped)
|
||||
called := false
|
||||
process := &managedProcess{
|
||||
command: &exec.Cmd{Process: &os.Process{Pid: 4242}},
|
||||
reaped: reaped,
|
||||
kill: func(int) error { called = true; return nil },
|
||||
}
|
||||
|
||||
process.terminate()
|
||||
if called {
|
||||
t.Fatal("terminate signaled an already-reaped process")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerCancellationReportsInterrupted(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
result, err := manager.Start(ctx, ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command},
|
||||
Request: processManagerRequest(root, command),
|
||||
}, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if !result.Exited || !result.Interrupted {
|
||||
t.Fatalf("cancelled result = %#v, want exited and interrupted", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerObservesChangesMadeDuringTransportStart(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "created.txt")
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
manager.startTransport = func(command *exec.Cmd, output io.Writer, tty bool) (io.WriteCloser, bool, func(), error) {
|
||||
if err := os.WriteFile(target, []byte("created"), 0o600); err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
return startProcessTransport(command, output, tty)
|
||||
}
|
||||
command := exec.Command("/bin/sh", "-c", "true")
|
||||
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command},
|
||||
Request: processManagerRequest(root, command),
|
||||
}, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if len(result.Changes) != 1 || result.Changes[0].Path != "created.txt" || result.Changes[0].Kind != ChangeCreated {
|
||||
t.Fatalf("changes = %#v, want created.txt created", result.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessWaitsClampToEffectiveBounds(t *testing.T) {
|
||||
if got := clampInitialProcessWait(time.Minute); got != maxInteractiveYield {
|
||||
t.Fatalf("initial upper clamp = %v, want %v", got, maxInteractiveYield)
|
||||
}
|
||||
if got := clampInitialProcessWait(time.Millisecond); got != time.Millisecond {
|
||||
t.Fatalf("initial short wait = %v, want caller value", got)
|
||||
}
|
||||
if got := clampContinuationWait(time.Hour, true); got != maxEmptyPollYield {
|
||||
t.Fatalf("empty poll upper clamp = %v, want %v", got, maxEmptyPollYield)
|
||||
}
|
||||
if got := clampContinuationWait(time.Hour, false); got != maxInteractiveYield {
|
||||
t.Fatalf("stdin upper clamp = %v, want %v", got, maxInteractiveYield)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOutputBufferCapsUndrainedData(t *testing.T) {
|
||||
buffer := newProcessOutputBuffer()
|
||||
chunk := []byte(strings.Repeat("x", 1024))
|
||||
for i := 0; i < maxPendingOutputBytes/len(chunk)+10; i++ {
|
||||
if _, err := buffer.Write(chunk); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
buffer.mu.Lock()
|
||||
dataLen := len(buffer.data)
|
||||
buffer.mu.Unlock()
|
||||
if dataLen > maxPendingOutputBytes {
|
||||
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxPendingOutputBytes)
|
||||
}
|
||||
if got := buffer.drain(); !strings.HasSuffix(string(got), string(chunk)) {
|
||||
t.Fatal("drained output should retain the newest bytes")
|
||||
}
|
||||
if !buffer.peekTruncated() || !buffer.consumeTruncated() || buffer.consumeTruncated() {
|
||||
t.Fatal("truncation state was not preserved and consumed exactly once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
|
||||
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
|
||||
stop := make(chan struct{})
|
||||
var writers sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
writers.Add(1)
|
||||
go func() {
|
||||
defer writers.Done()
|
||||
chunk := []byte(strings.Repeat("x", 256))
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
_, _ = process.output.Write(chunk)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
t.Cleanup(func() { close(stop); writers.Wait() })
|
||||
|
||||
wait := 200 * time.Millisecond
|
||||
started := time.Now()
|
||||
_, _ = process.collect(context.Background(), wait)
|
||||
if elapsed := time.Since(started); elapsed > 5*wait {
|
||||
t.Fatalf("collect took %v under continuous output, want close to %v", elapsed, wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessCollectCapsCumulativeOutput(t *testing.T) {
|
||||
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
|
||||
chunk := []byte(strings.Repeat("x", maxPendingOutputBytes/2))
|
||||
go func() {
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = process.output.Write(chunk)
|
||||
for {
|
||||
process.output.mu.Lock()
|
||||
drained := len(process.output.data) == 0
|
||||
process.output.mu.Unlock()
|
||||
if drained {
|
||||
break
|
||||
}
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
close(process.done)
|
||||
}()
|
||||
|
||||
output, truncated := process.collect(context.Background(), time.Second)
|
||||
if len(output) > maxPendingOutputBytes {
|
||||
t.Fatalf("collected %d bytes, want <= %d", len(output), maxPendingOutputBytes)
|
||||
}
|
||||
if !truncated {
|
||||
t.Fatal("cumulative output cap must report truncation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPruningDoesNotRaceTouch(t *testing.T) {
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
for id := 1000; id < 1012; id++ {
|
||||
manager.processes[id] = &managedProcess{id: id, lastUsedAt: time.Now(), done: make(chan struct{})}
|
||||
}
|
||||
target := manager.processes[1000]
|
||||
stop := make(chan struct{})
|
||||
var writer sync.WaitGroup
|
||||
writer.Add(1)
|
||||
go func() {
|
||||
defer writer.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
target.touch()
|
||||
}
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 2000; i++ {
|
||||
manager.mu.Lock()
|
||||
_ = manager.processToPruneLocked()
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
close(stop)
|
||||
writer.Wait()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//go:build !windows
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigureProcessGroup makes cmd the leader of a process group so lifecycle
|
||||
// operations cover descendants instead of orphaning them.
|
||||
func ConfigureProcessGroup(cmd *exec.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
}
|
||||
|
||||
// KillProcessTree immediately kills pid and, when it is a group leader, its
|
||||
// descendant process group.
|
||||
func KillProcessTree(pid int) error {
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminateProcessTree requests graceful termination, then force-kills the
|
||||
// process tree after grace. Callers retain their distinct persistence models;
|
||||
// this function owns only the OS lifecycle primitive.
|
||||
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if poll <= 0 {
|
||||
poll = 50 * time.Millisecond
|
||||
}
|
||||
if grace < 0 {
|
||||
grace = 0
|
||||
}
|
||||
deadline := time.Now().Add(grace)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(poll)
|
||||
}
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return err
|
||||
}
|
||||
deadline = time.Now().Add(grace)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(poll)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processSignalTarget(pid int) (int, error) {
|
||||
if pid <= 1 {
|
||||
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
|
||||
}
|
||||
target := pid
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else if errors.Is(err, syscall.ESRCH) {
|
||||
// Preserve the individual target; the signal call below treats ESRCH as
|
||||
// already gone, which is a successful lifecycle outcome.
|
||||
return pid, nil
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build windows
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ConfigureProcessGroup(cmd *exec.Cmd) {}
|
||||
|
||||
func KillProcessTree(pid int) error {
|
||||
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return process.Kill()
|
||||
}
|
||||
|
||||
func TerminateProcessTree(pid int, _, _ time.Duration) error { return KillProcessTree(pid) }
|
||||
|
||||
func taskkillPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = os.Getenv("windir")
|
||||
}
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
return filepath.Join(systemRoot, "System32", "taskkill.exe")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
|
||||
return nil, nil, errors.New("pty transport is unavailable on this platform")
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build linux
|
||||
|
||||
package tools
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -14,41 +14,25 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func startPTYProcess(command *exec.Cmd, output *execOutputBuffer) (io.WriteCloser, func(), error) {
|
||||
func startPTYProcess(command *exec.Cmd, output io.Writer) (io.WriteCloser, func(), error) {
|
||||
master, slave, err := openPTY()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = master.Close()
|
||||
_ = slave.Close()
|
||||
}
|
||||
command.Stdin = slave
|
||||
command.Stdout = slave
|
||||
command.Stderr = slave
|
||||
hardenPTYProcessLifetime(command, slave)
|
||||
cleanup := func() { _ = master.Close(); _ = slave.Close() }
|
||||
command.Stdin, command.Stdout, command.Stderr = slave, slave, slave
|
||||
hardenPTYProcess(command)
|
||||
if err := command.Start(); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
_ = slave.Close()
|
||||
copied := make(chan struct{})
|
||||
go func() {
|
||||
defer close(copied)
|
||||
_, _ = io.Copy(output, master)
|
||||
}()
|
||||
go func() { defer close(copied); _, _ = io.Copy(output, master) }()
|
||||
return master, func() {
|
||||
// cleanup runs in the Wait goroutine AFTER command.Wait() returns, so the
|
||||
// child has exited and its slave fds are closed — the master then EOFs once
|
||||
// io.Copy drains the remaining PTY output into the buffer. Wait for that copy
|
||||
// to finish BEFORE closing the master and letting the caller mark the session
|
||||
// done + remove it; otherwise a command's final output chunk (e.g. a test
|
||||
// runner's last PASS/FAIL line) can be lost on exit. Closing the master first
|
||||
// would truncate the unread tail, so the join precedes the Close. Bounded so a
|
||||
// stuck copy can't hang teardown. (AUDIT-M14)
|
||||
select {
|
||||
case <-copied:
|
||||
case <-time.After(bashWaitDelay):
|
||||
case <-time.After(processWaitDelay):
|
||||
}
|
||||
_ = master.Close()
|
||||
}, nil
|
||||
@@ -75,18 +59,17 @@ func openPTY() (*os.File, *os.File, error) {
|
||||
_ = master.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
slave := os.NewFile(uintptr(slaveFD), slaveName)
|
||||
return master, slave, nil
|
||||
return master, os.NewFile(uintptr(slaveFD), slaveName), nil
|
||||
}
|
||||
|
||||
func hardenPTYProcessLifetime(command *exec.Cmd, slave *os.File) {
|
||||
func hardenPTYProcess(command *exec.Cmd) {
|
||||
if command.SysProcAttr == nil {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
command.SysProcAttr.Setsid = true
|
||||
command.SysProcAttr.Setctty = true
|
||||
command.SysProcAttr.Ctty = 0
|
||||
command.WaitDelay = bashWaitDelay
|
||||
command.WaitDelay = processWaitDelay
|
||||
command.Cancel = func() error {
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build linux
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPTYFallbackRestoresOriginalProcessAttributes(t *testing.T) {
|
||||
original := &syscall.SysProcAttr{Setpgid: true}
|
||||
command := exec.Command("/bin/true")
|
||||
command.SysProcAttr = original
|
||||
saved := cloneSysProcAttr(command.SysProcAttr)
|
||||
hardenPTYProcess(command)
|
||||
resetCommandAfterPTYFallback(command, saved)
|
||||
|
||||
if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid {
|
||||
t.Fatalf("fallback attributes = %#v, want original Setpgid", command.SysProcAttr)
|
||||
}
|
||||
if command.SysProcAttr.Setsid || command.SysProcAttr.Setctty {
|
||||
t.Fatalf("fallback retained PTY attributes: %#v", command.SysProcAttr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const maxCapturedStreamBytes = 1 << 20
|
||||
|
||||
// Preparer is the platform-adapter seam. It turns a platform-neutral request
|
||||
// into one enforceable OS command while keeping native sandbox mechanics out of
|
||||
// every caller.
|
||||
type Preparer interface {
|
||||
PrepareExecution(context.Context, Request) (PreparedCommand, error)
|
||||
}
|
||||
|
||||
type PreparedCommand struct {
|
||||
Command *exec.Cmd
|
||||
Enforcement Enforcement
|
||||
Report func() (AdapterReport, error)
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
type CapturedRequest struct {
|
||||
Request Request
|
||||
Stdin []byte
|
||||
}
|
||||
|
||||
type CapturedResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Truncated bool
|
||||
Outcome Outcome
|
||||
Err error
|
||||
}
|
||||
|
||||
// Runner is the deep execution module used by Zero-owned subprocess launchers.
|
||||
// The preparer may be installed after tool registration, but execution fails
|
||||
// closed until an adapter is present.
|
||||
type Runner struct {
|
||||
mu sync.RWMutex
|
||||
preparer Preparer
|
||||
}
|
||||
|
||||
func NewRunner(preparer Preparer) *Runner {
|
||||
return &Runner{preparer: preparer}
|
||||
}
|
||||
|
||||
func (runner *Runner) SetPreparer(preparer Preparer) {
|
||||
if runner == nil {
|
||||
return
|
||||
}
|
||||
runner.mu.Lock()
|
||||
runner.preparer = preparer
|
||||
runner.mu.Unlock()
|
||||
}
|
||||
|
||||
func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest) CapturedResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := input.Request.Validate(); err != nil {
|
||||
return capturedSetupFailure("invalid execution request: "+err.Error(), err, Enforcement{})
|
||||
}
|
||||
if input.Request.Mode != ModeCaptured {
|
||||
err := fmt.Errorf("captured execution requires mode %q", ModeCaptured)
|
||||
return capturedSetupFailure(err.Error(), err, Enforcement{})
|
||||
}
|
||||
prepared, err := runner.Prepare(ctx, input.Request)
|
||||
if err != nil {
|
||||
return capturedSetupFailure("prepare execution: "+err.Error(), err, prepared.Enforcement)
|
||||
}
|
||||
if prepared.Cleanup != nil {
|
||||
defer prepared.Cleanup()
|
||||
}
|
||||
if prepared.Command == nil {
|
||||
err := errors.New("execution adapter returned no command")
|
||||
return capturedSetupFailure(err.Error(), err, prepared.Enforcement)
|
||||
}
|
||||
if len(input.Stdin) > 0 {
|
||||
prepared.Command.Stdin = bytes.NewReader(input.Stdin)
|
||||
}
|
||||
stdout := &capturedBuffer{limit: maxCapturedStreamBytes}
|
||||
stderr := &capturedBuffer{limit: maxCapturedStreamBytes}
|
||||
prepared.Command.Stdout = stdout
|
||||
prepared.Command.Stderr = stderr
|
||||
runErr := prepared.Command.Run()
|
||||
report, reportErr := AdapterReport{}, error(nil)
|
||||
if prepared.Report != nil {
|
||||
report, reportErr = prepared.Report()
|
||||
}
|
||||
result := CapturedResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Truncated: stdout.Truncated() || stderr.Truncated(),
|
||||
Err: runErr,
|
||||
Outcome: Outcome{
|
||||
Enforcement: prepared.Enforcement,
|
||||
},
|
||||
}
|
||||
exitCode := commandExitCode(runErr)
|
||||
result.Outcome.Exit = &Exit{Code: exitCode}
|
||||
switch {
|
||||
case reportErr != nil:
|
||||
result.Outcome.State = StateFailed
|
||||
result.Outcome.Kind = OutcomeSandboxSetupFailure
|
||||
result.Err = reportErr
|
||||
case report.Denial != nil:
|
||||
denial := *report.Denial
|
||||
result.Outcome.State = StateDenied
|
||||
result.Outcome.Kind = OutcomeEnforcementDenied
|
||||
result.Outcome.Denial = &denial
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
result.Outcome.State = StateFailed
|
||||
result.Outcome.Kind = OutcomeTimedOut
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
result.Outcome.State = StateCancelled
|
||||
result.Outcome.Kind = OutcomeCancelled
|
||||
case runErr == nil:
|
||||
result.Outcome.State = StateCompleted
|
||||
result.Outcome.Kind = OutcomeSuccess
|
||||
case executableNotFound(runErr):
|
||||
result.Outcome.State = StateFailed
|
||||
result.Outcome.Kind = OutcomeExecutableNotFound
|
||||
default:
|
||||
result.Outcome.State = StateFailed
|
||||
result.Outcome.Kind = OutcomeApplicationFailure
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Prepare evaluates and prepares a typed request for callers that require
|
||||
// streaming pipes or protocol-specific lifecycle handling, such as stdio MCP.
|
||||
// The returned cleanup remains mandatory; ordinary captured commands should use
|
||||
// ExecuteCaptured so the Runner owns it automatically.
|
||||
func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCommand, error) {
|
||||
if runner == nil {
|
||||
return PreparedCommand{}, errors.New("execution runner is not configured")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := request.Validate(); err != nil {
|
||||
return PreparedCommand{}, err
|
||||
}
|
||||
runner.mu.RLock()
|
||||
preparer := runner.preparer
|
||||
runner.mu.RUnlock()
|
||||
if preparer == nil {
|
||||
return PreparedCommand{}, errors.New("execution adapter is not configured")
|
||||
}
|
||||
return preparer.PrepareExecution(ctx, request)
|
||||
}
|
||||
|
||||
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
|
||||
return CapturedResult{
|
||||
Stderr: message,
|
||||
Err: err,
|
||||
Outcome: Outcome{
|
||||
State: StateFailed,
|
||||
Kind: OutcomeSandboxSetupFailure,
|
||||
Exit: &Exit{Code: -1},
|
||||
Enforcement: enforcement,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func executableNotFound(err error) bool {
|
||||
var execErr *exec.Error
|
||||
return errors.As(err, &execErr)
|
||||
}
|
||||
|
||||
type capturedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
limit int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (buffer *capturedBuffer) Write(data []byte) (int, error) {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
remaining := buffer.limit - buffer.buffer.Len()
|
||||
if remaining > 0 {
|
||||
_, _ = buffer.buffer.Write(data[:min(len(data), remaining)])
|
||||
}
|
||||
if len(data) > remaining {
|
||||
buffer.truncated = true
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (buffer *capturedBuffer) String() string {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
return buffer.buffer.String()
|
||||
}
|
||||
|
||||
func (buffer *capturedBuffer) Truncated() bool {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
return buffer.truncated
|
||||
}
|
||||
|
||||
var _ io.Writer = (*capturedBuffer)(nil)
|
||||
@@ -0,0 +1,77 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type capturedTestPreparer struct {
|
||||
request Request
|
||||
}
|
||||
|
||||
func (preparer *capturedTestPreparer) PrepareExecution(_ context.Context, request Request) (PreparedCommand, error) {
|
||||
preparer.request = request
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestCapturedRunnerHelperProcess$")
|
||||
command.Env = append(os.Environ(), "ZERO_CAPTURED_RUNNER_HELPER=1")
|
||||
return PreparedCommand{
|
||||
Command: command,
|
||||
Enforcement: Enforcement{
|
||||
Backend: "test-adapter",
|
||||
Level: "native",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCapturedRunnerHelperProcess(t *testing.T) {
|
||||
if os.Getenv("ZERO_CAPTURED_RUNNER_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(os.Stdout, "stdout")
|
||||
fmt.Fprint(os.Stderr, "stderr")
|
||||
os.Exit(7)
|
||||
}
|
||||
|
||||
func TestRunnerExecutesCapturedRequestThroughAdapter(t *testing.T) {
|
||||
preparer := &capturedTestPreparer{}
|
||||
runner := NewRunner(preparer)
|
||||
request := Request{
|
||||
Origin: OriginHook,
|
||||
Mode: ModeCaptured,
|
||||
Command: Command{Name: "ignored-by-test-adapter"},
|
||||
WorkingDirectory: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}
|
||||
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: request})
|
||||
|
||||
if preparer.request.Origin != OriginHook {
|
||||
t.Fatalf("adapter origin = %q, want hook", preparer.request.Origin)
|
||||
}
|
||||
if result.Stdout != "stdout" || result.Stderr != "stderr" {
|
||||
t.Fatalf("captured output = stdout %q stderr %q", result.Stdout, result.Stderr)
|
||||
}
|
||||
if result.Outcome.Kind != OutcomeApplicationFailure || result.Outcome.Exit == nil || result.Outcome.Exit.Code != 7 {
|
||||
t.Fatalf("outcome = %#v, want application failure exit 7", result.Outcome)
|
||||
}
|
||||
if result.Outcome.Enforcement.Backend != "test-adapter" {
|
||||
t.Fatalf("enforcement = %#v", result.Outcome.Enforcement)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerWithoutAdapterFailsClosed(t *testing.T) {
|
||||
runner := NewRunner(nil)
|
||||
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{
|
||||
Origin: OriginPlugin,
|
||||
Mode: ModeCaptured,
|
||||
Command: Command{Name: "true"},
|
||||
WorkingDirectory: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
}})
|
||||
if result.Outcome.Kind != OutcomeSandboxSetupFailure || !strings.Contains(result.Stderr, "adapter") {
|
||||
t.Fatalf("result = %#v, want fail-closed missing-adapter result", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const processWaitDelay = 2 * time.Second
|
||||
|
||||
type processTransportStarter func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error)
|
||||
|
||||
func startProcessTransport(command *exec.Cmd, output io.Writer, ttyRequested bool) (io.WriteCloser, bool, func(), error) {
|
||||
if ttyRequested {
|
||||
original := cloneSysProcAttr(command.SysProcAttr)
|
||||
if stdin, cleanup, err := startPTYProcess(command, output); err == nil {
|
||||
return stdin, true, cleanup, nil
|
||||
}
|
||||
resetCommandAfterPTYFallback(command, original)
|
||||
}
|
||||
stdin, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
command.Stdout = output
|
||||
command.Stderr = output
|
||||
ConfigureProcessGroup(command)
|
||||
command.WaitDelay = processWaitDelay
|
||||
if err := command.Start(); err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
return stdin, false, func() {}, nil
|
||||
}
|
||||
|
||||
func cloneSysProcAttr(attributes *syscall.SysProcAttr) *syscall.SysProcAttr {
|
||||
if attributes == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *attributes
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func resetCommandAfterPTYFallback(command *exec.Cmd, original *syscall.SysProcAttr) {
|
||||
command.Stdin = nil
|
||||
command.Stdout = nil
|
||||
command.Stderr = nil
|
||||
command.SysProcAttr = original
|
||||
command.Cancel = nil
|
||||
command.WaitDelay = 0
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// defaultHookTimeout bounds a single hook command so a hung or slow hook cannot
|
||||
@@ -51,13 +53,14 @@ type commandRunner func(ctx context.Context, command string, args []string, stdi
|
||||
|
||||
// DispatcherOptions configures a Dispatcher.
|
||||
type DispatcherOptions struct {
|
||||
Config Config
|
||||
Audit *AuditStore // optional; when set, every run is recorded
|
||||
Cwd string // working directory for hook commands
|
||||
Env []string // extra environment entries appended to the process env
|
||||
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
|
||||
now func() time.Time
|
||||
run commandRunner
|
||||
Config Config
|
||||
Audit *AuditStore // optional; when set, every run is recorded
|
||||
Cwd string // working directory for hook commands
|
||||
Env []string // extra environment entries appended to the process env
|
||||
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
|
||||
Execution *execution.Runner // routes hook subprocesses through sandbox policy
|
||||
now func() time.Time
|
||||
run commandRunner
|
||||
}
|
||||
|
||||
// Dispatcher selects and runs the hooks configured for a lifecycle event,
|
||||
@@ -87,7 +90,11 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
|
||||
}
|
||||
run := options.run
|
||||
if run == nil {
|
||||
run = execCommandRunner
|
||||
if options.Execution != nil {
|
||||
run = executionCommandRunner(options.Execution)
|
||||
} else {
|
||||
run = execCommandRunner
|
||||
}
|
||||
}
|
||||
return &Dispatcher{
|
||||
config: options.Config,
|
||||
@@ -100,6 +107,42 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
func executionCommandRunner(runner *execution.Runner) commandRunner {
|
||||
return func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
||||
result := runner.ExecuteCaptured(ctx, execution.CapturedRequest{
|
||||
Request: execution.Request{
|
||||
Origin: execution.OriginHook,
|
||||
Mode: execution.ModeCaptured,
|
||||
Command: execution.Command{Name: command, Args: append([]string(nil), args...), Env: append([]string(nil), env...)},
|
||||
WorkingDirectory: cwd,
|
||||
WorkspaceRoots: []string{cwd},
|
||||
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
|
||||
},
|
||||
Stdin: stdin,
|
||||
})
|
||||
exitCode := -1
|
||||
if result.Outcome.Exit != nil {
|
||||
exitCode = result.Outcome.Exit.Code
|
||||
}
|
||||
stderr := result.Stderr
|
||||
if stderr == "" && result.Outcome.Denial != nil {
|
||||
stderr = result.Outcome.Denial.Reason
|
||||
}
|
||||
commandErr := error(nil)
|
||||
switch result.Outcome.Kind {
|
||||
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound:
|
||||
commandErr = result.Err
|
||||
}
|
||||
return commandResult{
|
||||
ExitCode: exitCode,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: stderr,
|
||||
Err: commandErr,
|
||||
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blocksOn reports whether a non-zero exit for this event should veto the action.
|
||||
// Only beforeTool gates the tool; other events are observational.
|
||||
func blocksOn(event Event) bool {
|
||||
|
||||
@@ -2,13 +2,25 @@ package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
type hookExecutionPreparer struct {
|
||||
request execution.Request
|
||||
}
|
||||
|
||||
func (preparer *hookExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
|
||||
preparer.request = request
|
||||
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
|
||||
}
|
||||
|
||||
func beforeToolConfig(hooks ...Definition) Config {
|
||||
return Config{Enabled: true, Hooks: hooks}
|
||||
}
|
||||
@@ -186,6 +198,25 @@ func TestExecCommandRunnerCapturesExitAndStdin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherRoutesHookThroughTypedExecutionOrigin(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses /bin/sh")
|
||||
}
|
||||
preparer := &hookExecutionPreparer{}
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(Definition{ID: "typed", Event: EventBeforeTool, Command: "/bin/sh", Args: []string{"-c", "cat"}, Enabled: true}),
|
||||
Cwd: t.TempDir(),
|
||||
Execution: execution.NewRunner(preparer),
|
||||
})
|
||||
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", Payload: map[string]any{"ok": true}})
|
||||
if outcome.Blocked || outcome.Ran != 1 {
|
||||
t.Fatalf("dispatch outcome = %#v", outcome)
|
||||
}
|
||||
if preparer.request.Origin != execution.OriginHook || preparer.request.Mode != execution.ModeCaptured {
|
||||
t.Fatalf("execution request = %#v", preparer.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandRunnerReportsLaunchFailureFailsClosedForBeforeTool(t *testing.T) {
|
||||
result := execCommandRunner(context.Background(), "definitely-not-a-real-binary-zzz", nil, nil, t.TempDir(), nil)
|
||||
if result.Err == nil {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package installtxn provides the cross-process filesystem transaction used by
|
||||
// plugin and skill installation. Callers stage content before taking the lock,
|
||||
// then commit the content swap and lockfile update together while holding it.
|
||||
package installtxn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const lockFileName = ".zero-install.lock"
|
||||
|
||||
// Lock takes the per-install-root cross-process lock. It blocks until any other
|
||||
// installer or remover using dir has completed.
|
||||
func Lock(dir string) (func(), error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create install dir: %w", err)
|
||||
}
|
||||
return lockFile(filepath.Join(dir, lockFileName))
|
||||
}
|
||||
|
||||
// StageDir creates an install workspace on the target filesystem. Content must
|
||||
// be built and validated in the returned stage directory before CommitDir is
|
||||
// called. cleanup is always safe to call.
|
||||
func StageDir(dir string) (stage string, cleanup func(), err error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", func() {}, fmt.Errorf("create install dir: %w", err)
|
||||
}
|
||||
workspace, err := os.MkdirTemp(dir, ".zero-install-txn-")
|
||||
if err != nil {
|
||||
return "", func() {}, fmt.Errorf("create install staging dir: %w", err)
|
||||
}
|
||||
return filepath.Join(workspace, "staged"), func() { cleanupWorkspace(workspace) }, nil
|
||||
}
|
||||
|
||||
// CommitDir replaces target with staged and runs publish while retaining the
|
||||
// previous target. If either the swap or publish fails, the previous target is
|
||||
// restored (or the new target is removed for a first install).
|
||||
//
|
||||
// The caller must hold the install-root lock returned by Lock.
|
||||
func CommitDir(target string, staged string, publish func() error) error {
|
||||
workspace := filepath.Dir(staged)
|
||||
backup := filepath.Join(workspace, "previous")
|
||||
hadPrevious := false
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
return fmt.Errorf("retain previous install: %w", err)
|
||||
}
|
||||
hadPrevious = true
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("inspect previous install: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(staged, target); err != nil {
|
||||
if hadPrevious {
|
||||
if restoreErr := os.Rename(backup, target); restoreErr != nil {
|
||||
return errors.Join(fmt.Errorf("publish staged install: %w", err), fmt.Errorf("restore previous install: %w", restoreErr))
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("publish staged install: %w", err)
|
||||
}
|
||||
if err := publish(); err != nil {
|
||||
return rollback(target, backup, hadPrevious, err)
|
||||
}
|
||||
if hadPrevious {
|
||||
_ = os.RemoveAll(backup)
|
||||
}
|
||||
cleanupWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveDir removes target and runs publish while retaining the target until
|
||||
// publish succeeds. A publish failure restores the directory.
|
||||
//
|
||||
// The caller must hold the install-root lock returned by Lock.
|
||||
func RemoveDir(target string, publish func() error) error {
|
||||
workspace, err := os.MkdirTemp(filepath.Dir(target), ".zero-install-txn-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create removal staging dir: %w", err)
|
||||
}
|
||||
defer cleanupWorkspace(workspace)
|
||||
backup := filepath.Join(workspace, "previous")
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
return fmt.Errorf("retain removed install: %w", err)
|
||||
}
|
||||
if err := publish(); err != nil {
|
||||
if restoreErr := os.Rename(backup, target); restoreErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("restore removed install: %w", restoreErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
_ = os.RemoveAll(backup)
|
||||
return nil
|
||||
}
|
||||
|
||||
func rollback(target string, backup string, hadPrevious bool, cause error) error {
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
return errors.Join(cause, fmt.Errorf("remove failed install: %w", err))
|
||||
}
|
||||
if hadPrevious {
|
||||
if err := os.Rename(backup, target); err != nil {
|
||||
return errors.Join(cause, fmt.Errorf("restore previous install: %w", err))
|
||||
}
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// cleanupWorkspace never removes a retained previous install. If rollback was
|
||||
// unable to restore it (for example because Windows still has a target file
|
||||
// open), preserving the workspace is safer than turning a recoverable error
|
||||
// into data loss.
|
||||
func cleanupWorkspace(workspace string) {
|
||||
if _, err := os.Stat(filepath.Join(workspace, "previous")); err == nil {
|
||||
return
|
||||
}
|
||||
_ = os.RemoveAll(workspace)
|
||||
}
|
||||
|
||||
// WriteFileAtomically publishes data by renaming a complete sibling temporary
|
||||
// file over path. The caller is responsible for any surrounding transaction
|
||||
// lock.
|
||||
func WriteFileAtomically(path string, data []byte, perm os.FileMode) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), ".zero-lockfile-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer func() { _ = os.Remove(tempPath) }()
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceFile(tempPath, path)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package installtxn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommitDirRestoresPreviousInstallWhenPublishFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "demo")
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "version"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staged, cleanup, err := StageDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
if err := os.MkdirAll(staged, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staged, "version"), []byte("new"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
publishErr := errors.New("publish failed")
|
||||
err = CommitDir(target, staged, func() error { return publishErr })
|
||||
if !errors.Is(err, publishErr) {
|
||||
t.Fatalf("CommitDir error = %v, want publish failure", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(target, "version"))
|
||||
if err != nil {
|
||||
t.Fatalf("read restored install: %v", err)
|
||||
}
|
||||
if string(data) != "old" {
|
||||
t.Fatalf("restored content = %q, want old", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitDirRemovesFirstInstallWhenPublishFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "demo")
|
||||
staged, cleanup, err := StageDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
if err := os.MkdirAll(staged, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = CommitDir(target, staged, func() error { return errors.New("publish failed") })
|
||||
if err == nil {
|
||||
t.Fatal("CommitDir unexpectedly succeeded")
|
||||
}
|
||||
if _, statErr := os.Stat(target); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("failed first install remains at target: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupWorkspacePreservesRetainedPreviousInstall(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
previous := filepath.Join(workspace, "previous")
|
||||
if err := os.MkdirAll(previous, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cleanupWorkspace(workspace)
|
||||
|
||||
if _, err := os.Stat(previous); err != nil {
|
||||
t.Fatalf("cleanup removed retained previous install: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build !windows
|
||||
|
||||
package installtxn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func lockFile(path string) (func(), error) {
|
||||
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open install lock: %w", err)
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("lock install root: %w", err)
|
||||
}
|
||||
return func() {
|
||||
_ = unix.Flock(int(file.Fd()), unix.LOCK_UN)
|
||||
_ = file.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func replaceFile(source string, target string) error {
|
||||
return os.Rename(source, target)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package installtxn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func lockFile(path string) (func(), error) {
|
||||
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open install lock: %w", err)
|
||||
}
|
||||
handle := windows.Handle(file.Fd())
|
||||
overlapped := new(windows.Overlapped)
|
||||
if err := windows.LockFileEx(handle, windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("lock install root: %w", err)
|
||||
}
|
||||
return func() {
|
||||
_ = windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
|
||||
_ = file.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func replaceFile(source string, target string) error {
|
||||
return windows.MoveFileEx(windows.StringToUTF16Ptr(source), windows.StringToUTF16Ptr(target), windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
|
||||
}
|
||||
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user