mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ca569d09c | ||
|
|
9d324c487e | ||
|
|
abbed6f791 | ||
|
|
fa68f834d5 | ||
|
|
e54e5af6d7 | ||
|
|
2b08339aee | ||
|
|
64df522343 | ||
|
|
7cb13ccc22 | ||
|
|
73c2d59afb | ||
|
|
9e2dad38e2 | ||
|
|
e894db9ab4 | ||
|
|
45a3b59a8d | ||
|
|
fd88bde58d | ||
|
|
a9897f45e8 | ||
|
|
20a22cadac | ||
|
|
6937a309cf | ||
|
|
da244bcbfc | ||
|
|
e34c8d2252 | ||
|
|
91d921f4c5 | ||
|
|
6ae8396f02 | ||
|
|
2837f5efb3 | ||
|
|
08fbea7a32 | ||
|
|
82ae3a8809 | ||
|
|
488def68dc | ||
|
|
b04fe96e9e | ||
|
|
79fa1422ef | ||
|
|
1aacf38b72 | ||
|
|
3063593129 | ||
|
|
d6b25fc303 | ||
|
|
7c50a9fb98 | ||
|
|
275e248669 | ||
|
|
0207f2b269 | ||
|
|
8b3d1b1597 | ||
|
|
a3852bac59 | ||
|
|
a3e7446681 | ||
|
|
0021475d4c | ||
|
|
deba3adfca | ||
|
|
501f32160f | ||
|
|
1e4db7c664 | ||
|
|
f9092668a8 | ||
|
|
7f5e5af7bb | ||
|
|
f30f550e60 | ||
|
|
aadb4a27e9 | ||
|
|
a6ec0435e3 | ||
|
|
1b5db17656 | ||
|
|
eeea3308fe | ||
|
|
27b319ca88 | ||
|
|
1fde941803 | ||
|
|
7acac7acee | ||
|
|
1649008506 | ||
|
|
6fe0d1edcd | ||
|
|
e59bf78cab | ||
|
|
6645f9744b |
No files matched your search
@@ -29,6 +29,9 @@ reviews:
|
||||
- path: "tests/**/*.ts"
|
||||
instructions: |
|
||||
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
|
||||
- path: "**/*.go"
|
||||
instructions: |
|
||||
Do not request Plan 9 or other unsupported GOOS/GOARCH shims. The five native prebuilt release targets are linux-x64, linux-arm64, macos-arm64, macos-x64, and windows-x64. Android/Termux is also a supported runtime through the documented android-arm64 source build and Linux artifact fallback. Other GOOS/GOARCH combinations are not a review criterion.
|
||||
tools:
|
||||
github-checks:
|
||||
timeout_ms: 300000
|
||||
|
||||
+153
-75
@@ -6,10 +6,109 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Code Quality & Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Check formatting
|
||||
run: make fmt-check
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: make deadcode
|
||||
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: make lint-static
|
||||
|
||||
test:
|
||||
name: Unit Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Test with coverage
|
||||
run: |
|
||||
go test -count=1 -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: Publish coverage summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f coverage.out ]; then
|
||||
go tool cover -func=coverage.out | awk '
|
||||
BEGIN {
|
||||
total = "unknown"
|
||||
rows = ""
|
||||
}
|
||||
$1 == "total:" {
|
||||
total = $3
|
||||
next
|
||||
}
|
||||
{
|
||||
rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3)
|
||||
}
|
||||
END {
|
||||
print "### 📊 Test Coverage Summary: **" total "**"
|
||||
print "<details><summary>Coverage per function</summary>\n"
|
||||
print "| File | Function | Coverage |"
|
||||
print "| :--- | :--- | :--- |"
|
||||
printf "%s", rows
|
||||
print "</details>"
|
||||
}' >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: zero-coverage-report
|
||||
path: coverage.out
|
||||
if-no-files-found: warn
|
||||
|
||||
smoke:
|
||||
name: Smoke (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -18,64 +117,22 @@ jobs:
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
unformatted="$(gofmt -l .)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:" >&2
|
||||
echo "$unformatted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
- name: Test (Fast Non-Race)
|
||||
run: go test ./...
|
||||
|
||||
- name: Build binary
|
||||
@@ -84,9 +141,15 @@ jobs:
|
||||
- name: Smoke binary
|
||||
run: go run ./cmd/zero-release smoke
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
race:
|
||||
name: Race Detector
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# A race the detector newly reports can present as a deadlock rather than a
|
||||
# failure, and the default job timeout is six hours. This job is expected to
|
||||
# be the longest in the workflow, so the ceiling is generous but finite.
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -100,55 +163,70 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# Invoke the Makefile target rather than repeating its flags. `make test`
|
||||
# is already `go test ./... -race -count=1`, so running it here is what
|
||||
# stops the project's declared test command and what CI actually runs
|
||||
# from drifting apart again. The race detector needs cgo, which the
|
||||
# hosted ubuntu runner supplies without an extra setup step.
|
||||
- name: Test with the race detector
|
||||
run: make test
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build binary
|
||||
run: go run ./cmd/zero-release build
|
||||
|
||||
- name: Performance smoke
|
||||
run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||
run: |
|
||||
mkdir -p dist/perf
|
||||
go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||
|
||||
- name: Upload performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: zero-performance-smoke
|
||||
path: dist/perf/perf-bench.json
|
||||
if-no-files-found: warn
|
||||
|
||||
security:
|
||||
name: Security & code health
|
||||
name: Security & Code Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# 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
|
||||
- name: govulncheck (linux)
|
||||
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: make deadcode
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
# package but never actually called, plus staticcheck-style correctness
|
||||
# and readability issues. Scoped to a few linters rather than the full
|
||||
# default battery, and non-blocking, while the existing findings across
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: make lint-static
|
||||
- name: govulncheck (windows)
|
||||
run: make vulncheck-windows
|
||||
@@ -14,6 +14,9 @@ on:
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate action.yml
|
||||
|
||||
@@ -81,6 +81,13 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
|
||||
paths on Windows). Prefer one cross-platform function with small `GOOS`
|
||||
checks over duplicated helpers that drift.
|
||||
- **Supported targets only:** CONTRIBUTING.md names the five native prebuilt
|
||||
release artifacts (`linux-x64`, `linux-arm64`, `macos-arm64`, `macos-x64`,
|
||||
`windows-x64`) and the additional supported Android/Termux source-build and
|
||||
artifact-fallback path. Other `GOOS`/`GOARCH` combinations, including
|
||||
`plan9`, are unsupported and are not a review criterion. Do not block a PR
|
||||
for failing to compile on an unsupported `GOOS`/`GOARCH`. Tests may use
|
||||
`plan9` as a fixture for "unsupported".
|
||||
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
|
||||
success and error paths (including stderr). Fail closed on ownership, lease,
|
||||
and permission checks. Do not rely on pre-open path resolution
|
||||
|
||||
@@ -126,6 +126,31 @@ not enough. Dependency changes need a clear project benefit, such as fixing a
|
||||
specific bug, addressing a security issue, improving compatibility, or supporting
|
||||
an approved feature.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
Zero publishes five native prebuilt artifacts from
|
||||
`.github/workflows/release-artifacts.yml`:
|
||||
|
||||
- `linux-x64` (`linux/amd64`)
|
||||
- `linux-arm64` (`linux/arm64`)
|
||||
- `macos-arm64` (`darwin/arm64`)
|
||||
- `macos-x64` (`darwin/amd64`)
|
||||
- `windows-x64` (`windows/amd64`)
|
||||
|
||||
The shell installer and npm wrapper resolve the applicable artifacts from that
|
||||
list. The PowerShell installer supports the published `windows-x64` artifact;
|
||||
there is no native Windows ARM64 artifact. Windows on ARM users can run the x64
|
||||
build under emulation or build from source.
|
||||
|
||||
Android/Termux is also supported even though it has no distinct release
|
||||
artifact. The npm wrapper maps Android to the Linux artifact, and
|
||||
`docs/INSTALL.md` documents the native `android/arm64` source build needed on
|
||||
devices whose seccomp policy blocks the Linux build's syscall path.
|
||||
|
||||
Other `GOOS`/`GOARCH` values, including Plan 9, are unsupported. They are not a
|
||||
review criterion. Do not block a pull request for failing to compile on an
|
||||
unsupported target. Tests may use `plan9` as a fixture for an unsupported OS.
|
||||
|
||||
## What We Prioritize
|
||||
|
||||
The core team will prioritize:
|
||||
|
||||
@@ -7,7 +7,7 @@ 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
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
@@ -44,7 +44,8 @@ lint: fmt-check vet
|
||||
# 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 deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off
|
||||
|
||||
lint-static:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||
@@ -55,6 +56,11 @@ deadcode:
|
||||
vulncheck:
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
vulncheck-windows:
|
||||
mkdir -p "$(CURDIR)/.cache/gobin"
|
||||
GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
|
||||
GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
@@ -77,4 +83,4 @@ baseline: build
|
||||
--output internal/perfbench/reports/baseline.json
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline"
|
||||
@@ -26,7 +26,7 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
## Why Zero
|
||||
|
||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
|
||||
OpenAI-/Anthropic-compatible endpoint.
|
||||
- **Stay in control.** File writes, shell commands, network access, and
|
||||
out-of-workspace writes go through Zero's permission and sandbox policy.
|
||||
@@ -182,8 +182,12 @@ zero providers add custom-openai-compatible \
|
||||
--set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
`zero providers detect`.
|
||||
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
|
||||
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
|
||||
load a model and enable its local OpenAI-compatible API (default
|
||||
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
|
||||
loaded model ID in the add command. If no usable ID is discovered, load a model
|
||||
and retry. Model IDs requiring shell-specific quoting use interactive setup.
|
||||
|
||||
## Daily Use
|
||||
|
||||
@@ -273,6 +277,19 @@ zero sandbox grants list
|
||||
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
||||
and MCP support for additional tools.
|
||||
|
||||
`web_fetch` refuses loopback, private and other special-use addresses: it checks
|
||||
the URL before asking permission, resolves the host, and dials the address it
|
||||
validated so a name cannot resolve to something else in between.
|
||||
|
||||
If `HTTP_PROXY`/`HTTPS_PROXY` is set, `web_fetch` and the provider connectivity
|
||||
probe use it, and that last step changes: the proxy is dialed and the target
|
||||
hostname is sent to it, so the proxy decides which address the request actually
|
||||
reaches. The URL is still checked and resolved locally first, but a proxy that
|
||||
answers differently can reach a private service. A forward proxy already sees
|
||||
and can rewrite every request through it, so this is the trust you accept by
|
||||
configuring one. Leave the variables unset for the checks to be enforced end to
|
||||
end.
|
||||
|
||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
||||
so the normal sandbox and permission policy applies. Long-running commands stay
|
||||
attached to a background terminal session and can be listed or stopped from the
|
||||
@@ -371,9 +388,13 @@ go run ./cmd/zero-perf-bench
|
||||
|
||||
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
||||
session (background connection prewarm + request-prefix telemetry) for headless
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
|
||||
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
|
||||
variable unset and set.
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
|
||||
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
|
||||
with the variable unset and set.
|
||||
|
||||
Native ChatGPT Responses sessions are enabled by default. Set
|
||||
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
|
||||
transport.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
|
||||
+6
-2
@@ -23,7 +23,7 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||
@@ -114,7 +114,11 @@ export LONGCAT_API_KEY=...
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
|
||||
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。
|
||||
|
||||
## 日常使用
|
||||
|
||||
|
||||
@@ -627,7 +627,12 @@ Core tool groups include:
|
||||
skills, `ask_user`, permission requests.
|
||||
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
|
||||
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
|
||||
- **Network tools**: `web_fetch` and optional web search backend.
|
||||
- **Network tools**: `web_fetch` and optional web search backend. `web_fetch`
|
||||
validates the URL before the permission prompt, refuses loopback/private/
|
||||
special-use addresses, and dials the address it resolved. With
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
|
||||
target itself, so destination enforcement ends at the proxy; see the note in
|
||||
the README.
|
||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||
`internal/browser`.
|
||||
|
||||
+13
-3
@@ -93,14 +93,24 @@ binary:
|
||||
|
||||
| State on disk | Meaning |
|
||||
|---|---|
|
||||
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
|
||||
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker, or a per-user update-recovery record naming that copy | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. The `.keep` marker is in the installation directory and can be deleted; the identity-bound record is not, so deleting the marker alone does not end the refusal. |
|
||||
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
|
||||
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
|
||||
|
||||
The refusal names the paths involved and the two moves that end the state:
|
||||
either move the recovery binary back over the executable path, or — if the
|
||||
installed binary is the one you want — delete the `.keep` marker (or the
|
||||
`.recovery` copy, once you have verified the installed binary) and update again.
|
||||
installed binary is the one you want — delete the recovery copy itself (not
|
||||
only the `.keep` marker) after verifying the installed binary, then update
|
||||
again. Deleting the marker alone is not enough: a failed restore also records
|
||||
that copy in per-user state outside the installation directory, and the next
|
||||
update consults that record even if the marker is gone.
|
||||
|
||||
That trusted record lives under `%AppData%\zero\update-recovery` (per-user).
|
||||
The recovery copy and its `.keep` marker sit in the installation directory
|
||||
(per-machine). A second Windows account on the same machine does not see the
|
||||
first account's unresolved record; the sibling marker is the machine-visible
|
||||
signal for every account. There is no separate machine-wide store: writing one
|
||||
would need a shared writable location and ACL rules the updater does not own.
|
||||
|
||||
This is deliberately fail-closed and differs from older releases, which deleted
|
||||
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.26.6
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
charm.land/bubbletea/v2 v2.0.8
|
||||
charm.land/bubbletea/v2 v2.0.9
|
||||
charm.land/lipgloss/v2 v2.0.5
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sys v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
||||
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
||||
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
|
||||
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -64,13 +64,15 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
+52
-6
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -30,10 +31,12 @@ type Deps struct {
|
||||
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.
|
||||
BuildWorkspace func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error)
|
||||
// BuildWorkspace creates the per-turn scoped tool workspace for a validated
|
||||
// workspace root. Its Close method releases any resources the registry owns
|
||||
// (notably MCP server connections) after the turn completes or is cancelled.
|
||||
// Shell tools remain confined exactly like the exec surface — never unconfined
|
||||
// on the host.
|
||||
BuildWorkspace func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*Workspace, error)
|
||||
// ResolveWorkspaceRoot validates + normalizes a client-supplied cwd (must be an
|
||||
// existing directory; never the bare root). It is the file-tool confinement root.
|
||||
ResolveWorkspaceRoot func(cwd string) (string, error)
|
||||
@@ -41,6 +44,27 @@ type Deps struct {
|
||||
AgentInfo Implementation
|
||||
}
|
||||
|
||||
// Workspace is the per-turn execution environment passed to the agent. ACP does
|
||||
// not retain it across turns because MCP connections belong to the registry that
|
||||
// advertised their tools; keeping a stale registry after a cancelled turn would
|
||||
// leak its server process and its permission state into the next turn.
|
||||
type Workspace struct {
|
||||
Registry *tools.Registry
|
||||
Sandbox *sandbox.Engine
|
||||
DeferThreshold int
|
||||
Notices []string
|
||||
Cleanup func() error
|
||||
}
|
||||
|
||||
// Close releases resources created with the workspace. A nil cleanup is valid
|
||||
// for core-only workspaces.
|
||||
func (w *Workspace) Close() error {
|
||||
if w == nil || w.Cleanup == nil {
|
||||
return nil
|
||||
}
|
||||
return w.Cleanup()
|
||||
}
|
||||
|
||||
// Agent is the ACP agent server bound to one JSON-RPC connection (one editor).
|
||||
type Agent struct {
|
||||
conn *Conn
|
||||
@@ -241,13 +265,34 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
if err != nil {
|
||||
return "", RPCError(codeInternalError, "provider: "+err.Error())
|
||||
}
|
||||
mode := sess.currentMode()
|
||||
// Build the SCOPED registry + sandbox engine for this session's workspace so
|
||||
// shell/file tools are confined to the workspace exactly like the exec surface.
|
||||
registry, sandboxEngine, err := a.deps.BuildWorkspace(sess.cwd, resolved)
|
||||
// The workspace can also own MCP connections, which must be closed after every
|
||||
// turn even if the agent run returns an error or the client cancels it.
|
||||
workspace, err := a.deps.BuildWorkspace(ctx, sess.cwd, resolved, mode)
|
||||
if err != nil {
|
||||
return "", RPCError(codeInternalError, "workspace: "+err.Error())
|
||||
}
|
||||
if workspace == nil || workspace.Registry == nil {
|
||||
if workspace != nil {
|
||||
_ = workspace.Close()
|
||||
}
|
||||
return "", RPCError(codeInternalError, "workspace: missing tool registry")
|
||||
}
|
||||
defer func() {
|
||||
if err := workspace.Close(); err != nil {
|
||||
log.Printf("acp: close workspace: %v", err)
|
||||
}
|
||||
}()
|
||||
registry := workspace.Registry
|
||||
sandboxEngine := workspace.Sandbox
|
||||
note := ¬ifier{conn: a.conn, sessionID: sess.id}
|
||||
for _, notice := range workspace.Notices {
|
||||
if text := strings.TrimSpace(redaction.RedactString(notice, redaction.Options{})); text != "" {
|
||||
note.text("\n\n[zero warning] " + text + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
opts := agent.Options{
|
||||
Cwd: sess.cwd,
|
||||
@@ -256,7 +301,8 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
Model: resolved.Provider.Model,
|
||||
Registry: registry,
|
||||
Sandbox: sandboxEngine,
|
||||
PermissionMode: sess.currentMode(),
|
||||
PermissionMode: mode,
|
||||
DeferThreshold: workspace.DeferThreshold,
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Images: images,
|
||||
OnText: note.text,
|
||||
|
||||
+152
-4
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -35,6 +36,35 @@ func (f fakeProvider) StreamCompletion(_ context.Context, _ zeroruntime.Completi
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type acpDeferredPromptTool struct{ name string }
|
||||
|
||||
func (t acpDeferredPromptTool) Name() string { return t.name }
|
||||
func (acpDeferredPromptTool) Description() string { return "deferred MCP test tool" }
|
||||
func (acpDeferredPromptTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
|
||||
func (acpDeferredPromptTool) Deferred() bool { return true }
|
||||
func (acpDeferredPromptTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
func (acpDeferredPromptTool) Safety() tools.Safety {
|
||||
return tools.Safety{
|
||||
SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionPrompt,
|
||||
AdvertiseInAuto: true,
|
||||
}
|
||||
}
|
||||
|
||||
type captureToolsProvider struct {
|
||||
requests chan zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func (p captureToolsProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
p.requests <- request
|
||||
ch := make(chan zeroruntime.StreamEvent, 2)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func testDeps(t *testing.T) Deps {
|
||||
t.Helper()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
@@ -53,10 +83,10 @@ func testDeps(t *testing.T) Deps {
|
||||
return fakeProvider{text: "Hello from ZERO"}, nil
|
||||
},
|
||||
RunAgent: agent.Run,
|
||||
BuildWorkspace: func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
BuildWorkspace: func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
|
||||
r := tools.NewRegistry()
|
||||
r.Register(tools.NewUpdatePlanTool())
|
||||
return r, nil, nil
|
||||
return &Workspace{Registry: r}, nil
|
||||
},
|
||||
ResolveWorkspaceRoot: func(cwd string) (string, error) { return cwd, nil },
|
||||
Store: store,
|
||||
@@ -468,8 +498,8 @@ func TestACPRunTurnWiresSandboxAndScopedRegistry(t *testing.T) {
|
||||
reg := tools.NewRegistry()
|
||||
reg.Register(tools.NewUpdatePlanTool())
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
deps.BuildWorkspace = func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
return reg, engine, nil
|
||||
deps.BuildWorkspace = func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
|
||||
return &Workspace{Registry: reg, Sandbox: engine}, nil
|
||||
}
|
||||
var captured agent.Options
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
@@ -497,6 +527,124 @@ func TestACPRunTurnWiresSandboxAndScopedRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPRunTurnUsesWorkspaceDeferThresholdAtProviderBoundary(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
threshold int
|
||||
want []string
|
||||
}{
|
||||
{name: "explicit zero disables", threshold: 0, want: []string{"mcp_docs_alpha", "mcp_docs_beta"}},
|
||||
{name: "below threshold stays eager", threshold: 3, want: []string{"mcp_docs_alpha", "mcp_docs_beta"}},
|
||||
{name: "at threshold defers", threshold: 2, want: []string{tools.ToolSearchToolName}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
requests := make(chan zeroruntime.CompletionRequest, 4)
|
||||
deps := testDeps(t)
|
||||
deps.NewProvider = func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return captureToolsProvider{requests: requests}, nil
|
||||
}
|
||||
deps.BuildWorkspace = func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(acpDeferredPromptTool{name: "mcp_docs_alpha"})
|
||||
registry.Register(acpDeferredPromptTool{name: "mcp_docs_beta"})
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
return &Workspace{Registry: registry, DeferThreshold: tc.threshold}, nil
|
||||
}
|
||||
|
||||
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.Fatal(err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("hi")}}, &PromptResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := <-requests
|
||||
got := make([]string, 0, len(request.Tools))
|
||||
for _, definition := range request.Tools {
|
||||
got = append(got, definition.Name)
|
||||
}
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("provider tools = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPRunTurnEmitsWorkspaceSetupNotices(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.BuildWorkspace = func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
return &Workspace{Registry: registry, Notices: []string{"MCP server docs unavailable, skipped: timeout"}}, nil
|
||||
}
|
||||
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.Fatal(err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("hi")}}, &PromptResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := drainText(t, h.updates)
|
||||
if !strings.Contains(got, "[zero warning] MCP server docs unavailable, skipped: timeout") || !strings.Contains(got, "Hello from ZERO") {
|
||||
t.Fatalf("streamed text = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRunTurnClosesWorkspace proves a per-turn workspace never outlives the
|
||||
// agent run. This is particularly important for MCP: its registry owns live
|
||||
// client connections and, for stdio servers, child processes.
|
||||
func TestACPRunTurnClosesWorkspaceAfterSuccessAndFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
runErr error
|
||||
}{
|
||||
{name: "success"},
|
||||
{name: "agent failure", runErr: errors.New("provider interrupted")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
closed := 0
|
||||
deps.BuildWorkspace = func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
return &Workspace{Registry: registry, Cleanup: func() error {
|
||||
closed++
|
||||
return nil
|
||||
}}, nil
|
||||
}
|
||||
deps.RunAgent = func(context.Context, string, zeroruntime.Provider, agent.Options) (agent.Result, error) {
|
||||
return agent.Result{FinalAnswer: "ok"}, tc.runErr
|
||||
}
|
||||
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)
|
||||
}
|
||||
err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("hello")}}, &PromptResult{})
|
||||
if tc.runErr == nil && err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if tc.runErr != nil && err == nil {
|
||||
t.Fatal("session/prompt succeeded after agent failure")
|
||||
}
|
||||
if closed != 1 {
|
||||
t.Fatalf("workspace cleanup calls = %d, want 1", closed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRejectsInvalidCwd confirms session/new fails when the workspace root
|
||||
// resolver rejects the client cwd (e.g. filesystem root).
|
||||
func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
|
||||
@@ -142,13 +142,15 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
|
||||
// session/request_permission request from a ZERO permission request.
|
||||
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
|
||||
args := marshalArgs(req.Args)
|
||||
return ToolCallUpdate{
|
||||
upd := ToolCallUpdate{
|
||||
ToolCallID: req.ToolCallID,
|
||||
Title: toolTitle(req.ToolName, string(args)),
|
||||
Kind: toolKindFor(req.ToolName),
|
||||
Status: ToolStatusPending,
|
||||
RawInput: rawInputBytes(args),
|
||||
}
|
||||
attachBrowserToolDetails(&upd, req.ToolName)
|
||||
return upd
|
||||
}
|
||||
|
||||
func marshalArgs(args map[string]any) []byte {
|
||||
|
||||
@@ -82,3 +82,17 @@ func TestPermissionToolCall(t *testing.T) {
|
||||
t.Error("expected rawInput from args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
|
||||
call := permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "browser-1",
|
||||
ToolName: "browser_connect",
|
||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
||||
})
|
||||
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
|
||||
t.Fatalf("browser descriptor = %#v", got)
|
||||
}
|
||||
if call.Title != "browser connect" {
|
||||
t.Fatalf("title = %q", call.Title)
|
||||
}
|
||||
}
|
||||
+162
-6
@@ -2,7 +2,10 @@ package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -44,12 +47,125 @@ func toolKindFor(name string) string {
|
||||
|
||||
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
|
||||
func toolTitle(name, rawArgs string) string {
|
||||
if browser, ok := browserToolDetails(name); ok {
|
||||
return browserToolTitle(browser.Command, rawArgs)
|
||||
}
|
||||
if hint := primaryArgHint(rawArgs); hint != "" {
|
||||
return name + " " + hint
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// browserToolDetails identifies ZERO's local browser helpers without treating
|
||||
// similarly named MCP tools as browser automation. The descriptor intentionally
|
||||
// contains no request data: ACP tool input is already protocol-visible, but a
|
||||
// durable UI must not need to retain text, local CDP targets, or full URLs just
|
||||
// to recognise the browser operation.
|
||||
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
|
||||
const prefix = "browser_"
|
||||
command, ok := strings.CutPrefix(name, prefix)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch command {
|
||||
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
|
||||
return &BrowserToolDetails{Version: 1, Command: command}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
|
||||
|
||||
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
|
||||
// extension channel. Keeping this in one helper prevents start, result, and
|
||||
// permission payloads from drifting onto different wire shapes.
|
||||
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
|
||||
browser, ok := browserToolDetails(name)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(browser)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
|
||||
}
|
||||
|
||||
// browserToolTitle avoids putting browser_type text, an attached DevTools
|
||||
// endpoint, or a URL query/fragment in a tool-card title. Those values can
|
||||
// carry credentials or session data; the UI only needs the operation and, for
|
||||
// navigation, a human-recognisable origin.
|
||||
func browserToolTitle(command, rawArgs string) string {
|
||||
switch command {
|
||||
case "action":
|
||||
action, ok := exactJSONStringArg(rawArgs, "command")
|
||||
if !ok {
|
||||
return "browser action"
|
||||
}
|
||||
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
|
||||
return "browser action " + action
|
||||
}
|
||||
return "browser action"
|
||||
case "open":
|
||||
rawURL, ok := exactJSONStringArg(rawArgs, "url")
|
||||
if !ok {
|
||||
return "browser open"
|
||||
}
|
||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
||||
if err != nil {
|
||||
return "browser open"
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "browser open"
|
||||
}
|
||||
origin := u.Scheme + "://" + u.Host
|
||||
if !browserTitleTextSafe(origin) {
|
||||
return "browser open"
|
||||
}
|
||||
return "browser open " + truncateHint(origin)
|
||||
default:
|
||||
return "browser " + command
|
||||
}
|
||||
}
|
||||
|
||||
// browserTitleTextSafe validates text after URL parsing has decoded escaped
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: control,
|
||||
// format/bidi, and line/paragraph separator runes can reorder or split the
|
||||
// permission label shown to a user. The execution URL remains unchanged.
|
||||
func browserTitleTextSafe(text string) bool {
|
||||
if !utf8.ValidString(text) {
|
||||
return false
|
||||
}
|
||||
for _, r := range text {
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// exactJSONStringArg mirrors ZERO's map-based tool argument decoding: only the
|
||||
// exact JSON key is considered, and a non-string value is invalid. In
|
||||
// particular, an incidental "URL" key must not change a permission title when
|
||||
// browser_open will only read "url".
|
||||
func exactJSONStringArg(rawArgs, key string) (string, bool) {
|
||||
var args map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(rawArgs), &args) != nil {
|
||||
return "", false
|
||||
}
|
||||
raw, ok := args[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var value string
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
|
||||
// raw JSON arguments. Best-effort; returns "" when it can't parse.
|
||||
func primaryArgHint(rawArgs string) string {
|
||||
@@ -89,7 +205,7 @@ func rawInput(args string) json.RawMessage {
|
||||
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
|
||||
// update (status in_progress — ZERO executes immediately after advertising).
|
||||
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
return ToolCallUpdate{
|
||||
upd := ToolCallUpdate{
|
||||
SessionUpdate: UpdateToolCall,
|
||||
ToolCallID: call.ID,
|
||||
Title: toolTitle(call.Name, call.Arguments),
|
||||
@@ -97,6 +213,8 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
Status: ToolStatusInProgress,
|
||||
RawInput: rawInput(call.Arguments),
|
||||
}
|
||||
attachBrowserToolDetails(&upd, call.Name)
|
||||
return upd
|
||||
}
|
||||
|
||||
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
|
||||
@@ -116,26 +234,64 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
|
||||
if locs := toolResultLocations(result); len(locs) > 0 {
|
||||
upd.Locations = locs
|
||||
}
|
||||
attachBrowserToolDetails(&upd, result.Name)
|
||||
return upd
|
||||
}
|
||||
|
||||
func toolResultContent(result agent.ToolResult) []ToolCallContent {
|
||||
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
|
||||
text := strings.TrimRight(result.Output, "\n")
|
||||
if text == "" {
|
||||
text = result.Display.Summary
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
return appendToolResultDiffs(content, result.FileDiffs)
|
||||
}
|
||||
return []ToolCallContent{ToolContent(TextBlock(text))}
|
||||
content = append(content, ToolContent(TextBlock(text)))
|
||||
return appendToolResultDiffs(content, result.FileDiffs)
|
||||
}
|
||||
|
||||
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
|
||||
for _, diff := range diffs {
|
||||
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
|
||||
continue
|
||||
}
|
||||
var oldText *string
|
||||
if diff.OldExists {
|
||||
old := diff.OldText
|
||||
oldText = &old
|
||||
}
|
||||
var newText *string
|
||||
if diff.NewExists {
|
||||
updated := diff.NewText
|
||||
newText = &updated
|
||||
}
|
||||
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
|
||||
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
|
||||
for _, f := range result.ChangedFiles {
|
||||
if strings.TrimSpace(f) == "" {
|
||||
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
|
||||
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
|
||||
for _, diff := range result.FileDiffs {
|
||||
path := diff.Path
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
locs = append(locs, ToolCallLocation{Path: path})
|
||||
}
|
||||
// FileDiff.Path is canonical absolute path data while ChangedFiles is
|
||||
// normally workspace-relative. Without the trusted workspace root these
|
||||
// coordinate systems cannot be correlated safely: a suffix match would let
|
||||
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
|
||||
// shared seen set deduplicates only identities already exactly comparable.
|
||||
for _, f := range result.ChangedFiles {
|
||||
if f == "" || seen[f] {
|
||||
continue
|
||||
}
|
||||
seen[f] = true
|
||||
locs = append(locs, ToolCallLocation{Path: f})
|
||||
}
|
||||
return locs
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
|
||||
t.Helper()
|
||||
raw, ok := update.Meta[zeroBrowserMetaKey]
|
||||
if !ok {
|
||||
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
|
||||
}
|
||||
var details BrowserToolDetails
|
||||
if err := json.Unmarshal(raw, &details); err != nil {
|
||||
t.Fatalf("decode browser metadata: %v", err)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func TestAgentMessageAndThoughtChunks(t *testing.T) {
|
||||
m := agentMessageChunk("hello")
|
||||
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
|
||||
@@ -56,6 +72,217 @@ func TestToolTitleAndHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
|
||||
start := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-1",
|
||||
Name: "browser_open",
|
||||
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
|
||||
})
|
||||
if got := browserDescriptor(t, start); got != (BrowserToolDetails{Version: 1, Command: "open"}) {
|
||||
t.Fatalf("browser descriptor = %#v, want open", got)
|
||||
}
|
||||
if start.Title != "browser open https://example.com" {
|
||||
t.Fatalf("browser title = %q", start.Title)
|
||||
}
|
||||
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
|
||||
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
|
||||
}
|
||||
encoded, err := json.Marshal(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire struct {
|
||||
Meta map[string]json.RawMessage `json:"_meta"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := wire.Meta[zeroBrowserMetaKey]; !ok {
|
||||
t.Fatalf("browser wire metadata = %#v", wire.Meta)
|
||||
}
|
||||
|
||||
typed := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-2",
|
||||
Name: "browser_type",
|
||||
Arguments: `{"ref":"email","text":"[email protected]"}`,
|
||||
})
|
||||
if got := browserDescriptor(t, typed); got.Command != "type" {
|
||||
t.Fatalf("browser type descriptor = %#v", got)
|
||||
}
|
||||
if typed.Title != "browser type" || strings.Contains(typed.Title, "[email protected]") {
|
||||
t.Fatalf("browser type title = %q", typed.Title)
|
||||
}
|
||||
|
||||
action := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-3",
|
||||
Name: "browser_action",
|
||||
Arguments: `{"command":"keyboard_insert_text","args":["[email protected]"]}`,
|
||||
})
|
||||
if action.Title != "browser action keyboard_insert_text" {
|
||||
t.Fatalf("browser action title = %q", action.Title)
|
||||
}
|
||||
|
||||
result := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "browser-2",
|
||||
Name: "browser_type",
|
||||
Status: tools.StatusOK,
|
||||
})
|
||||
if got := browserDescriptor(t, result); got.Command != "type" {
|
||||
t.Fatalf("browser result descriptor = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserDescriptorSurvivesProtocolShapedRoundTrip(t *testing.T) {
|
||||
updates := []ToolCallUpdate{
|
||||
toolCallStart(agent.ToolCall{
|
||||
ID: "start",
|
||||
Name: "browser_open",
|
||||
Arguments: `{"url":"https://user:[email protected]/private?token=secret#fragment"}`,
|
||||
}),
|
||||
toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "result",
|
||||
Name: "browser_type",
|
||||
Status: tools.StatusOK,
|
||||
}),
|
||||
permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "permission",
|
||||
ToolName: "browser_connect",
|
||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
||||
}),
|
||||
}
|
||||
|
||||
type protocolToolCallUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||
Content []ToolCallContent `json:"content,omitempty"`
|
||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
encoded, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(encoded, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := root["browser"]; ok {
|
||||
t.Fatalf("browser descriptor escaped ACP _meta: %s", encoded)
|
||||
}
|
||||
|
||||
var protocol protocolToolCallUpdate
|
||||
if err := json.Unmarshal(encoded, &protocol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarded, err := json.Marshal(protocol)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roundTripped ToolCallUpdate
|
||||
if err := json.Unmarshal(forwarded, &roundTripped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
details := browserDescriptor(t, roundTripped)
|
||||
if details.Version != 1 || details.Command == "" {
|
||||
t.Fatalf("round-tripped browser descriptor = %#v", details)
|
||||
}
|
||||
descriptorJSON := string(roundTripped.Meta[zeroBrowserMetaKey])
|
||||
for _, secret := range []string{"password", "private", "token", "fragment", "127.0.0.1", "9222"} {
|
||||
if strings.Contains(descriptorJSON, secret) {
|
||||
t.Fatalf("browser descriptor leaked %q: %s", secret, descriptorJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
|
||||
if got := browserToolTitle("open", `{"url":"evil.example.test/pay?token=hidden#fragment"}`); got != "browser open https://evil.example.test" {
|
||||
t.Fatalf("bare-host title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("open", `{"URL":"https://different.example.test"}`); got != "browser open" {
|
||||
t.Fatalf("case-variant URL title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("open", `{"URL":"https://different.example.test","url":"https://actual.example.test/path"}`); got != "browser open https://actual.example.test" {
|
||||
t.Fatalf("exact URL key title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("action", `{"command":"not an action"}`); got != "browser action" {
|
||||
t.Fatalf("unknown browser action title = %q", got)
|
||||
}
|
||||
|
||||
longHost := "https://" + strings.Repeat("a", 200) + ".example.test/path?token=hidden"
|
||||
title := browserToolTitle("open", `{"url":"`+longHost+`"}`)
|
||||
if !utf8.ValidString(title) || utf8.RuneCountInString(title) > len("browser open ")+61 || strings.Contains(title, "token=") {
|
||||
t.Fatalf("bounded browser origin title = %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
|
||||
for _, rawURL := range []string{
|
||||
"https://safe.example%E2%80%AEevil.test/path",
|
||||
"https://safe.example%E2%81%A6evil.test/path",
|
||||
"https://safe.example%C2%85evil.test/path",
|
||||
"https://safe.example%E2%80%A8evil.test/path",
|
||||
"https://safe.example%E2%80%A9evil.test/path",
|
||||
} {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("execution URL rejected: %v", err)
|
||||
}
|
||||
if normalized != rawURL {
|
||||
t.Fatalf("execution URL = %q, want unchanged %q", normalized, rawURL)
|
||||
}
|
||||
|
||||
args, err := json.Marshal(map[string]any{"url": rawURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updates := []ToolCallUpdate{
|
||||
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
|
||||
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
|
||||
}
|
||||
for _, update := range updates {
|
||||
encoded, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded ToolCallUpdate
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Title != "browser open" {
|
||||
t.Fatalf("unsafe browser title survived wire round trip: %q", decoded.Title)
|
||||
}
|
||||
for _, r := range decoded.Title {
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
t.Fatalf("browser title contains unsafe presentation rune %U: %q", r, decoded.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
|
||||
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
|
||||
if len(start.Meta) != 0 {
|
||||
t.Fatalf("MCP-like tool received built-in browser metadata: %#v", start.Meta)
|
||||
}
|
||||
encoded, err := json.Marshal(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), `"browser"`) {
|
||||
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallStart(t *testing.T) {
|
||||
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
|
||||
if upd.SessionUpdate != UpdateToolCall {
|
||||
@@ -74,21 +301,26 @@ func TestToolCallStart(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToolCallResult(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.go")
|
||||
ok := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "tc1",
|
||||
Name: "edit_file",
|
||||
Status: tools.StatusOK,
|
||||
Output: "applied\n",
|
||||
ChangedFiles: []string{"a.go", ""},
|
||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
|
||||
})
|
||||
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
|
||||
t.Fatalf("unexpected ok result: %+v", ok)
|
||||
}
|
||||
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
t.Fatalf("unexpected content: %+v", ok.Content)
|
||||
}
|
||||
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
|
||||
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
|
||||
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
|
||||
t.Fatalf("unexpected diff content: %+v", diff)
|
||||
}
|
||||
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
|
||||
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
|
||||
}
|
||||
|
||||
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
|
||||
@@ -97,6 +329,190 @@ func TestToolCallResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "empty.txt")
|
||||
content := appendToolResultDiffs(nil, []tools.FileDiff{
|
||||
{Path: path, OldExists: false, NewExists: true, NewText: ""},
|
||||
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
|
||||
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
|
||||
})
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("diff content = %#v", content)
|
||||
}
|
||||
for index, diff := range content {
|
||||
encoded, err := json.Marshal(diff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wire["path"] != path {
|
||||
t.Fatalf("wire diff %d = %s", index, encoded)
|
||||
}
|
||||
switch index {
|
||||
case 0:
|
||||
oldText, present := wire["oldText"]
|
||||
if !present || oldText != nil || wire["newText"] != "" {
|
||||
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
|
||||
}
|
||||
case 1:
|
||||
if wire["oldText"] != "before" || wire["newText"] != "" {
|
||||
t.Fatalf("empty replacement diff = %s", encoded)
|
||||
}
|
||||
case 2:
|
||||
newText, present := wire["newText"]
|
||||
if wire["oldText"] != "before" || !present || newText != nil {
|
||||
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
rootPath := filepath.Join(root, "a.go")
|
||||
nestedPath := filepath.Join(root, "sub", "a.go")
|
||||
diff := func(path string) tools.FileDiff {
|
||||
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
diffs []tools.FileDiff
|
||||
want []string
|
||||
}{
|
||||
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
locations := toolResultLocations(agent.ToolResult{
|
||||
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
|
||||
FileDiffs: tc.diffs,
|
||||
})
|
||||
if len(locations) != len(tc.want) {
|
||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
||||
}
|
||||
for index := range tc.want {
|
||||
if locations[index].Path != tc.want[index] {
|
||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
|
||||
relativePath := " report.txt "
|
||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ChangedFiles: []string{relativePath},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
|
||||
}},
|
||||
})
|
||||
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
|
||||
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
|
||||
}
|
||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
||||
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.go")
|
||||
locations := toolResultLocations(agent.ToolResult{
|
||||
ChangedFiles: []string{path, path},
|
||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
|
||||
})
|
||||
if len(locations) != 1 || locations[0].Path != path {
|
||||
t.Fatalf("exact duplicate locations = %#v", locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
|
||||
relativePath := "deleted.go"
|
||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ChangedFiles: []string{relativePath},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
|
||||
}},
|
||||
})
|
||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
|
||||
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
|
||||
}
|
||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
||||
t.Fatalf("deleted file locations = %#v", update.Locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
path := filepath.Join(t.TempDir(), "secret.txt")
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
|
||||
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
|
||||
}}})
|
||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
|
||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
|
||||
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
|
||||
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{
|
||||
ChangedFiles: []string{"credentials.txt"},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
|
||||
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
|
||||
}},
|
||||
})
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "call", Status: tools.StatusOK,
|
||||
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
|
||||
})
|
||||
if len(update.Content) != 0 {
|
||||
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
|
||||
}
|
||||
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
|
||||
t.Fatalf("ACP path fallback = %#v", update.Locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
|
||||
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
|
||||
for name, separator := range map[string]string{
|
||||
"combining grapheme joiner": "\u034f",
|
||||
"variation selector": "\ufe0f",
|
||||
} {
|
||||
for _, side := range []string{"old", "new"} {
|
||||
t.Run(name+" "+side, func(t *testing.T) {
|
||||
obfuscated := secret[:20] + separator + secret[20:]
|
||||
diff := tools.FileDiff{
|
||||
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
|
||||
OldText: "safe old", NewText: "safe new",
|
||||
}
|
||||
if side == "old" {
|
||||
diff.OldText = obfuscated
|
||||
} else {
|
||||
diff.NewText = obfuscated
|
||||
}
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
|
||||
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
|
||||
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
|
||||
}
|
||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
|
||||
if len(update.Content) != 0 {
|
||||
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanUpdateAndStatus(t *testing.T) {
|
||||
upd := planUpdate([]tools.PlanItem{
|
||||
{Content: "step a", Status: "completed"},
|
||||
|
||||
+39
-3
@@ -210,6 +210,21 @@ type ToolCallUpdate struct {
|
||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||
Content []ToolCallContent `json:"content,omitempty"`
|
||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
|
||||
// namespaced key so protocol-shaped clients can preserve them while decoding
|
||||
// and re-encoding a tool call.
|
||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// BrowserToolDetails identifies the browser helper operation behind a tool
|
||||
// call. Version is the schema version for this optional ZERO extension;
|
||||
// Command is one of install, launch, connect, open, snapshot, click, type,
|
||||
// press, or action. Future fields must remain display-safe and must not
|
||||
// include browser profile data, cookies, typed text, URL paths/queries, or
|
||||
// DevTools endpoints.
|
||||
type BrowserToolDetails struct {
|
||||
Version int `json:"version"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
|
||||
@@ -220,9 +235,30 @@ type ToolCallContent struct {
|
||||
// type == "content"
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
// type == "diff"
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText string `json:"oldText,omitempty"`
|
||||
NewText string `json:"newText,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText *string `json:"oldText,omitempty"`
|
||||
NewText *string `json:"newText,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON preserves ACP's discriminated content union. A diff always has
|
||||
// path, while nullable oldText/newText distinguish creation, deletion, and an
|
||||
// existing file replaced with empty content. Other content variants omit all
|
||||
// diff fields rather than serializing irrelevant nulls.
|
||||
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
|
||||
if content.Type == "diff" {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
OldText *string `json:"oldText"`
|
||||
NewText *string `json:"newText"`
|
||||
}{
|
||||
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
|
||||
})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
}{Type: content.Type, Content: content.Content})
|
||||
}
|
||||
|
||||
func ToolContent(block ContentBlock) ToolCallContent {
|
||||
|
||||
+178
-11
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -144,11 +146,17 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
for _, tool := range tools {
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
total += ApproxTextTokens(string(tool.Type))
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
if tool.Format != nil {
|
||||
total += ApproxTextTokens(tool.Format.Type)
|
||||
total += ApproxTextTokens(tool.Format.Syntax)
|
||||
total += ApproxTextTokens(tool.Format.Definition)
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
}
|
||||
return total
|
||||
@@ -390,6 +398,27 @@ type compactionState struct {
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
|
||||
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
|
||||
// (Options.Summarizer) for the current main model. summarizerProvider
|
||||
// memoizes the built provider and summarizerResolved records that the
|
||||
// factory ran (it may legitimately return no provider: the main model is
|
||||
// already the cheap one). summarizerBroken is the sticky fallback: once a
|
||||
// build or call fails, the run stays on the main provider until the next
|
||||
// model switch — a misconfigured cheap model must never break (or
|
||||
// repeatedly delay) compaction.
|
||||
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
summarizerProvider Provider
|
||||
summarizerResolved bool
|
||||
summarizerBroken bool
|
||||
// window is the context window the threshold was derived from. The
|
||||
// reactive free-prune stage compares against it (the limit the provider
|
||||
// enforces), not against the 70% proactive threshold — a pruned history
|
||||
// between the two would still succeed on retry.
|
||||
window int
|
||||
// mainModel is the model the run currently streams on; the summarizer
|
||||
// choice is made against it and remade after a mid-run switch.
|
||||
mainModel string
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
@@ -424,17 +453,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
window: options.ContextWindow,
|
||||
mainModel: options.Model,
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
summarizerFactory: options.Summarizer,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// updateWindow re-derives the compaction threshold after a mid-run model
|
||||
// switch. The low-water mark is reset: it was measured against the old
|
||||
// threshold, and keeping it could suppress a compaction the smaller window
|
||||
// now requires. <= 0 (unknown model) leaves everything unchanged.
|
||||
func (state *compactionState) updateWindow(window int) {
|
||||
if window <= 0 {
|
||||
return
|
||||
}
|
||||
state.enabled = true
|
||||
state.window = window
|
||||
state.threshold = compactionThreshold(window)
|
||||
state.lowWaterMark = 0
|
||||
if state.planner != nil {
|
||||
state.planner.config.contextWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// switchModel records a mid-run model switch: the window is re-derived
|
||||
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
|
||||
// the next compaction re-resolves it against the new main model. A run that
|
||||
// started on the cheap model (no dedicated summarizer) and escalated to an
|
||||
// expensive one therefore regains cheap summaries — the exact workflow a
|
||||
// dedicated summarizer exists for.
|
||||
func (state *compactionState) switchModel(modelID string, window int) {
|
||||
state.mainModel = modelID
|
||||
state.updateWindow(window)
|
||||
state.summarizerProvider = nil
|
||||
state.summarizerResolved = false
|
||||
state.summarizerBroken = false
|
||||
}
|
||||
|
||||
// summarizeProvider returns the provider summarization calls should use and
|
||||
// whether it is the dedicated summarizer: that one when it applies to the
|
||||
// current main model and is healthy, else the main provider. The factory runs
|
||||
// at most once per main model; a build error marks the summarizer broken for
|
||||
// that model. Callers branch on the boolean rather than comparing Provider
|
||||
// values — interface comparison panics on a non-comparable dynamic type.
|
||||
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
|
||||
if state.summarizerFactory == nil || state.summarizerBroken {
|
||||
return main, false
|
||||
}
|
||||
if !state.summarizerResolved {
|
||||
state.summarizerResolved = true
|
||||
built, err := state.summarizerFactory(ctx, state.mainModel)
|
||||
if err != nil {
|
||||
state.summarizerBroken = true
|
||||
return main, false
|
||||
}
|
||||
state.summarizerProvider = built
|
||||
}
|
||||
if state.summarizerProvider == nil {
|
||||
return main, false
|
||||
}
|
||||
return state.summarizerProvider, true
|
||||
}
|
||||
|
||||
// fitsWindow reports whether a calibrated size is safely under the limit the
|
||||
// provider enforces. The proactive threshold (70% of the window) is a trigger
|
||||
// for the paid summarizer, not the hard limit; the reactive path compares
|
||||
// against the window itself, with a small margin for estimate error, so a
|
||||
// history pruned to, say, 85% of the window takes the free retry.
|
||||
func (state *compactionState) fitsWindow(size int) bool {
|
||||
if state.window <= 0 {
|
||||
return size <= state.threshold
|
||||
}
|
||||
return size <= state.window-state.window/20
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
|
||||
// when the history is under threshold, or when the history has not grown past
|
||||
@@ -528,10 +628,34 @@ func (state *compactionState) recover(
|
||||
// context-limit error never triggers an unexpected summarization call.
|
||||
return messages, false, nil
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
if !isContextLimitError(errorMessage) {
|
||||
return messages, false, nil
|
||||
}
|
||||
if !isContextLimitError(errorMessage) {
|
||||
|
||||
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
|
||||
// often be fixed for free by dropping superseded reads and pruning old
|
||||
// tool-result bodies. It runs regardless of the one-shot reactive budget
|
||||
// (it costs nothing and cannot loop: a second pass finds nothing left to
|
||||
// prune). The free retry is taken only when the pruned history fits the
|
||||
// window the provider enforces — otherwise a retry would fail the same
|
||||
// way, so fall through to the paid summarizer in this same call.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
prunedAny := false
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if prunedAny {
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if state.fitsWindow(size) {
|
||||
state.lowWaterMark = size
|
||||
return messages, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if state.reactiveAttempted {
|
||||
return messages, false, nil
|
||||
}
|
||||
|
||||
@@ -571,11 +695,21 @@ func (state *compactionState) recover(
|
||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||
// The call goes to the dedicated (cheap) summarizer when one applies; if that
|
||||
// call fails the summarizer is marked broken for the current model and the
|
||||
// same slice is retried once on the main provider, so the caller sees at most
|
||||
// one (main-provider-quality) error.
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
chosen, dedicated := state.summarizeProvider(ctx, provider)
|
||||
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
if err == nil || !dedicated {
|
||||
return summary, err
|
||||
}
|
||||
state.summarizerBroken = true
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
}
|
||||
}
|
||||
@@ -659,8 +793,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
|
||||
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
|
||||
// search results), yet the summary instructions only need their gist — the
|
||||
// model that ACTED on the full output already turned it into decisions the
|
||||
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
|
||||
// head+tail of a result while cutting the summarizer input by 5-10x on
|
||||
// tool-heavy sessions; the summarizer's own cost is the price being cut.
|
||||
const summaryToolResultClamp = 2000
|
||||
|
||||
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
|
||||
// a write_file call carries the whole file body in its arguments.
|
||||
const summaryToolArgsClamp = 500
|
||||
|
||||
// clampForSummary head+tail-truncates text for the summarizer transcript,
|
||||
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
|
||||
func clampForSummary(text string, max int) string {
|
||||
if max <= 0 || len(text) <= max {
|
||||
return text
|
||||
}
|
||||
head := max * 3 / 4
|
||||
tail := max - head
|
||||
for head > 0 && !utf8.RuneStart(text[head]) {
|
||||
head--
|
||||
}
|
||||
start := len(text) - tail
|
||||
for start < len(text) && !utf8.RuneStart(text[start]) {
|
||||
start++
|
||||
}
|
||||
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
|
||||
}
|
||||
|
||||
// renderTranscript flattens messages into a plain-text transcript for the
|
||||
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
||||
// Tool-result bodies and tool-call arguments are clamped (see the constants
|
||||
// above): the summary needs what was done and decided, not the raw payloads.
|
||||
func renderTranscript(messages []zeroruntime.Message) string {
|
||||
lines := make([]string, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
@@ -670,13 +837,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
|
||||
if len(message.ToolCalls) > 0 {
|
||||
calls := make([]string, 0, len(message.ToolCalls))
|
||||
for _, call := range message.ToolCalls {
|
||||
calls = append(calls, call.Name+"("+call.Arguments+")")
|
||||
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
||||
}
|
||||
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case zeroruntime.MessageRoleTool:
|
||||
lines = append(lines, "tool result: "+message.Content)
|
||||
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
||||
default:
|
||||
lines = append(lines, string(message.Role)+": "+message.Content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// compactableHistory returns a history whose middle is large enough to trip
|
||||
// the proactive threshold for a 1000-token window.
|
||||
func compactableHistory() []zeroruntime.Message {
|
||||
return []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
|
||||
}
|
||||
}
|
||||
|
||||
// Summarization must run on the dedicated (cheap) summarizer when one is
|
||||
// configured — the main provider sees no summarize traffic at all.
|
||||
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
|
||||
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
main := &mockProvider{}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
|
||||
|
||||
if len(cheap.requests) == 0 {
|
||||
t.Fatal("dedicated summarizer never received the summarize call")
|
||||
}
|
||||
if len(main.requests) != 0 {
|
||||
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
|
||||
t.Fatal("compaction did not shrink the history")
|
||||
}
|
||||
found := false
|
||||
for _, message := range compacted {
|
||||
if strings.Contains(message.Content, "CHEAP SUMMARY") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cheap summarizer's summary missing from the compacted history")
|
||||
}
|
||||
}
|
||||
|
||||
// A failing summarizer falls back to the main provider for the SAME slice and
|
||||
// stays broken for the rest of the run — no repeated failed cheap calls.
|
||||
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
|
||||
|
||||
summary, err := summarize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback must succeed via the main provider: %v", err)
|
||||
}
|
||||
if summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, want the main provider's", summary)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
|
||||
}
|
||||
if !state.summarizerBroken {
|
||||
t.Fatal("a failed summarizer must be marked broken for the run")
|
||||
}
|
||||
|
||||
// Second summarization: straight to main, no cheap retry.
|
||||
if _, err := summarize(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
|
||||
}
|
||||
if len(main.requests) != 2 {
|
||||
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A factory that errors (misconfigured model) is equivalent to no summarizer:
|
||||
// main provider used, factory not retried.
|
||||
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return nil, errors.New("unknown model")
|
||||
},
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
|
||||
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("after a factory failure the run must stay on the main provider")
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool payloads are clamped in the summarizer transcript: results to
|
||||
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
|
||||
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
|
||||
bigResult := strings.Repeat("r", 50_000)
|
||||
bigArgs := strings.Repeat("w", 20_000)
|
||||
transcript := renderTranscript([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "c1", Name: "write_file", Arguments: bigArgs},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
|
||||
})
|
||||
|
||||
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
|
||||
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
|
||||
}
|
||||
if !strings.Contains(transcript, "chars omitted for summarization") {
|
||||
t.Fatal("clamped transcript must carry the omission marker")
|
||||
}
|
||||
if !strings.Contains(transcript, "keep going") {
|
||||
t.Fatal("non-tool content must survive unclamped")
|
||||
}
|
||||
// Head AND tail of the clamped payloads survive.
|
||||
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
|
||||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
|
||||
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// bigToolHistory returns a history with n large tool results (~40k estimated
|
||||
// tokens each): the trailing protection window keeps the newest verbatim,
|
||||
// the older ones are prunable.
|
||||
func bigToolHistory(n int) []zeroruntime.Message {
|
||||
bigBody := strings.Repeat("x", 160_000)
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
|
||||
for i := 0; i < n; i++ {
|
||||
id := string(rune('a' + i))
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
|
||||
)
|
||||
}
|
||||
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
|
||||
}
|
||||
|
||||
func summaryProvider(text string) *mockProvider {
|
||||
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
}
|
||||
|
||||
// A context-limit error is first answered with the free prune stage; when the
|
||||
// pruned history fits the window the retry is free (no summarizer call) and
|
||||
// the one-shot reactive budget is untouched. The paid summarizer only runs
|
||||
// when a later error finds nothing left to prune.
|
||||
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// The protected trailing tool output (~40k tokens) must fit the window.
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("prunable history that fits must produce a free retry")
|
||||
}
|
||||
if len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
t.Fatal("a free prune retry must not consume the reactive budget")
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
|
||||
// Second context-limit error on the pruned history: nothing meaningfully
|
||||
// prunable remains, so the paid summarizer fires and consumes the budget.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("second recover must compact and retry")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("second recover must reach the summarizer")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("a paid recover must consume the reactive budget")
|
||||
}
|
||||
}
|
||||
|
||||
// When pruning reclaims something but the pruned history still exceeds the
|
||||
// window, recover falls through to the paid summarizer in the SAME call
|
||||
// instead of spending a retry that would fail the same way.
|
||||
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// A 1,000-token window: even the protected trailing output overflows it.
|
||||
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("insufficient prune must still end in a retry via summarization")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("the paid summarizer consumes the reactive budget")
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(messages) {
|
||||
t.Fatal("recover did not shrink the history")
|
||||
}
|
||||
}
|
||||
|
||||
// The free prune stage is not gated behind the one-shot budget: after a paid
|
||||
// reactive compaction, a later context-limit error still gets a free retry
|
||||
// when pruning makes the history fit.
|
||||
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
state.reactiveAttempted = true
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
// With nothing left to prune and the budget spent, the caller keeps its
|
||||
// original error — recover neither loops nor summarizes.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil || retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
|
||||
}
|
||||
}
|
||||
|
||||
// The free-retry gate compares against the window the provider enforces, not
|
||||
// the 70% proactive threshold: a history pruned to ~80% of the window still
|
||||
// takes the free retry.
|
||||
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
messages := bigToolHistory(5)
|
||||
pruned, _ := pruneStaleToolOutput(messages, 2)
|
||||
prunedTokens := estimateTokens(pruned)
|
||||
// Choose a window so the pruned size sits between 70% and 95% of it.
|
||||
window := prunedTokens * 100 / 80
|
||||
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
|
||||
if prunedTokens <= state.threshold {
|
||||
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
|
||||
}
|
||||
|
||||
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// After a mid-run model switch the summarizer is re-resolved against the new
|
||||
// main model: a run that started on the cheap model (no dedicated summarizer)
|
||||
// and escalated gets a cheap summarizer, and a previously broken one is
|
||||
// retried for the new model.
|
||||
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
|
||||
cheap := summaryProvider("CHEAP SUMMARY")
|
||||
main := summaryProvider("MAIN SUMMARY")
|
||||
var factoryModels []string
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Model: "cheap-model",
|
||||
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
|
||||
factoryModels = append(factoryModels, mainModelID)
|
||||
if mainModelID == "cheap-model" {
|
||||
return nil, nil // main already is the cheap model
|
||||
}
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("on the cheap model the main provider must summarize")
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
|
||||
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
state.switchModel("big-model", 400_000)
|
||||
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
|
||||
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
|
||||
}
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
|
||||
}
|
||||
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
|
||||
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
// A broken summarizer is retried after the next switch.
|
||||
state.summarizerBroken = true
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("a broken summarizer must fall back to main")
|
||||
}
|
||||
state.switchModel("other-model", 0)
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionUpdateWindow(t *testing.T) {
|
||||
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
|
||||
state.lowWaterMark = 50_000
|
||||
before := state.threshold
|
||||
|
||||
state.updateWindow(0) // unknown model: unchanged
|
||||
if state.threshold != before || state.lowWaterMark != 50_000 {
|
||||
t.Fatal("updateWindow(0) must be a no-op")
|
||||
}
|
||||
|
||||
state.updateWindow(10_000)
|
||||
if state.threshold >= before {
|
||||
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
|
||||
}
|
||||
if state.lowWaterMark != 0 {
|
||||
t.Fatal("low-water mark must reset on a window change")
|
||||
}
|
||||
if !state.enabled {
|
||||
t.Fatal("a positive window must keep compaction enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: after escalate_model switches providers, the loop re-derives the
|
||||
// context window via ContextWindowFor, visible in the OnContext report.
|
||||
func TestRunEscalationRederivesContextWindow(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "big-model"})
|
||||
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
|
||||
var windows []int
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
ContextWindow: 100_000,
|
||||
Model: "small-model",
|
||||
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
if modelID == "big-model" {
|
||||
return 400_000
|
||||
}
|
||||
return 0
|
||||
},
|
||||
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(windows) < 2 {
|
||||
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
|
||||
}
|
||||
if windows[0] != 100_000 {
|
||||
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
|
||||
}
|
||||
if windows[len(windows)-1] != 400_000 {
|
||||
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// nonComparableProvider has a slice field, so comparing two Provider interface
|
||||
// values holding it would panic; the summarizer path must never compare them.
|
||||
type nonComparableProvider struct {
|
||||
inner *mockProvider
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
return p.inner.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
|
||||
}, nil)
|
||||
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
|
||||
}
|
||||
// And with no dedicated summarizer at all the closure still works.
|
||||
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -126,8 +126,11 @@ func capCompactionBrief(brief string) (string, bool) {
|
||||
|
||||
func compactionToolCallLine(call zeroruntime.ToolCall) string {
|
||||
detail := ""
|
||||
if call.Freeform && call.Name == "apply_patch" {
|
||||
detail = strings.Join(patchPaths(call.Arguments), ", ")
|
||||
}
|
||||
var arguments map[string]any
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
if detail == "" && json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
|
||||
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
detail = value
|
||||
|
||||
@@ -73,6 +73,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
|
||||
{ID: "raw-patch", Name: "apply_patch", Freeform: true, Arguments: "*** Begin Patch\n*** Update File: internal/api.go\n*** Add File: internal/config.go\n*** End Patch\n"},
|
||||
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
|
||||
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
|
||||
}},
|
||||
@@ -80,7 +81,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
|
||||
}
|
||||
|
||||
content := projectCompactionInput(messages).messages[0].Content
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "internal/api.go", "internal/config.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
|
||||
@@ -275,6 +275,27 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
|
||||
if estimateToolDefTokens(one) <= 0 {
|
||||
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
|
||||
}
|
||||
typed := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
typed[0].Type = zeroruntime.ToolDefinitionFreeform
|
||||
if estimateToolDefTokens(typed) <= estimateToolDefTokens(one) {
|
||||
t.Fatal("a non-empty tool type must increase the estimated request cost")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: WORD"}},
|
||||
} {
|
||||
t.Run("format "+test.name, func(t *testing.T) {
|
||||
formatted := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
formatted[0].Format = &test.format
|
||||
if estimateToolDefTokens(formatted) <= estimateToolDefTokens(one) {
|
||||
t.Fatalf("a non-empty format %s must increase the estimated request cost", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
|
||||
Name: "write_file",
|
||||
Description: "Write contents to a file in the workspace.",
|
||||
|
||||
@@ -102,6 +102,10 @@ func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.To
|
||||
for index, definition := range toolDefs {
|
||||
copied[index] = definition
|
||||
copied[index].Parameters = copySchemaMap(definition.Parameters)
|
||||
if definition.Format != nil {
|
||||
format := *definition.Format
|
||||
copied[index].Format = &format
|
||||
}
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
}
|
||||
toolDefs := []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file", Description: "Read a file",
|
||||
Format: &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: "start: PATH"},
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"path"},
|
||||
@@ -53,10 +54,12 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
toolDefs[0].Parameters["required"].([]string)[0] = "other"
|
||||
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
|
||||
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
|
||||
toolDefs[0].Format.Definition = "changed"
|
||||
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
|
||||
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" ||
|
||||
plan.Request.Tools[0].Format == nil || plan.Request.Tools[0].Format.Definition != "start: PATH" ||
|
||||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
|
||||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
|
||||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type customApplyPatchTool struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
type fixedPathScope struct {
|
||||
roots []string
|
||||
}
|
||||
|
||||
func (scope fixedPathScope) Roots() []string {
|
||||
return append([]string(nil), scope.roots...)
|
||||
}
|
||||
|
||||
func newFixedPathScope(t *testing.T, roots ...string) fixedPathScope {
|
||||
t.Helper()
|
||||
resolved := make([]string, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
absolute, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", root, err)
|
||||
}
|
||||
if physical, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
absolute = physical
|
||||
}
|
||||
resolved = append(resolved, absolute)
|
||||
}
|
||||
return fixedPathScope{roots: resolved}
|
||||
}
|
||||
|
||||
func (*customApplyPatchTool) Name() string { return "apply_patch" }
|
||||
func (*customApplyPatchTool) Description() string { return "custom JSON tool" }
|
||||
func (*customApplyPatchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"value": {Type: "string"}}}
|
||||
}
|
||||
func (*customApplyPatchTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow, SideEffect: tools.SideEffectRead}
|
||||
}
|
||||
func (tool *customApplyPatchTool) Run(context.Context, map[string]any) tools.Result {
|
||||
tool.calls++
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchUsesExistingToolExecutionPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchSupportsGrantedExtraRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(root, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(extra, "hello.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform extra-root apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("read extra-root patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsMixedWorkspaceAndExtraRoots(t *testing.T) {
|
||||
for _, extraHasMatchingPath := range []bool{false, true} {
|
||||
name := "extra target missing"
|
||||
if extraHasMatchingPath {
|
||||
name = "extra target exists"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspaceTarget := filepath.Join(workspace, "src", "a.go")
|
||||
if err := os.WriteFile(workspaceTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extraTarget := filepath.Join(extra, "src", "a.go")
|
||||
if extraHasMatchingPath {
|
||||
if err := os.MkdirAll(filepath.Dir(extraTarget), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(extraTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
extraCreated := filepath.Join(extra, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/a.go",
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + extraCreated,
|
||||
"+extra",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "mixes workspace-relative paths with an extra write root") {
|
||||
t.Fatalf("mixed-root patch result = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(workspaceTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("workspace target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
if extraHasMatchingPath {
|
||||
if content, err := os.ReadFile(extraTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("extra-root target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(extraCreated); !os.IsNotExist(err) {
|
||||
t.Fatalf("mixed-root patch created extra file: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchAcceptsOneSemanticRoot(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
useExtraRoot bool
|
||||
}{
|
||||
{name: "workspace"},
|
||||
{name: "extra root", useExtraRoot: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := workspace
|
||||
if test.useExtraRoot {
|
||||
root = extra
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(root, "src", "a.go")
|
||||
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatePath := target
|
||||
if !test.useExtraRoot {
|
||||
updatePath = filepath.Join("src", "a.go")
|
||||
}
|
||||
created := filepath.Join(root, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: " + updatePath,
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + created,
|
||||
"+created",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("single-root patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(target); err != nil || string(content) != "new\n" {
|
||||
t.Fatalf("updated content=%q err=%v", content, err)
|
||||
}
|
||||
if content, err := os.ReadFile(created); err != nil || string(content) != "created\n" {
|
||||
t.Fatalf("created content=%q err=%v", content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsAbsolutePathOutsideGrantedRoots(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
granted := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
scope := newFixedPathScope(t, root, granted)
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(outside, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("ungranted absolute patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("ungranted absolute patch created target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedFreeformPatchRejectsIntermediateSymlinkSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating directory symlinks requires privileges on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
intermediate := filepath.Join(extra, "nested")
|
||||
if err := os.Mkdir(intermediate, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
scope := newFixedPathScope(t, root, extra)
|
||||
tool := tools.NewScopedApplyPatchTool(root, scope)
|
||||
target := filepath.Join(intermediate, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
args, err := tools.PrepareFreeformApplyPatchArguments(tool, patch)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareFreeformApplyPatchArguments: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(intermediate); err != nil {
|
||||
t.Fatalf("Remove intermediate: %v", err)
|
||||
}
|
||||
if err := os.Symlink(outside, intermediate); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("symlink-swapped patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "blocked.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("symlink-swapped patch wrote outside root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFreeformToolFailsClosed(t *testing.T) {
|
||||
result, err := executeToolCall(context.Background(), tools.NewRegistry(), zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "unknown", Arguments: "raw", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("unknown freeform status = %s, want error", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomApplyPatchCannotUseFreeformContract(t *testing.T) {
|
||||
custom := &customApplyPatchTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(custom)
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: "raw patch", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "Unsupported freeform tool call") {
|
||||
t.Fatalf("custom freeform apply_patch result = %#v, want unsupported error", result)
|
||||
}
|
||||
if custom.calls != 0 {
|
||||
t.Fatalf("custom apply_patch executed %d times", custom.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedFreeformApplyPatchDoesNotWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: denied.txt\n+must not exist\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeAsk, Options{
|
||||
Cwd: root,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "test denial"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("denied freeform status = %s, want error", result.Status)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "denied.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("denied freeform patch changed the workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySafeToolCallsPreservesRawFreeformArguments(t *testing.T) {
|
||||
raw := "*** Begin Patch\n*** Update File: internal/main.go\n@@\n-old\n+new\n*** End Patch\n"
|
||||
safe := historySafeToolCalls([]ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "apply_patch", Arguments: raw, Freeform: true,
|
||||
}})
|
||||
if len(safe) != 1 || safe[0].Arguments != raw || safe[0].ProviderCallID != "provider-1" || !safe[0].Freeform {
|
||||
t.Fatalf("freeform history changed raw call: %#v", safe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortedToolResultPreservesProviderCallID(t *testing.T) {
|
||||
messages := appendAbortedToolResults(nil, []ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "read_file",
|
||||
}})
|
||||
if len(messages) != 1 || messages[0].ToolCallID != "call-1" || messages[0].ToolCallProviderID != "provider-1" || !messages[0].IsError {
|
||||
t.Fatalf("aborted tool result lost provider identity: %#v", messages)
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ const (
|
||||
// knows before spending more tool turns.
|
||||
toolOnlyProgressReminderAt = 6
|
||||
|
||||
// planReminderTurn is the turn (1-based) by the end of which a multi-step
|
||||
// task should have called update_plan; if it hasn't, a one-time reminder is
|
||||
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
|
||||
// without a spurious planning nag.
|
||||
planReminderTurn = 3
|
||||
// planReminderTurn and planReminderToolThreshold keep bounded implementation
|
||||
// work plan-free while still nudging a genuinely sustained run that has not
|
||||
// established any structured progress. Both thresholds must be met.
|
||||
planReminderTurn = 7
|
||||
planReminderToolThreshold = 7
|
||||
|
||||
// planToolName is the planning tool the loop watches for by name.
|
||||
planToolName = "update_plan"
|
||||
@@ -591,13 +591,12 @@ func (state *guardState) planReminder(turn int) string {
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
|
||||
// called update_plan if it's doing a multi-step task (>=1 other tool call).
|
||||
// One-shot for the whole run.
|
||||
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
|
||||
// sustained work should have called update_plan. One-shot for the whole run.
|
||||
if !state.notCalledReminderSent &&
|
||||
!state.planEverCalled &&
|
||||
turn >= planReminderTurn &&
|
||||
state.totalToolCalls >= 1 {
|
||||
state.totalToolCalls >= planReminderToolThreshold {
|
||||
state.notCalledReminderSent = true
|
||||
return planNotCalledReminder()
|
||||
}
|
||||
|
||||
@@ -257,19 +257,16 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
if planReminderToolThreshold != expectedThreshold {
|
||||
t.Fatalf("plan reminder tool threshold = %d, want %d", planReminderToolThreshold, expectedThreshold)
|
||||
}
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // turn 1: other tool call
|
||||
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`), // turn 2: still no update_plan
|
||||
toolTurn("call-3", "read_file", `{"path":"notes.txt"}`), // turn 3: reminder fires here
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
@@ -287,6 +284,44 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForBoundedToolSequence(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold-1), textTurn("done"))}
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry, MaxTurns: 12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if count := countUserMessagesContaining(result.Messages, planNotCalledReminderMarker); count != 0 {
|
||||
t.Fatalf("bounded tool sequence must not draw a plan reminder, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanReminderWaitsForTurnThresholdAfterEnoughToolCalls(t *testing.T) {
|
||||
state := guardState{totalToolCalls: 7}
|
||||
if got := state.planReminder(6); got != "" {
|
||||
t.Fatalf("planReminder(6) = %q, want no reminder before turn threshold", got)
|
||||
}
|
||||
if state.notCalledReminderSent {
|
||||
t.Fatal("early turn marked the not-called reminder as sent")
|
||||
}
|
||||
}
|
||||
|
||||
func repeatedReadTurns(count int) [][]zeroruntime.StreamEvent {
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, count)
|
||||
for range count {
|
||||
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
|
||||
+63
-17
@@ -141,6 +141,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterInputTokens, int64(usage.InputTokens))
|
||||
options.Trace.Counter(trace.CounterCachedInputTokens, int64(usage.CachedInputTokens))
|
||||
options.Trace.Counter(trace.CounterCacheWriteTokens, int64(usage.CacheWriteTokens))
|
||||
options.Trace.Counter(trace.CounterOutputTokens, int64(usage.OutputTokens))
|
||||
}
|
||||
}
|
||||
@@ -697,11 +698,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
@@ -844,11 +846,21 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
// here, so a switch to a model with a different window keeps compacting
|
||||
// against the original budget. Fixing it needs the switcher to also
|
||||
// report the new window — out of scope for this change.
|
||||
// Re-derive the context window for the new model so compaction and
|
||||
// the OnContext budget report track the model actually in force —
|
||||
// without this a switch to a smaller-window model can overflow the
|
||||
// target, and a switch to a larger one over-compacts. An unknown
|
||||
// model (<= 0) keeps the original window. The compactor also
|
||||
// re-resolves its dedicated summarizer against the new model.
|
||||
window := 0
|
||||
if options.ContextWindowFor != nil {
|
||||
window = options.ContextWindowFor(turnRequestedModel)
|
||||
}
|
||||
if window > 0 {
|
||||
options.ContextWindow = window
|
||||
planner.config.contextWindow = window
|
||||
}
|
||||
compactor.switchModel(turnRequestedModel, window)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1035,6 +1047,9 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
|
||||
safe := make([]ToolCall, len(calls))
|
||||
for i, call := range calls {
|
||||
safe[i] = call
|
||||
if call.Freeform {
|
||||
continue
|
||||
}
|
||||
args := strings.TrimSpace(call.Arguments)
|
||||
switch {
|
||||
case args == "":
|
||||
@@ -1118,8 +1133,28 @@ func decodeToolArguments(arguments string, v any) error {
|
||||
}
|
||||
|
||||
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if call.Freeform {
|
||||
if !toolFound || !tools.IsBuiltInApplyPatch(tool) {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Unsupported freeform tool call for " + call.Name + ".",
|
||||
}, nil
|
||||
}
|
||||
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid freeform apply_patch input: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
args = prepared
|
||||
} else if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
@@ -1147,7 +1182,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
|
||||
modeName := string(permissionMode)
|
||||
return ToolResult{
|
||||
@@ -1493,6 +1527,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
@@ -1799,6 +1834,10 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
|
||||
}
|
||||
|
||||
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
|
||||
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
|
||||
// explicitly cross the same transcript/redaction boundary before its result
|
||||
// can be forwarded through ACP.
|
||||
result = tools.ScrubResultSecrets(result)
|
||||
output, outputRedacted := scrubInterceptedOutput(result.Output)
|
||||
display := result.Display
|
||||
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
|
||||
@@ -1826,6 +1865,7 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
@@ -2119,6 +2159,7 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
@@ -3210,11 +3251,15 @@ func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition,
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
definition := zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
if tools.IsBuiltInApplyPatch(tool) {
|
||||
definition.Type = zeroruntime.ToolDefinitionFreeform
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
@@ -3369,10 +3414,11 @@ func loadedToolsFromResult(meta map[string]string) []string {
|
||||
func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Message {
|
||||
for _, call := range remaining {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
IsError: true,
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
|
||||
@@ -27,6 +27,23 @@ type mockProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{
|
||||
Status: tools.StatusError,
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "secret.txt"),
|
||||
OldExists: true,
|
||||
NewExists: true,
|
||||
OldText: "token=" + secret,
|
||||
NewText: "safe",
|
||||
}},
|
||||
})
|
||||
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || !result.Redacted {
|
||||
t.Fatalf("pre-permission FileDiff = %#v, redacted = %t", result.FileDiffs, result.Redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
|
||||
call := ToolCall{Name: tools.ExecCommandToolName}
|
||||
@@ -4096,7 +4113,7 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
|
||||
// caller's callback AND stamps token counters, and the run completes.
|
||||
func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 10, CompletionTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
@@ -4135,6 +4152,9 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
|
||||
t.Fatalf("cached input token counter = %d, want 20", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterCacheWriteTokens); got != 10 {
|
||||
t.Fatalf("cache-write token counter = %d, want 10", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
|
||||
t.Fatalf("output token counter = %d, want 40", got)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,19 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestRuntimeToolDefinitionMarksOnlyBuiltInApplyPatchFreeform(t *testing.T) {
|
||||
builtIn := runtimeToolDefinition(tools.NewScopedApplyPatchTool(t.TempDir(), nil))
|
||||
if builtIn.Type != zeroruntime.ToolDefinitionFreeform {
|
||||
t.Fatalf("built-in apply_patch type = %q, want freeform", builtIn.Type)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
custom := runtimeToolDefinition(countingSchemaTool{name: "apply_patch", calls: &calls})
|
||||
if custom.Type != "" {
|
||||
t.Fatalf("custom apply_patch type = %q, want legacy function shape", custom.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||
// output is identical to the uncached path.
|
||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||
@@ -33,7 +35,24 @@ const (
|
||||
// The ratchet did its job: it caught the creep and forced a decision instead
|
||||
// of a drift. That is the point of it, so keep raising it deliberately rather
|
||||
// than reflexively.
|
||||
//
|
||||
// THIS COUNTS THE SCHEMAS ONLY, NOT THE HOST'S SHELL GUIDANCE. Until
|
||||
// 2026-09-07 it counted both, and that made it a different measurement on
|
||||
// every machine: exec_command appends shell guidance whose size depends on
|
||||
// which shell was detected, so the same tree measured 3498 on Linux, ~3600 on
|
||||
// a Windows host with pwsh 7, and 3653 on Windows PowerShell 5.1, which is
|
||||
// stock Windows. The 5.1 case has been over this ceiling since view_image
|
||||
// landed, so `go test ./...` failed for anyone on a stock Windows box while
|
||||
// CI stayed green, because the runners have pwsh 7 and take the cheaper
|
||||
// branch. A budget that passes or fails on which PowerShell happens to be
|
||||
// installed is not ratcheting anything.
|
||||
maxEagerToolSchemaTokens = 3650
|
||||
// The host-specific half, ratcheted on its own so it cannot creep either.
|
||||
// Measured 2026-09-07: Windows PowerShell 5.1 is the most expensive host at
|
||||
// 155 tokens (722 chars), pwsh 7 is 110, cmd.exe is 87, and every POSIX host
|
||||
// is 0 because no guidance is appended there. Set above the worst case, not
|
||||
// above whatever this machine happens to be.
|
||||
maxExecCommandShellGuidanceTokens = 200
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
@@ -56,8 +75,35 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
total := estimateToolDefTokens(exposed)
|
||||
// exec_command's description carries this host's shell guidance, and that
|
||||
// guidance is the one part of the eager set that is not the same everywhere.
|
||||
// Charged separately below rather than folded in here, or this ceiling would
|
||||
// mean a different thing on every machine. See the constants above.
|
||||
//
|
||||
// Removed from the description BEFORE estimating rather than subtracted from
|
||||
// the total afterwards. ApproxTextTokens is an integer division, so
|
||||
// floor((schema+guidance)/4) - floor(guidance/4) comes out one token high
|
||||
// whenever the two remainders add past four, and which side of that line a
|
||||
// host lands on depends on the length of its guidance: exactly the
|
||||
// host-dependence this number is supposed to be rid of.
|
||||
guidance := tools.HostExecCommandShellGuidance()
|
||||
schemas := make([]zeroruntime.ToolDefinition, len(exposed))
|
||||
copy(schemas, exposed)
|
||||
stripped := 0
|
||||
for index := range schemas {
|
||||
without := strings.TrimSuffix(schemas[index].Description, guidance)
|
||||
if without != schemas[index].Description {
|
||||
stripped++
|
||||
}
|
||||
schemas[index].Description = without
|
||||
}
|
||||
if guidance != "" && stripped != 1 {
|
||||
t.Fatalf("SETUP INVALID: this host's shell guidance came off %d descriptions, want exactly exec_command's; internal/tools pins it as that description's tail", stripped)
|
||||
}
|
||||
got := estimateToolDefTokens(schemas)
|
||||
guidanceTokens := ApproxTextTokens(guidance)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools (%d total, %d host shell guidance)", got, len(exposed), total, guidanceTokens)
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
|
||||
// tool deferred leaves it exposed here and changes nothing. Deferral is
|
||||
@@ -66,6 +112,9 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
// deliberate raise.
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
if guidanceTokens > maxExecCommandShellGuidanceTokens {
|
||||
t.Fatalf("this host's exec_command shell guidance is %d tokens, over the %d ceiling — trim the guidance in internal/tools/shell_runtime.go or raise the ceiling deliberately", guidanceTokens, maxExecCommandShellGuidanceTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAdvertisesBothEditTools(t *testing.T) {
|
||||
|
||||
@@ -58,12 +58,18 @@ func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSu
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
writeLengthPrefixed(&toolsSB, string(def.Type))
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
if def.Format != nil {
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Type)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Definition)
|
||||
}
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,38 @@ func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintIncludesToolTypeAndFormat(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
base := zeroruntime.ToolDefinition{Name: "apply_patch", Description: "Patch files"}
|
||||
baseFingerprint := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{base})
|
||||
|
||||
typed := base
|
||||
typed.Type = zeroruntime.ToolDefinitionFreeform
|
||||
if got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{typed}); got.ToolsHash == baseFingerprint.ToolsHash {
|
||||
t.Fatal("ToolsHash must change when tool Type changes")
|
||||
}
|
||||
|
||||
formatCases := []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: PATCH"}},
|
||||
{name: "empty", format: zeroruntime.ToolDefinitionFormat{}},
|
||||
}
|
||||
for _, test := range formatCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withFormat := base
|
||||
withFormat.Format = &test.format
|
||||
got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{withFormat})
|
||||
if got.SchemaHash == baseFingerprint.SchemaHash {
|
||||
t.Fatalf("SchemaHash must change for %s format input", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||
// model addendum change) must move the base-instructions hash, which moves
|
||||
|
||||
@@ -30,13 +30,10 @@ work.
|
||||
read-before-edit discipline: inspect the target file and nearby callers,
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** Use update_plan for implementation or investigation that has at
|
||||
least three meaningful dependent steps, then keep it current at milestones.
|
||||
Mark completed work and the next in-progress step together when practical;
|
||||
do not spend separate turns updating it after every file or command. Keep at
|
||||
most one item in_progress. Skip plans for simple lookups, explanations, code
|
||||
navigation, and other short tasks. Never create a plan after the work is
|
||||
already complete merely to describe what you did.
|
||||
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
|
||||
work. Skip it for bounded changes in one component. Update only when the
|
||||
overall phase changes, keep at most one item in_progress, and never create a
|
||||
plan after the work is complete.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
@@ -55,9 +52,9 @@ work.
|
||||
edit_file, apply_patch, write_file - over shelling out to
|
||||
cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Prefer read_minified_file when initially exploring source code; it preserves
|
||||
code while removing comments and redundant whitespace. Use read_file when
|
||||
exact text, comments, or line numbers are needed.
|
||||
- Use read_minified_file only to explore large or unfamiliar source. Use
|
||||
read_file for small or likely edit targets and exact text, comments, or line
|
||||
numbers. Do not read both unless a new need for exact content appears.
|
||||
- Keep edits focused and reviewable. A single patch may update several related
|
||||
files when they form one coherent change; do not hide unrelated edits in a
|
||||
bulk shell or script rewrite.
|
||||
@@ -76,21 +73,24 @@ work.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
over-built; if a 200-line solution could be 50, rewrite it.
|
||||
over-built; if a 200-line solution could be 50, rewrite it. Once the required
|
||||
behavior passes validation, stop instead of adding optional cleanup.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
## Testing gate (mandatory)
|
||||
|
||||
- After any change to code, verify after edits by running the project's
|
||||
validators before you summarize or commit: tests, type-checks, linters, and/or
|
||||
the build, as appropriate. Scope them to the change while iterating; reserve
|
||||
full-suite runs for milestones.
|
||||
- If you are unsure which validators apply, search the repo (Makefile, package
|
||||
manifests, CI config) to find them.
|
||||
- After code changes, run the project's documented appropriate validators.
|
||||
Scope iteration checks, combine compatible commands when diagnostics stay
|
||||
useful, and run the final set once after the last edit. Rerun only after a
|
||||
change or failure.
|
||||
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
|
||||
do not invent a parallel flow.
|
||||
- Never claim a task is done, and never commit, while validators are failing. If
|
||||
they fail, fix the cause and rerun; do not paper over it. If you could not run
|
||||
a validator, say so explicitly rather than implying success.
|
||||
- A successful native patch confirms the edit. Reread only when the next change
|
||||
needs exact content or the result was ambiguous.
|
||||
|
||||
## Tool use
|
||||
|
||||
@@ -148,6 +148,7 @@ work.
|
||||
- Default to concise, skimmable output. Lead with the answer or the result.
|
||||
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
||||
code blocks for code, and `inline code` for file paths, commands, symbols, and
|
||||
short snippets. Reference code as `file:line` so it is clickable.
|
||||
short snippets. Reference code with file paths and include line numbers when
|
||||
already known; do not look them up solely for the final summary.
|
||||
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
|
||||
say so; when something is done and verified, state it plainly without hedging.
|
||||
@@ -60,12 +60,11 @@ const openAIPromptAddendum = `<model_guidance>
|
||||
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||
commands, and symbols.
|
||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
||||
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python
|
||||
for file work. read_file, edit_file and write_file take one file per call;
|
||||
a single apply_patch may span several files when they form one coherent
|
||||
change. Do not batch file writes into a script. Independent tool calls
|
||||
(several reads, several edit_file calls to different files) belong in the
|
||||
same turn.
|
||||
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
|
||||
file work. Use them directly; never run apply_patch through exec_command.
|
||||
read_file, edit_file and write_file take one file per call, while one coherent
|
||||
apply_patch may span several files. Keep independent native calls in the same
|
||||
turn, and do not batch file writes into a script.
|
||||
- Persist until the task is fully handled this turn: gather context, implement,
|
||||
run the validators, and report — do not stop at a partial result.
|
||||
</model_guidance>`
|
||||
|
||||
@@ -29,6 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
if got := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{Model: "gpt-5"}))), " "); !strings.Contains(got, "never run apply_patch through exec_command") {
|
||||
t.Fatalf("OpenAI prompt must route edits through the native patch tool:\n%s", got)
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
|
||||
@@ -29,7 +29,10 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
|
||||
if strings.Contains(prompt, "clickable file paths") {
|
||||
t.Fatalf("system prompt must not claim inline-code file paths are clickable: %s", prompt)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"read-before-edit",
|
||||
@@ -44,6 +47,13 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"do not recognize",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
"skip it for bounded changes in one component",
|
||||
"do not read both unless a new need for exact content appears",
|
||||
"combine compatible commands when diagnostics stay useful",
|
||||
"do not invent a parallel flow",
|
||||
"a successful native patch confirms the edit",
|
||||
"once the required behavior passes validation, stop",
|
||||
"do not look them up solely for the final summary",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
|
||||
+21
-4
@@ -84,6 +84,7 @@ type ToolResult struct {
|
||||
Images []zeroruntime.ImageBlock
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
FileDiffs []tools.FileDiff
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
@@ -348,10 +349,26 @@ type Options struct {
|
||||
// CompactionPreserveLast is how many trailing messages compaction keeps
|
||||
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
||||
CompactionPreserveLast int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// Summarizer, when set, lazily builds the provider used for compaction
|
||||
// summarization calls — typically a cheap/fast model, since summaries at
|
||||
// main-model prices are the single most expensive recurring event in a
|
||||
// long run. It receives the main model currently in force and may return
|
||||
// (nil, nil) when no dedicated summarizer applies to it (the main model is
|
||||
// already the cheap one). Built on the first paid compaction and again
|
||||
// after a mid-run model switch. Any failure (build or call) falls back to
|
||||
// the run's main provider until the next switch, so a misconfigured
|
||||
// summarizer can never break compaction. nil keeps today's behavior.
|
||||
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
// ContextWindowFor, when set, resolves a model ID to its context window so
|
||||
// the compactor can re-derive its threshold after a mid-run model switch
|
||||
// (escalate_model). Without it a switch keeps compacting against the
|
||||
// original model's window — overflowing a smaller target or over-compacting
|
||||
// a larger one. Return <= 0 when the model is unknown (window unchanged).
|
||||
ContextWindowFor func(modelID string) int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// FileTracker records per-session file read/write versions so the write tools
|
||||
// can detect a file changed on disk outside Zero since it was last read. nil
|
||||
// disables the check. Created once per session and threaded into every tool run.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
@@ -45,6 +47,16 @@ func terminateProcess(pid int) error {
|
||||
// rediscovery after an owned leader exits. Ordinary commands fall back to the
|
||||
// safe PID/tree path rather than assuming their PID is also a process-group ID.
|
||||
//
|
||||
// The bool result is whether the leader had already exited before termination
|
||||
// was attempted — the POSIX analogue of Windows GetExitCodeProcess returning a
|
||||
// value other than STILL_ACTIVE. A waitable zombie is already-exited: Wait can
|
||||
// collect it, but kill(0) still succeeds, and when ps is unavailable
|
||||
// signalTargetRunning conservatively treats that as "still running".
|
||||
// TerminateCommand uses the flag to discard the resulting spurious
|
||||
// SIGKILL-timeout once the reap has independently succeeded. The flag is about
|
||||
// the leader only; group/tree termination is still invoked so live descendants
|
||||
// are signalled.
|
||||
//
|
||||
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
|
||||
// own convention. A command made its own session leader via Setsid (as opposed
|
||||
// to Setpgid) is also its own process-group leader in practice, but takes the
|
||||
@@ -52,8 +64,20 @@ func terminateProcess(pid int) error {
|
||||
// because TerminateCommand has exactly one caller in this codebase; worth
|
||||
// covering explicitly if Setsid-configured commands start using this path too.
|
||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
alreadyExited := leaderWaitableExited(cmd.Process.Pid)
|
||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
||||
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
|
||||
return alreadyExited, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
return false, terminateProcess(cmd.Process.Pid)
|
||||
return alreadyExited, terminateProcess(cmd.Process.Pid)
|
||||
}
|
||||
|
||||
// terminationTargetGoneAfterReap independently checks the exact signal target
|
||||
// used for an owned command. In particular, reaping a zombie group leader does
|
||||
// not make this return true while any descendant still occupies that group.
|
||||
func terminationTargetGoneAfterReap(cmd *exec.Cmd) bool {
|
||||
target := cmd.Process.Pid
|
||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
||||
target = -target
|
||||
}
|
||||
return errors.Is(syscall.Kill(target, syscall.Signal(0)), syscall.ESRCH)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -220,6 +221,109 @@ func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandZombieLeaderWithoutPS(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skip("waitable-exited leader probe is implemented on linux and darwin")
|
||||
}
|
||||
|
||||
grace, poll := terminationGracePeriod, terminationPollInterval
|
||||
terminationGracePeriod, terminationPollInterval = 150*time.Millisecond, 10*time.Millisecond
|
||||
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
|
||||
|
||||
// Issue #862: a zombie group leader with ps unresolvable used to burn both
|
||||
// grace periods (kill(0) succeeds against a zombie) and return
|
||||
// "did not exit after SIGKILL" even though cmd.Wait reaped exit status 0.
|
||||
// The already-exited probe must answer without ps so TerminateCommand can
|
||||
// discard that spurious timeout after a successful reap.
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
t.Cleanup(func() {
|
||||
if cmd.ProcessState == nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !leaderWaitableExited(pid) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("leader %d did not become waitable-exited within 5s", pid)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", "")
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v, want nil (zombie leader with ps unavailable must not report SIGKILL timeout)", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("zombie leader was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandPreservesGroupFailureWithExitedLeaderAndLiveChild(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skip("waitable-exited leader probe is implemented on linux and darwin")
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read forked child pid: %v", err)
|
||||
}
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse forked child pid %q: %v", line, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
if cmd.ProcessState == nil {
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !leaderWaitableExited(cmd.Process.Pid) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("leader %d did not become waitable-exited within 5s", cmd.Process.Pid)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
treeErr := errors.New("process group could not be terminated")
|
||||
originalTerminateOwnedProcess := terminateOwnedProcessForTest
|
||||
terminateOwnedProcessForTest = func(got *exec.Cmd) (bool, error) {
|
||||
if got != cmd {
|
||||
t.Fatalf("terminate called with %p, want %p", got, cmd)
|
||||
}
|
||||
return true, treeErr
|
||||
}
|
||||
t.Cleanup(func() { terminateOwnedProcessForTest = originalTerminateOwnedProcess })
|
||||
|
||||
err = TerminateCommand(cmd)
|
||||
if !errors.Is(err, treeErr) {
|
||||
t.Fatalf("TerminateCommand error = %v, want live-group termination failure", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("exited leader was not reaped")
|
||||
}
|
||||
if processStopped(childPID) {
|
||||
t.Fatalf("child %d unexpectedly stopped; failure boundary needs a live group member", childPID)
|
||||
}
|
||||
}
|
||||
|
||||
func processStopped(pid int) bool {
|
||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build darwin
|
||||
|
||||
package background
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// szomb is sys/proc.h SZOMB: the process has exited and is waiting to be reaped.
|
||||
const szomb int8 = 5
|
||||
|
||||
// leaderWaitableExited reports whether pid has already exited and is waiting
|
||||
// to be reaped. This is the POSIX analogue of Windows GetExitCodeProcess
|
||||
// returning a value other than STILL_ACTIVE: the leader is a zombie, waitable
|
||||
// as exited, without collecting it. A true result does not mean the process
|
||||
// group is empty — descendants may still be running.
|
||||
//
|
||||
// Darwin has no /proc and x/sys/unix does not wrap waitid here, so the probe
|
||||
// is kern.proc.pid via sysctl. It does not use ps, which is the #862 failure
|
||||
// mode when PATH cannot resolve ps.
|
||||
func leaderWaitableExited(pid int) bool {
|
||||
if pid <= 1 {
|
||||
return false
|
||||
}
|
||||
kinfo, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return kinfo.Proc.P_stat == szomb
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build linux
|
||||
|
||||
package background
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// POSIX waitid si_code values for an already-exited child (bits/waitflags.h).
|
||||
const (
|
||||
cldExited int32 = 1
|
||||
cldKilled int32 = 2
|
||||
cldDumped int32 = 3
|
||||
)
|
||||
|
||||
// leaderWaitableExited reports whether pid has already exited and is waiting
|
||||
// to be reaped. This is the POSIX analogue of Windows GetExitCodeProcess
|
||||
// returning a value other than STILL_ACTIVE: the leader is waitable as exited
|
||||
// (typically a zombie) without collecting it. A true result does not mean the
|
||||
// process group is empty — descendants may still be running.
|
||||
//
|
||||
// Detection does not use ps. waitid(WNOHANG|WNOWAIT) asks the kernel whether
|
||||
// an exit status is already available. /proc/<pid>/stat state Z is the
|
||||
// fallback when waitid cannot answer (for example if procfs is the only
|
||||
// usable source). Either path is independent of PATH, which is the #862
|
||||
// failure mode: empty PATH makes ps unresolvable and kill(0) still succeeds
|
||||
// against a zombie.
|
||||
func leaderWaitableExited(pid int) bool {
|
||||
if pid <= 1 {
|
||||
return false
|
||||
}
|
||||
if waitidAlreadyExited(pid) {
|
||||
return true
|
||||
}
|
||||
return procIsZombie(pid)
|
||||
}
|
||||
|
||||
func waitidAlreadyExited(pid int) bool {
|
||||
var info unix.Siginfo
|
||||
err := unix.Waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOHANG|unix.WNOWAIT, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch info.Code {
|
||||
case cldExited, cldKilled, cldDumped:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func procIsZombie(pid int) bool {
|
||||
data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// /proc/<pid>/stat: pid (comm) state ... — comm may contain spaces or
|
||||
// parentheses, so the state is the first field after the last ')'.
|
||||
i := bytes.LastIndexByte(data, ')')
|
||||
if i < 0 || i+1 >= len(data) {
|
||||
return false
|
||||
}
|
||||
rest := bytes.TrimSpace(data[i+1:])
|
||||
return len(rest) > 0 && rest[0] == 'Z'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
|
||||
package background
|
||||
|
||||
// leaderWaitableExited cannot positively identify a waitable-exited leader on
|
||||
// this platform without ps. Returning false keeps TerminateCommand's existing
|
||||
// conservative behaviour: a termination error is not discarded after reap.
|
||||
func leaderWaitableExited(pid int) bool {
|
||||
return false
|
||||
}
|
||||
@@ -50,3 +50,8 @@ func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
}
|
||||
return alreadyExited, terminateErr
|
||||
}
|
||||
|
||||
// Windows has no persistent process-group identity to query after a dead root
|
||||
// is reaped. Preserve TerminateCommand's existing, documented dead-root
|
||||
// behavior; the stronger independent target check is available on POSIX.
|
||||
func terminationTargetGoneAfterReap(*exec.Cmd) bool { return true }
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
|
||||
const commandReapTimeout = 3 * time.Second
|
||||
|
||||
// terminateOwnedProcessForTest is a seam for the failure-boundary tests. Tests
|
||||
// replace it only while no other test in this package is running in parallel.
|
||||
var terminateOwnedProcessForTest = terminateOwnedProcess
|
||||
|
||||
// TerminateProcess stops a background process by PID — on Windows its process
|
||||
// tree; on POSIX its whole process group when the PID leads its own group (the
|
||||
// invariant ConfigureChildProcessGroup establishes for processes started through
|
||||
@@ -26,11 +30,15 @@ func TerminateProcess(pid int) error {
|
||||
// have exclusive ownership of cmd: it must not have previously called Wait or
|
||||
// Process.Release, and no goroutine may call either concurrently. On POSIX it
|
||||
// stops the whole group when the command was configured as its leader; ordinary
|
||||
// commands safely fall back to PID/tree discovery. On Windows, success for a
|
||||
// leader that was already dead confirms only that the leader was reaped;
|
||||
// descendants may survive because Windows cannot rediscover a tree from a dead
|
||||
// root. `zero daemon start` needs this operation when readiness times out: it
|
||||
// launched the child, so it must both stop the tree and collect the leader.
|
||||
// commands safely fall back to PID/tree discovery. When POSIX observes a
|
||||
// waitable-zombie leader before signalling, a termination error is discarded
|
||||
// after reap only if the launch-time signal target is independently gone; the
|
||||
// leader observation alone says nothing about descendants. Windows retains its
|
||||
// existing behavior of ignoring the expected kill-attempt error when
|
||||
// GetExitCodeProcess shows that the root was already dead. Windows cannot
|
||||
// rediscover a tree from a dead root, so descendants may survive there. `zero
|
||||
// daemon start` needs this operation when readiness times out: it launched the
|
||||
// child, so it must both stop the tree and collect the leader.
|
||||
//
|
||||
// The order matters: the tree is signalled first, because Wait releases the
|
||||
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
|
||||
@@ -44,7 +52,7 @@ func TerminateCommand(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return errors.New("terminate command: process was never started")
|
||||
}
|
||||
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
|
||||
leaderAlreadyExited, terminateErr := terminateOwnedProcessForTest(cmd)
|
||||
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
|
||||
if reapErr != nil {
|
||||
if terminateErr != nil {
|
||||
@@ -52,12 +60,16 @@ func TerminateCommand(cmd *exec.Cmd) error {
|
||||
}
|
||||
return reapErr
|
||||
}
|
||||
if terminateErr != nil && !leaderAlreadyExited {
|
||||
return terminateErr
|
||||
if terminateErr != nil {
|
||||
if !leaderAlreadyExited || !terminationTargetGoneAfterReap(cmd) {
|
||||
return terminateErr
|
||||
}
|
||||
}
|
||||
// Only discard a termination error when the platform demonstrated before
|
||||
// attempting termination that the leader had already exited. A successful
|
||||
// reap alone says nothing about whether a live descendant tree was stopped.
|
||||
// On POSIX, discard a termination error only when the leader was already
|
||||
// exited and the launch-time signal target is independently gone after reap.
|
||||
// A successful leader reap alone says nothing about live descendants.
|
||||
// Windows retains its existing dead-root semantics because it has no
|
||||
// persistent group identity to query after the root exits.
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+75
-15
@@ -10,9 +10,11 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/acp"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
const acpUsage = `zero acp — serve the Agent Client Protocol (ACP) over stdio
|
||||
@@ -57,20 +59,8 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
// surface — no ACP-specific credential handling needed.
|
||||
NewProvider: deps.newProvider,
|
||||
RunAgent: agent.Run,
|
||||
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
|
||||
// exec surface, so ACP shell/file tools are confined — never run unconfined.
|
||||
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
return registry, engine, nil
|
||||
BuildWorkspace: func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*acp.Workspace, error) {
|
||||
return buildACPWorkspace(ctx, workspaceRoot, resolved, mode, deps)
|
||||
},
|
||||
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
|
||||
Store: deps.newSessionStore(),
|
||||
@@ -85,6 +75,76 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// buildACPWorkspace matches exec's registry construction for one ACP turn. MCP
|
||||
// servers use the same sandbox-prepared execution runner, project configuration is
|
||||
// gated by the validated workspace's trust state, and their runtime is released
|
||||
// when acp.Agent completes the turn. ACP deliberately stays at low autonomy: an
|
||||
// editor connection never upgrades MCP permissions beyond an interactive prompt.
|
||||
func buildACPWorkspace(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode, deps appDeps) (*acp.Workspace, error) {
|
||||
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
|
||||
workspace := &acp.Workspace{
|
||||
Registry: registry, Sandbox: engine,
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
}
|
||||
if mode != agent.PermissionModePlan {
|
||||
// MCP stdio servers are subprocesses. Passing the engine to their runner
|
||||
// keeps them inside the exact sandbox / lifecycle path used by zero exec.
|
||||
runtime, trustSkip, err := registerMCPToolsForWorkspaceWithOptions(
|
||||
ctx, workspaceRoot, registry, deps, mcp.AutonomyLow, workspaceRoot,
|
||||
mcp.RegisterOptions{Execution: execution.NewRunner(engine), AdvertiseInAuto: true},
|
||||
)
|
||||
if err != nil {
|
||||
// RegisterTools may have connected an earlier server before reporting a
|
||||
// later failure, so do not orphan a partial runtime on this error path.
|
||||
if runtime != nil {
|
||||
_ = runtime.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
workspace.Cleanup = runtime.Close
|
||||
workspace.Notices = acpMCPSetupNotices(trustSkip, runtime)
|
||||
}
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
// MCP tools are deferred-eligible. Register their loader only after every
|
||||
// ACP-visible tool is present, using the same mode the agent receives.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, mode, nil, nil)
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
func acpMCPSetupNotices(skip trustSkip, runtime mcpToolRuntime) []string {
|
||||
var notices []string
|
||||
if skip.excludedProjectConfig {
|
||||
if skip.trustCheckErrored {
|
||||
notices = append(notices, "The workspace-trust store could not be read; project MCP servers were ignored (fail-closed). Run 'zero trust' to enable them.")
|
||||
} else {
|
||||
notices = append(notices, "Project MCP servers were ignored in this untrusted workspace. Run 'zero trust' to enable them.")
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
return notices
|
||||
}
|
||||
for _, skipped := range runtime.Skipped() {
|
||||
if skipped.UnconfiguredDefault {
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf("MCP server %s unavailable, skipped", skipped.Name)
|
||||
if skipped.Err != nil {
|
||||
message += ": " + redaction.ErrorMessage(skipped.Err, redaction.Options{})
|
||||
}
|
||||
notices = append(notices, redaction.RedactString(message, redaction.Options{}))
|
||||
}
|
||||
return notices
|
||||
}
|
||||
|
||||
// acpWorkspaceRootResolver validates a client-supplied cwd into a confinement
|
||||
// root. It reuses exec's resolveWorkspaceRoot (abs+clean, must be an existing
|
||||
// dir) and additionally rejects the filesystem root and the home directory — an
|
||||
|
||||
@@ -5,10 +5,19 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
|
||||
type acpTestReader func([]byte) (int, error)
|
||||
@@ -108,6 +117,198 @@ func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPWorkspaceRegistersSandboxedMCPToolsAndClosesThem(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
if err := workspacetrust.Trust(workspaceRoot); err != nil {
|
||||
t.Fatalf("trust workspace: %v", err)
|
||||
}
|
||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("new grant store: %v", err)
|
||||
}
|
||||
|
||||
var gotExclude, registered, closed bool
|
||||
deps := fillAppDeps(appDeps{
|
||||
resolveMCPConfig: func(root string, excludeProject bool) (config.MCPConfig, error) {
|
||||
if root != workspaceRoot {
|
||||
t.Fatalf("MCP workspace root = %q, want %q", root, workspaceRoot)
|
||||
}
|
||||
gotExclude = excludeProject
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "fake-docs"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
||||
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
if ctx == nil {
|
||||
t.Fatal("MCP registration received nil context")
|
||||
}
|
||||
if len(cfg.Servers) != 1 || cfg.Servers["docs"].Command != "fake-docs" {
|
||||
t.Fatalf("MCP config = %+v", cfg)
|
||||
}
|
||||
if options.Autonomy != mcp.AutonomyLow {
|
||||
t.Fatalf("MCP autonomy = %q, want low", options.Autonomy)
|
||||
}
|
||||
if options.Execution == nil {
|
||||
t.Fatal("MCP stdio server was not given the sandbox execution runner")
|
||||
}
|
||||
if options.WorkspaceRoot != workspaceRoot {
|
||||
t.Fatalf("MCP execution workspace = %q, want %q", options.WorkspaceRoot, workspaceRoot)
|
||||
}
|
||||
if !options.AdvertiseInAuto {
|
||||
t.Fatal("ACP MCP tools must be advertised in auto mode without being pre-approved")
|
||||
}
|
||||
registered = true
|
||||
registry.Register(cliFakeDeferredTool{
|
||||
name: "mcp_docs_search", permission: tools.PermissionPrompt,
|
||||
advertiseInAuto: options.AdvertiseInAuto,
|
||||
})
|
||||
return closeFunc(func() error {
|
||||
closed = true
|
||||
return nil
|
||||
}), nil
|
||||
},
|
||||
})
|
||||
|
||||
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{
|
||||
Tools: config.ToolsConfig{DeferThreshold: 1},
|
||||
}, agent.PermissionModeAuto, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("buildACPWorkspace: %v", err)
|
||||
}
|
||||
if gotExclude {
|
||||
t.Fatal("trusted ACP workspace excluded project MCP configuration")
|
||||
}
|
||||
if !registered {
|
||||
t.Fatal("ACP workspace did not register configured MCP tools")
|
||||
}
|
||||
if _, ok := workspace.Registry.Get("mcp_docs_search"); !ok {
|
||||
t.Fatal("MCP tool is absent from ACP agent registry")
|
||||
}
|
||||
if _, ok := workspace.Registry.Get(tools.ToolSearchToolName); !ok {
|
||||
t.Fatal("ACP registry is missing tool_search for deferred MCP tools")
|
||||
}
|
||||
if workspace.DeferThreshold != 1 {
|
||||
t.Fatalf("workspace defer threshold = %d, want 1", workspace.DeferThreshold)
|
||||
}
|
||||
if err := workspace.Close(); err != nil {
|
||||
t.Fatalf("close ACP workspace: %v", err)
|
||||
}
|
||||
if !closed {
|
||||
t.Fatal("ACP workspace did not close its MCP runtime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPWorkspacePreservesRedactedMCPSetupNotices(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspaceRoot, ".zero"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectConfig := `{"mcp":{"servers":{"project-docs":{"type":"stdio","command":"project-docs"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(workspaceRoot, ".zero", "config.json"), []byte(projectConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := fillAppDeps(appDeps{
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "fake-docs"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{{
|
||||
Name: "docs", Err: errors.New("token=" + secret),
|
||||
}}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
joined := strings.Join(workspace.Notices, "\n")
|
||||
if !strings.Contains(joined, "untrusted workspace") || !strings.Contains(joined, "MCP server docs unavailable") {
|
||||
t.Fatalf("workspace notices = %#v", workspace.Notices)
|
||||
}
|
||||
if strings.Contains(joined, secret) || !strings.Contains(joined, "[REDACTED]") {
|
||||
t.Fatalf("workspace notices leaked secret: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPWorkspaceSkipsMCPInPlanMode(t *testing.T) {
|
||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("new grant store: %v", err)
|
||||
}
|
||||
called := false
|
||||
deps := fillAppDeps(appDeps{
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
called = true
|
||||
return config.MCPConfig{}, nil
|
||||
},
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
|
||||
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModePlan, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("buildACPWorkspace: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("plan-mode ACP workspace must not resolve or start MCP servers")
|
||||
}
|
||||
if err := workspace.Close(); err != nil {
|
||||
t.Fatalf("close plan workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPWorkspaceClosesPartialMCPRuntimeOnRegistrationError(t *testing.T) {
|
||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("new grant store: %v", err)
|
||||
}
|
||||
closed := false
|
||||
deps := fillAppDeps(appDeps{
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"partial": {Type: "stdio", Command: "fake-partial"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return closeFunc(func() error {
|
||||
closed = true
|
||||
return nil
|
||||
}), errors.New("second MCP server failed")
|
||||
},
|
||||
})
|
||||
|
||||
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
|
||||
if err == nil {
|
||||
t.Fatal("buildACPWorkspace succeeded after MCP registration error")
|
||||
}
|
||||
if workspace != nil {
|
||||
t.Fatal("buildACPWorkspace returned a workspace after MCP registration error")
|
||||
}
|
||||
if !closed {
|
||||
t.Fatal("partial MCP runtime was not closed after registration error")
|
||||
}
|
||||
}
|
||||
|
||||
type acpNotifyingReadCloser struct {
|
||||
io.ReadCloser
|
||||
readStarted chan<- struct{}
|
||||
|
||||
+191
-63
@@ -311,40 +311,35 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
|
||||
modelregistry.EnableModelsDevOverlay()
|
||||
|
||||
addDirs, args, err := splitLeadingAddDirFlags(args)
|
||||
// --add-dir, --theme and --allow-escalation may be written in any order;
|
||||
// see splitLeadingRootFlags for why they are split together.
|
||||
root, args, err := splitLeadingRootFlags(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --theme <name> selects the TUI palette non-interactively (auto or any registered
|
||||
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
|
||||
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
|
||||
theme, args, err := splitLeadingThemeFlag(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
moreDirs, args, err := splitLeadingAddDirFlags(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
addDirs = append(addDirs, moreDirs...)
|
||||
addDirs, theme, allowEscalation := root.addDirs, root.theme, root.allowEscalation
|
||||
|
||||
if len(args) == 0 {
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs, theme)
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs, theme, allowEscalation)
|
||||
}
|
||||
|
||||
// --add-dir grants an extra write root, and only the interactive TUI and
|
||||
// exec dispatch paths consume one. Fail loud everywhere else rather than
|
||||
// silently discarding an explicit grant — including help/version, which
|
||||
// run no agent and could only ignore it. The allowlist names exactly the
|
||||
// cases below that forward addDirs; a future subcommand is rejected by
|
||||
// default until it opts in here.
|
||||
if len(addDirs) > 0 {
|
||||
switch args[0] {
|
||||
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
|
||||
// Forwarded by the matching case below.
|
||||
default:
|
||||
return writeAppError(stderr, "--add-dir is only supported for the interactive TUI and exec", 1)
|
||||
}
|
||||
// --add-dir grants an extra write root and --allow-escalation opts a run
|
||||
// into mid-run model escalation; only the interactive TUI and exec consume
|
||||
// either. Both are forwarded to exec below and rejected loudly everywhere
|
||||
// else rather than silently discarded, including help/version, which run
|
||||
// no agent and could only ignore them. The allowlist names exactly the
|
||||
// cases below that forward; a future subcommand is rejected by default
|
||||
// until it opts in here.
|
||||
forwardsRootFlags := false
|
||||
switch args[0] {
|
||||
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
|
||||
forwardsRootFlags = true
|
||||
}
|
||||
if len(addDirs) > 0 && !forwardsRootFlags {
|
||||
return writeAppError(stderr, "--add-dir is only supported for the interactive TUI and exec", 1)
|
||||
}
|
||||
if allowEscalation && !forwardsRootFlags {
|
||||
return writeAppError(stderr, "--allow-escalation is only supported for the interactive TUI and exec", 1)
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
@@ -354,26 +349,24 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
// reach unsafe mode in the shell — and the "!" shell escape (which is
|
||||
// gated behind unsafe) was therefore unreachable.
|
||||
//
|
||||
// --add-dir may legally appear on either side of the flag, so re-split
|
||||
// the remaining args and merge with the dirs already collected. Any
|
||||
// trailing non-flag args were ignored on this path before --add-dir
|
||||
// existed and still are — but an --add-dir hidden BEHIND one would be
|
||||
// silently dropped with them, so reject that misplacement loudly.
|
||||
moreDirs, rest, err := splitLeadingAddDirFlags(args[1:])
|
||||
// The root flags may legally appear on either side of this flag, so
|
||||
// split them again from what follows it and merge with what the root
|
||||
// already took. Any trailing non-flag args were ignored on this path
|
||||
// before --add-dir existed and still are, but an --add-dir hidden
|
||||
// BEHIND one would be silently dropped with them, so reject that
|
||||
// misplacement loudly below.
|
||||
more, rest, err := splitLeadingRootFlags(args[1:])
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --theme may appear here too; extract it before the stray-arg checks so it is
|
||||
// not rejected as an unexpected positional, then re-split --add-dir after it.
|
||||
skipTheme, rest, err := splitLeadingThemeFlag(rest)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
moreDirs := more.addDirs
|
||||
// A --theme written before the flag was taken at the root and used to be
|
||||
// dropped here; one written after it wins, as the last occurrence does.
|
||||
skipTheme := theme
|
||||
if more.theme != "" {
|
||||
skipTheme = more.theme
|
||||
}
|
||||
evenMoreDirs, rest, err := splitLeadingAddDirFlags(rest)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
moreDirs = append(moreDirs, evenMoreDirs...)
|
||||
skipAllowEscalation := more.allowEscalation
|
||||
// A misplaced --add-dir anywhere in the remainder is the more specific error,
|
||||
// so check for it across all of rest before rejecting stray args.
|
||||
for _, arg := range rest {
|
||||
@@ -390,7 +383,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return writeAppError(stderr, "--skip-permissions-unsafe launches the interactive TUI and takes no prompt or subcommand; for a one-shot unsafe run use `zero exec --skip-permissions-unsafe -p \"...\"`", 1)
|
||||
}
|
||||
}
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme)
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme, allowEscalation || skipAllowEscalation)
|
||||
case "-h", "--help", "help":
|
||||
if err := writeHelp(stdout); err != nil {
|
||||
return 1
|
||||
@@ -423,16 +416,16 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if len(args) < 2 {
|
||||
return writePromptRequired(stderr)
|
||||
}
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
// Forward the root flags exec consumes so its own parser collects them.
|
||||
// Use the inline --prompt=<value> form so a prompt whose first character is a
|
||||
// dash (e.g. `zero -p "-foo"`) is taken verbatim instead of being mistaken for
|
||||
// a flag and rejected with "--prompt requires a value" (matches the cron path).
|
||||
execArgs := append(addDirFlagArgs(addDirs), "--prompt="+args[1])
|
||||
execArgs := append(rootFlagArgs(addDirs, allowEscalation), "--prompt="+args[1])
|
||||
execArgs = append(execArgs, args[2:]...)
|
||||
return runExec(execArgs, stdout, stderr, deps)
|
||||
case "exec":
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
||||
// Forward the root flags exec consumes so its own parser collects them.
|
||||
return runExec(append(rootFlagArgs(addDirs, allowEscalation), args[1:]...), stdout, stderr, deps)
|
||||
case "completions":
|
||||
return runCompletions(args[1:], stdout, stderr)
|
||||
case "daemon":
|
||||
@@ -694,11 +687,11 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
return deps
|
||||
}
|
||||
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string) int {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false)
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, allowEscalation bool) int {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false, allowEscalation)
|
||||
}
|
||||
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool, allowEscalation bool) int {
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
@@ -790,6 +783,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
// Mid-run model escalation is opt-in on this surface too. The tool is present
|
||||
// only when the operator asked for it with --allow-escalation, and the
|
||||
// switchers that make it do anything ride on the same flag through
|
||||
// Options.AllowEscalation below. Registering one without the other ships a
|
||||
// tool the loop will never act on, which looks like a feature and is not.
|
||||
if allowEscalation {
|
||||
registry.Register(tools.NewEscalateModelTool())
|
||||
}
|
||||
executionRunner := execution.NewRunner(nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
@@ -839,18 +840,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
mcpTokenStore = nil
|
||||
err = nil
|
||||
}
|
||||
criticalMCPConfig, optionalMCPConfig := splitMCPStartupConfig(mcpConfig)
|
||||
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
|
||||
if len(mcpConfig.Servers) > 0 {
|
||||
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
||||
if len(criticalMCPConfig.Servers) > 0 {
|
||||
runtime, registerErr := deps.registerMCPTools(context.Background(), registry, criticalMCPConfig, mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
closeMCPRuntime(stderr, mcpRuntime)
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
if registerErr != nil {
|
||||
closeMCPRuntime(stderr, runtime)
|
||||
return writeAppError(stderr, redaction.ErrorMessage(registerErr, redaction.Options{}), 1)
|
||||
}
|
||||
mcpRuntime = runtime
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// A server that could not be reached or validated is skipped, not fatal (one
|
||||
@@ -909,11 +912,35 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
// Activate deferred MCP-tool loading for the interactive run only when the
|
||||
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
||||
// The registry is complete (core + specialist + MCP + plugins) here, so the
|
||||
// count is accurate; below threshold this is a no-op and the surface is
|
||||
// unchanged. The interactive surface applies no operator tool filters, so
|
||||
// enabled/disabled are nil — matching the AgentOptions below.
|
||||
// This first pass covers every prompt-critical tool. Optional built-in MCP
|
||||
// defaults re-run the same gate after publishing their atomic batch, so a
|
||||
// later catalog generation cannot miss its loader. 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)
|
||||
var optionalMCPRuntime *optionalMCPStartup
|
||||
var awaitToolReadiness func(context.Context)
|
||||
if len(optionalMCPConfig.Servers) > 0 {
|
||||
optionalMCPRuntime = startOptionalMCP(
|
||||
context.Background(),
|
||||
registry,
|
||||
optionalMCPConfig,
|
||||
mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
},
|
||||
deps.registerMCPTools,
|
||||
func() {
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
},
|
||||
)
|
||||
defer closeMCPRuntime(stderr, optionalMCPRuntime)
|
||||
awaitToolReadiness = func(ctx context.Context) {
|
||||
optionalMCPRuntime.Await(ctx, optionalMCPPromptGrace)
|
||||
}
|
||||
}
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
var scratchBaseline scratchFileBaseline
|
||||
@@ -948,10 +975,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecentModels: resolved.Preferences.RecentModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
CompactionModel: resolved.Preferences.CompactionModel,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
|
||||
return optimized
|
||||
}
|
||||
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
|
||||
},
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
PrepareRunCompletionWarning: func() {
|
||||
scratchBaseline = scratchFileSnapshot(workspaceRoot)
|
||||
},
|
||||
@@ -959,6 +996,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return scratchFileWarning(workspaceRoot, scratchBaseline)
|
||||
},
|
||||
Registry: registry,
|
||||
AwaitToolReadiness: awaitToolReadiness,
|
||||
SessionStore: sessionStore,
|
||||
PeerService: peerService,
|
||||
SandboxStore: sandboxStore,
|
||||
@@ -996,6 +1034,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
AllowEscalation: allowEscalation,
|
||||
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
|
||||
// It resolves against the same merged set (default dir + plugin skill
|
||||
// roots) as the skill tool and the system-prompt list, re-read per use so
|
||||
@@ -1341,6 +1380,7 @@ Flags:
|
||||
-v, --version Print version
|
||||
-p, --prompt Run a one-shot prompt
|
||||
--add-dir <path> Allow writes in an extra directory (repeatable)
|
||||
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
|
||||
--skip-permissions-unsafe Launch the interactive shell in unsafe mode (enables the ! shell escape)
|
||||
`)
|
||||
return err
|
||||
@@ -1357,6 +1397,17 @@ func addDirFlagArgs(addDirs []string) []string {
|
||||
return flags
|
||||
}
|
||||
|
||||
// rootFlagArgs re-synthesises the root flags exec consumes, in the spelling
|
||||
// its own parser accepts, so a flag written before the subcommand reaches
|
||||
// the run exactly as one written after it would.
|
||||
func rootFlagArgs(addDirs []string, allowEscalation bool) []string {
|
||||
flags := addDirFlagArgs(addDirs)
|
||||
if allowEscalation {
|
||||
flags = append(flags, "--allow-escalation")
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// splitLeadingAddDirFlags strips leading --add-dir flags from the root
|
||||
// argument list (zero --add-dir <path> [--add-dir <path>] [subcommand …]).
|
||||
// Subcommands like exec parse their own --add-dir occurrences.
|
||||
@@ -1397,6 +1448,83 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
|
||||
return addDirs, args, nil
|
||||
}
|
||||
|
||||
// rootFlags is what the leading root flags amount to once every one of them
|
||||
// has been stripped from the front of the argument list.
|
||||
type rootFlags struct {
|
||||
addDirs []string
|
||||
theme string
|
||||
allowEscalation bool
|
||||
}
|
||||
|
||||
// splitLeadingRootFlags strips --add-dir, --theme and --allow-escalation from
|
||||
// the front of args in whatever order they were written, stopping at the
|
||||
// first token none of them claims.
|
||||
//
|
||||
// EACH SPLITTER STOPS AT THE FIRST TOKEN IT DOES NOT OWN, so running them once
|
||||
// in a fixed sequence made the order the operator wrote them in load-bearing:
|
||||
// a flag handled late in the sequence stranded every flag written after it as
|
||||
// an unknown command, and `zero --allow-escalation --theme auto` exited with
|
||||
// an argument error instead of launching. Running the sequence until it makes
|
||||
// no progress accepts every ordering, including a flag repeated on both sides
|
||||
// of another.
|
||||
func splitLeadingRootFlags(args []string) (rootFlags, []string, error) {
|
||||
var flags rootFlags
|
||||
for {
|
||||
before := len(args)
|
||||
addDirs, rest, err := splitLeadingAddDirFlags(args)
|
||||
if err != nil {
|
||||
return rootFlags{}, nil, err
|
||||
}
|
||||
flags.addDirs = append(flags.addDirs, addDirs...)
|
||||
theme, rest, err := splitLeadingThemeFlag(rest)
|
||||
if err != nil {
|
||||
return rootFlags{}, nil, err
|
||||
}
|
||||
if theme != "" {
|
||||
// The last occurrence wins across passes, as it does within one.
|
||||
flags.theme = theme
|
||||
}
|
||||
allowEscalation, rest, err := splitLeadingAllowEscalationFlag(rest)
|
||||
if err != nil {
|
||||
return rootFlags{}, nil, err
|
||||
}
|
||||
flags.allowEscalation = flags.allowEscalation || allowEscalation
|
||||
args = rest
|
||||
if len(args) == before {
|
||||
return flags, args, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitLeadingAllowEscalationFlag strips a leading --allow-escalation from the
|
||||
// root argument list, opting the interactive session into mid-run model
|
||||
// escalation.
|
||||
//
|
||||
// OPT-IN, THE SAME WAY exec IS. Escalation moves a run onto a different model,
|
||||
// which changes what the run costs and which provider sees the conversation, so
|
||||
// it is a decision the operator makes rather than a default. The exec flag
|
||||
// already answers this conservatively and the interactive surface should not
|
||||
// answer it differently.
|
||||
//
|
||||
// Bare flag only: repeating it is harmless, and an =value form is rejected so a
|
||||
// mistyped --allow-escalation=false is a loud error instead of silently enabling
|
||||
// the thing it was trying to turn off.
|
||||
func splitLeadingAllowEscalationFlag(args []string) (bool, []string, error) {
|
||||
allow := false
|
||||
for len(args) > 0 {
|
||||
switch {
|
||||
case args[0] == "--allow-escalation":
|
||||
allow = true
|
||||
args = args[1:]
|
||||
case strings.HasPrefix(args[0], "--allow-escalation="):
|
||||
return false, nil, errors.New("--allow-escalation takes no value; pass it bare to enable mid-run model escalation, or omit it")
|
||||
default:
|
||||
return allow, args, nil
|
||||
}
|
||||
}
|
||||
return allow, args, nil
|
||||
}
|
||||
|
||||
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
|
||||
// from the root argument list and validates it against the registered themes. The
|
||||
// last occurrence wins. A value outside the allowed set is a loud error rather than
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
@@ -486,6 +487,74 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsPaintsBeforeOptionalDefaultMCPIsReady(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPermissionStore() error = %v", err)
|
||||
}
|
||||
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
configResult := make(chan error, 1)
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
userConfigPath: func() (string, error) { return userConfigPath, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: config.DefaultMCPServers()}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return permissionStore, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return tokenStore, nil },
|
||||
registerMCPTools: func(_ context.Context, registry *tools.Registry, cfg config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
var configErr error
|
||||
if _, ok := cfg.Servers["exa"]; !ok || len(cfg.Servers) != 1 {
|
||||
configErr = fmt.Errorf("optional MCP config = %#v, want only exa", cfg.Servers)
|
||||
}
|
||||
configResult <- configErr
|
||||
close(started)
|
||||
<-release
|
||||
registry.Register(cliFakeMCPRegistryTool{})
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("optional MCP initialization did not start")
|
||||
}
|
||||
if options.AwaitToolReadiness == nil {
|
||||
t.Fatal("TUI did not receive optional tool readiness barrier")
|
||||
}
|
||||
if _, ok := options.Registry.Get("mcp_docs_lookup"); ok {
|
||||
t.Fatal("optional MCP tool was published before registration completed")
|
||||
}
|
||||
close(release)
|
||||
options.AwaitToolReadiness(ctx)
|
||||
if _, ok := options.Registry.Get("mcp_docs_lookup"); !ok {
|
||||
t.Fatal("optional MCP tool was not visible after readiness completed")
|
||||
}
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if configErr := <-configResult; configErr != nil {
|
||||
t.Fatal(configErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -565,6 +634,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
runtimeClosed := false
|
||||
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
@@ -591,7 +661,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
return closeFunc(func() error {
|
||||
runtimeClosed = true
|
||||
return nil
|
||||
}), errors.New("register mcp tools failed")
|
||||
}), fmt.Errorf("register mcp tools failed: %s", secret)
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
t.Fatal("TUI should not launch after MCP registration fails")
|
||||
@@ -605,6 +675,12 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "register mcp tools failed") {
|
||||
t.Fatalf("stderr missing registration error: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), secret) {
|
||||
t.Fatalf("stderr leaked MCP registration secret: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), redaction.RedactedSecret) {
|
||||
t.Fatalf("stderr missing redaction marker: %s", stderr.String())
|
||||
}
|
||||
if !runtimeClosed {
|
||||
t.Fatal("partial MCP runtime was not closed after registration error")
|
||||
}
|
||||
|
||||
+48
-1
@@ -74,6 +74,8 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return runAuthStatus(args[1:], stdout, stderr, deps)
|
||||
case "refresh":
|
||||
return runAuthRefresh(args[1:], stdout, stderr, deps)
|
||||
case "reset":
|
||||
return runAuthReset(args[1:], stdout, stderr, deps)
|
||||
case "openrouter":
|
||||
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
|
||||
case "chatgpt":
|
||||
@@ -261,6 +263,7 @@ type authArgs struct {
|
||||
json bool
|
||||
device bool
|
||||
watch bool
|
||||
confirm bool
|
||||
scopes []string
|
||||
help bool
|
||||
}
|
||||
@@ -286,6 +289,8 @@ func parseAuthArgs(sub string, args []string) (authArgs, error) {
|
||||
parsed.device = true
|
||||
case arg == "--watch":
|
||||
parsed.watch = true
|
||||
case arg == "--confirm":
|
||||
parsed.confirm = true
|
||||
case arg == "--scope":
|
||||
if i+1 >= len(args) {
|
||||
return authArgs{}, fmt.Errorf("--scope requires a value")
|
||||
@@ -321,6 +326,7 @@ func validateAuthFlags(sub string, a authArgs) error {
|
||||
"logout": {"json": true},
|
||||
"status": {"json": true},
|
||||
"refresh": {"watch": true},
|
||||
"reset": {"json": true, "confirm": true},
|
||||
}[sub]
|
||||
bad := func(name string) error { return fmt.Errorf("zero auth %s does not accept %s", sub, name) }
|
||||
if a.json && !allowed["json"] {
|
||||
@@ -332,6 +338,9 @@ func validateAuthFlags(sub string, a authArgs) error {
|
||||
if a.watch && !allowed["watch"] {
|
||||
return bad("--watch")
|
||||
}
|
||||
if a.confirm && !allowed["confirm"] {
|
||||
return bad("--confirm")
|
||||
}
|
||||
if len(a.scopes) > 0 && !allowed["scope"] {
|
||||
return bad("--scope")
|
||||
}
|
||||
@@ -569,6 +578,43 @@ func runAuthRefreshWatch(manager *oauth.Manager, key, provider string, stdout io
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthReset(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("reset", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) > 0 {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("zero auth reset takes no arguments (got %q)", parsed.positional[0]))
|
||||
}
|
||||
if !parsed.confirm {
|
||||
return writeExecUsageError(stderr, "zero auth reset clears all stored OAuth logins and shared MCP tokens; rerun with --confirm to reset")
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := manager.Reset(); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Reset bool `json:"reset"`
|
||||
}{Reset: true}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, "Reset OAuth token store."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func filterAuthStatuses(statuses []oauth.Status, provider string) []oauth.Status {
|
||||
want := oauth.ProviderKey(provider)
|
||||
filtered := make([]oauth.Status, 0, 1)
|
||||
@@ -589,6 +635,7 @@ Commands:
|
||||
logout <provider> Delete a provider's stored login
|
||||
status [provider] Show login presence/expiry (never the token)
|
||||
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
|
||||
reset --confirm [--json] Clear all OAuth logins and shared MCP tokens (also repairs corrupted stores)
|
||||
openrouter Log in to OpenRouter in the browser; mints an API key
|
||||
chatgpt Log in to ChatGPT in the browser (Codex backend, ChatGPT Plus/Pro)
|
||||
|
||||
@@ -615,7 +662,7 @@ Flags:
|
||||
--device Use the device-code flow (headless/SSH; no browser)
|
||||
--scope Add an OAuth scope (repeatable)
|
||||
--watch Keep the token fresh in the foreground (refresh only)
|
||||
--json Print result as JSON (status/logout)
|
||||
--json Print result as JSON (status/logout/reset)
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
|
||||
@@ -129,6 +129,8 @@ func TestRunAuthRejectsWrongFlags(t *testing.T) {
|
||||
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
|
||||
{"auth", "refresh", "demo", "--json"}, // json not for refresh
|
||||
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
|
||||
{"auth", "status", "--confirm"}, // confirm is reset-only
|
||||
{"auth", "reset", "--confirm", "--watch"}, // confirmation does not allow unrelated flags
|
||||
}
|
||||
for _, args := range cases {
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -196,13 +198,102 @@ func TestRunAuthHelp(t *testing.T) {
|
||||
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d", code)
|
||||
}
|
||||
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
|
||||
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "reset --confirm", "--device"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthResetRequiresConfirmation(t *testing.T) {
|
||||
for _, args := range [][]string{{}, {"--json"}} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runAuthReset(args, &stdout, &stderr, appDeps{})
|
||||
after, readErr := os.ReadFile(path)
|
||||
if readErr != nil || !bytes.Equal(before, after) {
|
||||
t.Fatalf("unconfirmed reset changed the store: %v", readErr)
|
||||
}
|
||||
if code == exitSuccess || !strings.Contains(stderr.String(), "--confirm") || stdout.Len() != 0 {
|
||||
t.Fatalf("unconfirmed reset: exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthResetJSON(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "reset", "--confirm", "--json"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v (stdout=%q)", err, stdout.String())
|
||||
}
|
||||
if !payload.Reset {
|
||||
t.Fatalf("payload = %+v, want reset=true", payload)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("status exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
||||
t.Fatalf("expected empty store after JSON reset, got: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthReset(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "secret"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "reset", "--confirm"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Reset OAuth token store") {
|
||||
t.Fatalf("stdout = %q", stdout.String())
|
||||
}
|
||||
|
||||
// Verify store is now empty.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("status exit = %d", code)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
||||
t.Fatalf("expected empty store after reset, got: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
||||
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
||||
// authorize params), not the generic manager path. The generic login accepts
|
||||
|
||||
@@ -407,7 +407,11 @@ func formatProviderCatalogLine(provider providerCatalogSummary) string {
|
||||
provider.RuntimeSupported,
|
||||
))
|
||||
if provider.RuntimeSupported {
|
||||
lines = append(lines, " setup: zero providers setup "+displayCLIValue(provider.ID, "unknown")+" --set-active")
|
||||
setup := " setup: zero providers setup " + displayCLIValue(provider.ID, "unknown") + " --set-active"
|
||||
if provider.ID == "atomic-chat-local" {
|
||||
setup = " setup: run zero setup to select a loaded model, or zero providers detect to get an add command"
|
||||
}
|
||||
lines = append(lines, setup)
|
||||
} else {
|
||||
lines = append(lines, " unsupported: "+displayCLIValue(provider.RuntimeUnsupportedReason, "unknown"))
|
||||
}
|
||||
@@ -502,7 +506,7 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers models [name] [flags]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio, Atomic Chat) and prints adopt commands plus per-provider next steps.
|
||||
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
|
||||
|
||||
Flags:
|
||||
|
||||
@@ -967,3 +967,10 @@ func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
|
||||
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicCatalogSetupRequiresLoadedModel(t *testing.T) {
|
||||
out := formatProviderCatalogLine(providerCatalogSummary{ID: "atomic-chat-local", RuntimeSupported: true})
|
||||
if strings.Contains(out, "zero providers setup atomic-chat-local --set-active") || !strings.Contains(out, "zero setup") {
|
||||
t.Fatalf("catalog advertises a failing setup command: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type completionContext struct {
|
||||
}
|
||||
|
||||
var completionRoot = completionNode{
|
||||
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--theme", "--skip-permissions-unsafe"},
|
||||
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--allow-escalation", "--theme", "--skip-permissions-unsafe"},
|
||||
children: []completionNode{
|
||||
{names: []string{"exec"}, flags: []string{
|
||||
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
|
||||
@@ -78,7 +78,8 @@ var completionRoot = completionNode{
|
||||
{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{"auth"}, children: append(leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh"),
|
||||
completionNode{names: []string{"reset"}, flags: []string{"--confirm", "--json", "--help"}})},
|
||||
{names: []string{"sandbox"}, children: []completionNode{
|
||||
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
|
||||
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
|
||||
|
||||
@@ -159,6 +159,8 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
assertCandidates(t, byPath["worktree"], "prepare", "release")
|
||||
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
|
||||
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
|
||||
assertCandidates(t, byPath["auth"], "openrouter", "chatgpt", "login", "logout", "status", "refresh", "reset")
|
||||
assertCandidates(t, byPath["auth reset"], "--confirm", "--json", "--help")
|
||||
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")
|
||||
|
||||
@@ -18,14 +18,20 @@ import (
|
||||
// cliFakeDeferredTool is deferred-eligible (implements Deferred() bool), mirroring
|
||||
// an MCP registry tool, so it counts toward the deferral threshold.
|
||||
type cliFakeDeferredTool struct {
|
||||
name string
|
||||
name string
|
||||
permission tools.Permission
|
||||
advertiseInAuto bool
|
||||
}
|
||||
|
||||
func (t cliFakeDeferredTool) Name() string { return t.name }
|
||||
func (t cliFakeDeferredTool) Description() string { return "fake deferred tool" }
|
||||
func (t cliFakeDeferredTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
|
||||
func (t cliFakeDeferredTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
|
||||
permission := t.permission
|
||||
if permission == "" {
|
||||
permission = tools.PermissionAllow
|
||||
}
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: permission, AdvertiseInAuto: t.advertiseInAuto}
|
||||
}
|
||||
func (t cliFakeDeferredTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
|
||||
+31
-59
@@ -442,57 +442,22 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// the resolved model and is reassigned by the model switcher on a mid-run
|
||||
// escalation so post-switch turns are attributed to the escalated model.
|
||||
currentModel := resolved.Provider.Model
|
||||
var modelSwitcher func(context.Context, string) (agent.Provider, error)
|
||||
if options.allowEscalation {
|
||||
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
|
||||
// deps.newProvider is wrapped (fillAppDeps) to apply the stored key, so
|
||||
// the escalated provider is authenticated even though resolved.Provider
|
||||
// is the pure profile — no per-site key handling here.
|
||||
switchedProfile := resolved.Provider
|
||||
switchedProfile.Model = modelID
|
||||
switchedProvider, err := deps.newProvider(switchedProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Mirror the agent loop's switch guard (it only reassigns the provider
|
||||
// when newProvider != nil). Updating currentModel only on a non-nil
|
||||
// provider keeps usage attribution consistent with whether the loop
|
||||
// actually switched — a (nil, nil) return leaves both untouched.
|
||||
if switchedProvider != nil {
|
||||
currentModel = modelID
|
||||
}
|
||||
return switchedProvider, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off).
|
||||
// nil when gated off or the profile is ineligible: agent.Run then wraps the
|
||||
// provider in its default adapter — the exact code path of today. The
|
||||
// session switcher is installed only when the run START is optimized, so
|
||||
// the legacy ModelSwitcher path above stays untouched otherwise.
|
||||
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off). nil
|
||||
// when gated off or the profile is ineligible: agent.Run then wraps the
|
||||
// provider in its default adapter. This is the run's STARTING session provider
|
||||
// and is used whether or not escalation is enabled.
|
||||
turnSessions, _ := providers.OptimizedTurnSessions(resolved.Provider, provider, providers.Options{})
|
||||
// Both switchers come from one shared builder so exec and the interactive TUI
|
||||
// cannot drift on the nil contracts the agent loop depends on. The session
|
||||
// switcher is nil unless this run STARTED optimized, which is what keeps a
|
||||
// default-adapter run on the default adapter.
|
||||
var modelSwitcher func(context.Context, string) (agent.Provider, error)
|
||||
var modelSessionSwitcher func(context.Context, string) (zeroruntime.TurnSessionProvider, error)
|
||||
if options.allowEscalation && turnSessions != nil {
|
||||
modelSessionSwitcher = func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
|
||||
switchedProfile := resolved.Provider
|
||||
switchedProfile.Model = modelID
|
||||
switchedProvider, err := deps.newProvider(switchedProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if switchedProvider == nil {
|
||||
// The loop treats a nil session source as "no swap" — mirror the
|
||||
// legacy closure's (nil, nil) contract.
|
||||
return nil, nil
|
||||
}
|
||||
currentModel = modelID
|
||||
if optimized, ok := providers.OptimizedTurnSessions(switchedProfile, switchedProvider, providers.Options{}); ok {
|
||||
return optimized, nil
|
||||
}
|
||||
// Ineligible switch target: default adapter, but with the switched
|
||||
// model's resolved capability projection preserved.
|
||||
return providers.DefaultTurnSessions(switchedProfile, switchedProvider, providers.Options{}), nil
|
||||
}
|
||||
if options.allowEscalation {
|
||||
modelSwitcher, modelSessionSwitcher = providers.EscalationSwitchers(
|
||||
resolved.Provider, provider, deps.newProvider,
|
||||
func(modelID string) { currentModel = modelID },
|
||||
)
|
||||
}
|
||||
|
||||
runMetadata, err := resolveExecRunMetadata(resolved.Provider)
|
||||
@@ -682,16 +647,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ModelSwitcher: modelSwitcher,
|
||||
TurnSessionProvider: turnSessions,
|
||||
ModelSessionSwitcher: modelSessionSwitcher,
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
Summarizer: summarizerFactory(resolved, deps.newProvider),
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
|
||||
},
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
@@ -939,6 +908,9 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
|
||||
if deferThreshold <= 0 {
|
||||
return
|
||||
}
|
||||
if _, registered := registry.Get(tools.ToolSearchToolName); registered {
|
||||
return
|
||||
}
|
||||
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -267,23 +267,29 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
|
||||
func (writer *execEventWriter) usage(usage agent.Usage) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{
|
||||
"type": "usage",
|
||||
"prompt_tokens": usage.PromptTokens,
|
||||
"completion_tokens": usage.CompletionTokens,
|
||||
"total_tokens": usage.TotalTokens(),
|
||||
"type": "usage",
|
||||
"prompt_tokens": usage.PromptTokens,
|
||||
"completion_tokens": usage.CompletionTokens,
|
||||
"cached_input_tokens": usage.CachedInputTokens,
|
||||
"cache_write_tokens": usage.CacheWriteTokens,
|
||||
"total_tokens": usage.TotalTokens(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
promptTokens := usage.EffectiveInputTokens()
|
||||
completionTokens := usage.EffectiveOutputTokens()
|
||||
cachedInputTokens := usage.CachedInputTokens
|
||||
cacheWriteTokens := usage.CacheWriteTokens
|
||||
totalTokens := usage.TotalTokens()
|
||||
writer.writeStreamJSON(streamjson.Event{
|
||||
Type: streamjson.EventUsage,
|
||||
RunID: writer.runID,
|
||||
PromptTokens: &promptTokens,
|
||||
CompletionTokens: &completionTokens,
|
||||
TotalTokens: &totalTokens,
|
||||
Type: streamjson.EventUsage,
|
||||
RunID: writer.runID,
|
||||
PromptTokens: &promptTokens,
|
||||
CompletionTokens: &completionTokens,
|
||||
CachedInputTokens: &cachedInputTokens,
|
||||
CacheWriteTokens: &cacheWriteTokens,
|
||||
TotalTokens: &totalTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +52,39 @@ func TestExecWriterPropagatesToolResultTruncation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecWriterPropagatesCacheUsage(t *testing.T) {
|
||||
for _, format := range []execOutputFormat{execOutputJSON, execOutputStreamJSON} {
|
||||
t.Run(string(format), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
writer := execEventWriter{
|
||||
stdout: &stdout,
|
||||
format: format,
|
||||
runID: "run_usage",
|
||||
streamedText: &strings.Builder{},
|
||||
}
|
||||
writer.usage(agent.Usage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 20,
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 20,
|
||||
CachedInputTokens: 60,
|
||||
CacheWriteTokens: 10,
|
||||
})
|
||||
if writer.err != nil {
|
||||
t.Fatalf("usage: %v", writer.err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil {
|
||||
t.Fatalf("decode output %q: %v", stdout.String(), err)
|
||||
}
|
||||
cachedKey, writeKey := "cached_input_tokens", "cache_write_tokens"
|
||||
if format == execOutputStreamJSON {
|
||||
cachedKey, writeKey = "cachedInputTokens", "cacheWriteTokens"
|
||||
}
|
||||
if payload[cachedKey] != float64(60) || payload[writeKey] != float64(10) {
|
||||
t.Fatalf("cache usage missing from payload: %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ type mcpWritableConfig struct {
|
||||
serverRaw map[string]json.RawMessage
|
||||
}
|
||||
|
||||
var lockMCPConfigFile = config.LockFile
|
||||
|
||||
// projectMCPConfigExists reports whether the workspace's project ./.zero/config.json
|
||||
// declares any MCP servers, so the trust notice fires only when project MCP config was
|
||||
// actually skipped (mirroring projectHooksFileExists / projectPluginsDirExists). A
|
||||
@@ -50,7 +52,7 @@ func projectMCPConfigExists(workspaceRoot string) bool {
|
||||
return len(fc.MCP.Servers) > 0
|
||||
}
|
||||
|
||||
func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) (exitCode int) {
|
||||
options, help, err := parseMCPAddArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -66,6 +68,26 @@ func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
|
||||
}
|
||||
// This edits the same user config document the config package mutates, with
|
||||
// the same read-modify-write + rename shape, so it takes the same
|
||||
// cross-process lock. Without it, an MCP config edit racing a provider or
|
||||
// preference write would silently drop whichever landed first (issue #832).
|
||||
unlock, err := lockMCPConfigFile(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
// A failed release leaves the lock held for the rest of the process, so
|
||||
// exiting success here would claim a state the next config write cannot
|
||||
// reproduce. It must not mask a failure this command already reported.
|
||||
if releaseErr := unlock(); releaseErr != nil && exitCode == exitSuccess {
|
||||
exitCode = writeAppError(stderr, redaction.ErrorMessage(releaseErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
}()
|
||||
cfg, err := readMCPWritableConfig(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
@@ -80,6 +102,10 @@ func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
if err := writeMCPWritableConfig(configPath, cfg); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := unlock(); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
released = true
|
||||
|
||||
if options.json {
|
||||
payload := struct {
|
||||
@@ -109,7 +135,7 @@ func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) (exitCode int) {
|
||||
options, positional, help, err := parseMCPConfigPositionalCommand(args, "remove")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -132,6 +158,26 @@ func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
|
||||
}
|
||||
// This edits the same user config document the config package mutates, with
|
||||
// the same read-modify-write + rename shape, so it takes the same
|
||||
// cross-process lock. Without it, an MCP config edit racing a provider or
|
||||
// preference write would silently drop whichever landed first (issue #832).
|
||||
unlock, err := lockMCPConfigFile(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
// A failed release leaves the lock held for the rest of the process, so
|
||||
// exiting success here would claim a state the next config write cannot
|
||||
// reproduce. It must not mask a failure this command already reported.
|
||||
if releaseErr := unlock(); releaseErr != nil && exitCode == exitSuccess {
|
||||
exitCode = writeAppError(stderr, redaction.ErrorMessage(releaseErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
}()
|
||||
cfg, err := readMCPWritableConfig(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
@@ -145,6 +191,10 @@ func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
}
|
||||
if err := unlock(); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
released = true
|
||||
|
||||
if options.json {
|
||||
payload := struct {
|
||||
@@ -167,7 +217,7 @@ func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
|
||||
func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) (exitCode int) {
|
||||
commandName := "enable"
|
||||
if disabled {
|
||||
commandName = "disable"
|
||||
@@ -194,6 +244,26 @@ func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
|
||||
}
|
||||
// This edits the same user config document the config package mutates, with
|
||||
// the same read-modify-write + rename shape, so it takes the same
|
||||
// cross-process lock. Without it, an MCP config edit racing a provider or
|
||||
// preference write would silently drop whichever landed first (issue #832).
|
||||
unlock, err := lockMCPConfigFile(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
// A failed release leaves the lock held for the rest of the process, so
|
||||
// exiting success here would claim a state the next config write cannot
|
||||
// reproduce. It must not mask a failure this command already reported.
|
||||
if releaseErr := unlock(); releaseErr != nil && exitCode == exitSuccess {
|
||||
exitCode = writeAppError(stderr, redaction.ErrorMessage(releaseErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
}()
|
||||
cfg, err := readMCPWritableConfig(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
@@ -210,6 +280,10 @@ func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
}
|
||||
if err := unlock(); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
released = true
|
||||
|
||||
if options.json {
|
||||
payload := struct {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
type mcpConfigLockCase struct {
|
||||
name string
|
||||
args []string
|
||||
disabled bool
|
||||
server bool
|
||||
}
|
||||
|
||||
func mcpConfigLockCases() []mcpConfigLockCase {
|
||||
return []mcpConfigLockCase{
|
||||
{name: "add", args: []string{"mcp", "add", "docs", "--", "docs-mcp"}},
|
||||
{name: "remove", args: []string{"mcp", "remove", "docs"}, server: true},
|
||||
{name: "enable", args: []string{"mcp", "enable", "docs"}, server: true, disabled: true},
|
||||
{name: "disable", args: []string{"mcp", "disable", "docs"}, server: true},
|
||||
}
|
||||
}
|
||||
|
||||
func seedMCPConfigLockCase(t *testing.T, path string, testCase mcpConfigLockCase) {
|
||||
t.Helper()
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if testCase.server {
|
||||
servers["docs"] = config.MCPServerConfig{Type: "stdio", Command: "docs-mcp", Disabled: testCase.disabled}
|
||||
}
|
||||
writeMCPCommandConfig(t, path, config.FileConfig{
|
||||
ActiveProvider: "seed",
|
||||
Providers: []config.ProviderProfile{{Name: "seed", Model: "seed-model"}},
|
||||
MCP: config.MCPConfig{Servers: servers},
|
||||
})
|
||||
}
|
||||
|
||||
func assertMCPConfigLockMutation(t *testing.T, path string, testCase mcpConfigLockCase) {
|
||||
t.Helper()
|
||||
cfg := readMCPCommandConfig(t, path)
|
||||
server, exists := cfg.MCP.Servers["docs"]
|
||||
switch testCase.name {
|
||||
case "add":
|
||||
if !exists {
|
||||
t.Fatal("mcp add did not persist the server")
|
||||
}
|
||||
case "remove":
|
||||
if exists {
|
||||
t.Fatal("mcp remove left the server configured")
|
||||
}
|
||||
case "enable":
|
||||
if !exists || server.Disabled {
|
||||
t.Fatalf("mcp enable result = %+v, exists=%v", server, exists)
|
||||
}
|
||||
case "disable":
|
||||
if !exists || !server.Disabled {
|
||||
t.Fatalf("mcp disable result = %+v, exists=%v", server, exists)
|
||||
}
|
||||
}
|
||||
if cfg.Preferences.Theme != "dracula" {
|
||||
t.Errorf("theme update was lost: theme = %q, want dracula", cfg.Preferences.Theme)
|
||||
}
|
||||
if cfg.ActiveProvider != "seed" || len(cfg.Providers) != 1 || cfg.Providers[0].Name != "seed" {
|
||||
t.Errorf("seeded provider was lost: active=%q providers=%#v", cfg.ActiveProvider, cfg.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPConfigCommandsParticipateInConfigLock covers the half of issue #832
|
||||
// that lives outside internal/config. MCP config commands read the SAME user
|
||||
// config document, edit it, and republish it with the same temp-file+rename
|
||||
// shape as the config package's mutators. Locking only the config package would
|
||||
// leave these writers free to clobber a concurrent provider or preference
|
||||
// update, and be clobbered by one, with the file still valid JSON afterwards.
|
||||
//
|
||||
// Racing the two writers and hoping to observe a lost update is unreliable —
|
||||
// the interleaving that loses one is narrow, and the test passed consistently
|
||||
// against the unlocked code. So this asserts the property that actually matters
|
||||
// and is deterministic: while the config lock is held elsewhere, the MCP
|
||||
// writer's update CANNOT land. Once released it completes, and both updates
|
||||
// survive.
|
||||
func TestRunMCPConfigCommandsParticipateInConfigLock(t *testing.T) {
|
||||
for _, testCase := range mcpConfigLockCases() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
seedMCPConfigLockCase(t, configPath, testCase)
|
||||
before, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unlock, err := config.LockFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire config lock: %v", err)
|
||||
}
|
||||
released := false
|
||||
release := func() {
|
||||
if !released {
|
||||
released = true
|
||||
if err := unlock(); err != nil {
|
||||
t.Fatalf("release config lock: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
defer release()
|
||||
|
||||
original := lockMCPConfigFile
|
||||
lockAttempted := make(chan struct{})
|
||||
lockMCPConfigFile = func(path string) (func() error, error) {
|
||||
close(lockAttempted)
|
||||
return original(path)
|
||||
}
|
||||
t.Cleanup(func() { lockMCPConfigFile = original })
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runWithDeps(testCase.args, &stdout, &stderr, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-lockAttempted:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatalf("mcp %s did not attempt config lock acquisition", testCase.name)
|
||||
}
|
||||
|
||||
for range 20 {
|
||||
after, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatalf("mcp %s changed config while its lock was held", testCase.name)
|
||||
}
|
||||
select {
|
||||
case exitCode := <-done:
|
||||
t.Fatalf("mcp %s completed (exit %d) while its lock was held", testCase.name, exitCode)
|
||||
default:
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
release()
|
||||
select {
|
||||
case exitCode := <-done:
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("mcp %s exitCode = %d stderr=%s", testCase.name, exitCode, stderr.String())
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatalf("mcp %s did not finish after the config lock was released", testCase.name)
|
||||
}
|
||||
|
||||
if _, err := config.SetTheme(configPath, "dracula"); err != nil {
|
||||
t.Fatalf("SetTheme: %v", err)
|
||||
}
|
||||
assertMCPConfigLockMutation(t, configPath, testCase)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMCPConfigCommandsReportLockAcquisitionFailure(t *testing.T) {
|
||||
sentinel := errors.New("injected lock acquisition failure")
|
||||
original := lockMCPConfigFile
|
||||
lockMCPConfigFile = func(string) (func() error, error) { return nil, sentinel }
|
||||
t.Cleanup(func() { lockMCPConfigFile = original })
|
||||
|
||||
for _, testCase := range mcpConfigLockCases() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
seedMCPConfigLockCase(t, configPath, testCase)
|
||||
before, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps(testCase.args, &stdout, &stderr, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
})
|
||||
if exitCode != exitCrash || stdout.Len() != 0 || !strings.Contains(stderr.String(), sentinel.Error()) {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q, want lock error without success output", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
after, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatal("command changed config after lock acquisition failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMCPConfigCommandsReleaseBeforeSuccessOutput(t *testing.T) {
|
||||
sentinel := errors.New("injected lock release failure")
|
||||
original := lockMCPConfigFile
|
||||
lockMCPConfigFile = func(string) (func() error, error) {
|
||||
return func() error { return sentinel }, nil
|
||||
}
|
||||
t.Cleanup(func() { lockMCPConfigFile = original })
|
||||
|
||||
for _, testCase := range mcpConfigLockCases() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
seedMCPConfigLockCase(t, configPath, testCase)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps(testCase.args, &stdout, &stderr, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
})
|
||||
if exitCode != exitCrash || stdout.Len() != 0 || !strings.Contains(stderr.String(), sentinel.Error()) {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q, want unlock error before success output", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
optionalMCPPromptGrace = time.Second
|
||||
optionalMCPCloseGrace = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// splitMCPStartupConfig keeps explicitly configured servers on the
|
||||
// prompt-critical path. Only unchanged built-in defaults may initialize after
|
||||
// first paint; their failure was already intentionally silent.
|
||||
func splitMCPStartupConfig(cfg config.MCPConfig) (critical config.MCPConfig, optional config.MCPConfig) {
|
||||
critical.Servers = map[string]config.MCPServerConfig{}
|
||||
optional.Servers = map[string]config.MCPServerConfig{}
|
||||
for name, server := range cfg.Servers {
|
||||
if server.Disabled {
|
||||
continue
|
||||
}
|
||||
if config.IsUnconfiguredDefault(name, server) {
|
||||
optional.Servers[name] = server
|
||||
continue
|
||||
}
|
||||
critical.Servers[name] = server
|
||||
}
|
||||
return critical, optional
|
||||
}
|
||||
|
||||
type optionalMCPStartup struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
runtime mcpToolRuntime
|
||||
|
||||
closeOnce sync.Once
|
||||
runtimeCloseOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func startOptionalMCP(
|
||||
parent context.Context,
|
||||
registry *tools.Registry,
|
||||
cfg config.MCPConfig,
|
||||
options mcp.RegisterOptions,
|
||||
register func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error),
|
||||
onReady func(),
|
||||
) *optionalMCPStartup {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
startup := &optionalMCPStartup{cancel: cancel, done: make(chan struct{})}
|
||||
go func() {
|
||||
runtime, err := register(ctx, registry, cfg, options)
|
||||
if err == nil && onReady != nil {
|
||||
onReady()
|
||||
}
|
||||
startup.mu.Lock()
|
||||
startup.runtime = runtime
|
||||
startup.mu.Unlock()
|
||||
close(startup.done)
|
||||
}()
|
||||
return startup
|
||||
}
|
||||
|
||||
// Await gives optional tools one bounded opportunity to join the next turn's
|
||||
// immutable registry snapshot. A timeout leaves this turn on the existing core
|
||||
// tool set; a later turn observes the completed generation.
|
||||
func (startup *optionalMCPStartup) Await(ctx context.Context, timeout time.Duration) bool {
|
||||
if startup == nil {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
default:
|
||||
}
|
||||
if timeout <= 0 {
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) Close() error {
|
||||
if startup == nil {
|
||||
return nil
|
||||
}
|
||||
startup.closeOnce.Do(func() {
|
||||
startup.cancel()
|
||||
reaped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(reaped)
|
||||
<-startup.done
|
||||
startup.closeRuntime()
|
||||
}()
|
||||
timer := time.NewTimer(optionalMCPCloseGrace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-reaped:
|
||||
case <-timer.C:
|
||||
}
|
||||
})
|
||||
startup.mu.Lock()
|
||||
defer startup.mu.Unlock()
|
||||
return startup.closeErr
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) closeRuntime() {
|
||||
startup.runtimeCloseOnce.Do(func() {
|
||||
startup.mu.Lock()
|
||||
runtime := startup.runtime
|
||||
startup.mu.Unlock()
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
err := runtime.Close()
|
||||
startup.mu.Lock()
|
||||
if err != nil && startup.closeErr == nil {
|
||||
startup.closeErr = err
|
||||
}
|
||||
startup.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type closeTrackingMCPRuntime struct {
|
||||
closed chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func (runtime *closeTrackingMCPRuntime) Close() error {
|
||||
close(runtime.closed)
|
||||
return runtime.err
|
||||
}
|
||||
|
||||
func (*closeTrackingMCPRuntime) Skipped() []mcp.SkippedServer { return nil }
|
||||
|
||||
func TestSplitMCPStartupConfigDefersOnlyUnconfiguredDefaults(t *testing.T) {
|
||||
defaults := config.DefaultMCPServers()
|
||||
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"exa": defaults["exa"],
|
||||
"docs": {Type: "http", URL: "https://docs.example.test/mcp"},
|
||||
"disabled": {Type: "http", URL: "https://disabled.example.test/mcp", Disabled: true},
|
||||
}}
|
||||
|
||||
critical, optional := splitMCPStartupConfig(cfg)
|
||||
if _, ok := critical.Servers["docs"]; !ok || len(critical.Servers) != 1 {
|
||||
t.Fatalf("critical servers = %#v, want only docs", critical.Servers)
|
||||
}
|
||||
if _, ok := optional.Servers["exa"]; !ok || len(optional.Servers) != 1 {
|
||||
t.Fatalf("optional servers = %#v, want only exa", optional.Servers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPAwaitTimesOutThenObservesPublishedTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{})
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
registry,
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(ctx context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
registry.RegisterBatch([]tools.Tool{tools.NewScopedReadFileTool(t.TempDir(), nil)})
|
||||
return noopMCPRuntime{}, nil
|
||||
case <-ctx.Done():
|
||||
return noopMCPRuntime{}, ctx.Err()
|
||||
}
|
||||
},
|
||||
nil,
|
||||
)
|
||||
defer func() { _ = startup.Close() }()
|
||||
<-started
|
||||
|
||||
if startup.Await(t.Context(), time.Millisecond) {
|
||||
t.Fatal("optional startup unexpectedly completed before release")
|
||||
}
|
||||
if got := len(registry.All()); got != 0 {
|
||||
t.Fatalf("registry published tools before readiness: %d", got)
|
||||
}
|
||||
close(release)
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("optional startup did not complete after release")
|
||||
}
|
||||
if _, ok := registry.Get("read_file"); !ok {
|
||||
t.Fatal("completed optional startup did not publish its tool batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPFailedRegistrationSkipsReadinessCallback(t *testing.T) {
|
||||
readyCalls := 0
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, errors.New("connect failed")
|
||||
},
|
||||
func() { readyCalls++ },
|
||||
)
|
||||
defer func() { _ = startup.Close() }()
|
||||
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("failed optional startup did not settle")
|
||||
}
|
||||
if readyCalls != 0 {
|
||||
t.Fatalf("readiness callback ran after failed registration: %d", readyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseCancelsBlockedStartup(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(ctx context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
<-started
|
||||
if err := startup.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseReturnsRuntimeErrorWithinGrace(t *testing.T) {
|
||||
closeErr := errors.New("close failed")
|
||||
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{}), err: closeErr}
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return runtime, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("optional startup did not settle")
|
||||
}
|
||||
if err := startup.Close(); !errors.Is(err, closeErr) {
|
||||
t.Fatalf("Close() error = %v, want %v", err, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseBoundsRegistrationThatIgnoresCancellation(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
close(release)
|
||||
}
|
||||
}()
|
||||
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{})}
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return runtime, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
<-started
|
||||
|
||||
closeResult := make(chan error, 1)
|
||||
go func() { closeResult <- startup.Close() }()
|
||||
select {
|
||||
case err := <-closeResult:
|
||||
if err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close blocked on registration after cancellation")
|
||||
}
|
||||
|
||||
close(release)
|
||||
released = true
|
||||
select {
|
||||
case <-runtime.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("late optional MCP runtime was not closed")
|
||||
}
|
||||
}
|
||||
+12
-10
@@ -31,6 +31,14 @@ type mcpToolListItem struct {
|
||||
// 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, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
|
||||
options := mcp.RegisterOptions{}
|
||||
if len(runners) > 0 {
|
||||
options.Execution = runners[0]
|
||||
}
|
||||
return registerMCPToolsForWorkspaceWithOptions(ctx, workspaceRoot, registry, deps, autonomy, trustRoot, options)
|
||||
}
|
||||
|
||||
func registerMCPToolsForWorkspaceWithOptions(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, options mcp.RegisterOptions) (mcpToolRuntime, trustSkip, error) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
|
||||
@@ -47,16 +55,10 @@ 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,
|
||||
})
|
||||
options.PermissionStore = store
|
||||
options.Autonomy = autonomy
|
||||
options.WorkspaceRoot = workspaceRoot
|
||||
runtime, err := deps.registerMCPTools(ctx, registry, cfg, options)
|
||||
return runtime, skip, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
"mvdan.cc/sh/v3/shell"
|
||||
)
|
||||
|
||||
func TestLocalAdoptionSavesAndReloadsEligibleModel(t *testing.T) {
|
||||
const wantModel = "-loaded chat model"
|
||||
requests := make(chan string, 4)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/models":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"data":[{"id":"text-embedding-local"},{"id":%q}]}`, wantModel)
|
||||
case "/v1/chat/completions":
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode completion request: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Error("local adoption unexpectedly sent authorization")
|
||||
}
|
||||
select {
|
||||
case requests <- body.Model:
|
||||
default:
|
||||
t.Error("unexpected extra completion requests")
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"adoption ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
detected := provideronboarding.DetectLocalRuntimes(context.Background(), provideronboarding.LocalDetectOptions{
|
||||
HTTPClient: server.Client(),
|
||||
Candidates: []provideronboarding.LocalRuntime{{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model", BaseURL: server.URL + "/v1"}},
|
||||
})
|
||||
if len(detected) != 1 {
|
||||
t.Fatalf("expected one detected runtime: %+v", detected)
|
||||
}
|
||||
args, err := shell.Fields(detected[0].SetupAction().Command, func(string) string { return "" })
|
||||
if err != nil || len(args) < 4 {
|
||||
t.Fatalf("invalid adoption command: args=%q err=%v", args, err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
run := func(args ...string) string {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
childArgs := append([]string{"-test.run=^TestLocalAdoptionCLIProcess$", "--"}, args...)
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], childArgs...)
|
||||
cmd.Dir = root
|
||||
cmd.Env = []string{
|
||||
"ZERO_TEST_LOCAL_ADOPTION=1", "ZERO_CRED_STORAGE=encrypted-file",
|
||||
"HOME=" + root, "USERPROFILE=" + root,
|
||||
"XDG_CONFIG_HOME=" + filepath.Join(root, "config"),
|
||||
"XDG_CACHE_HOME=" + filepath.Join(root, "cache"),
|
||||
"XDG_STATE_HOME=" + filepath.Join(root, "state"),
|
||||
"APPDATA=" + filepath.Join(root, "config"),
|
||||
"LOCALAPPDATA=" + filepath.Join(root, "cache"),
|
||||
}
|
||||
for _, key := range []string{"PATH", "SystemRoot", "WINDIR", "TMPDIR", "TEMP", "TMP"} {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
cmd.Env = append(cmd.Env, key+"="+value)
|
||||
}
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("CLI %q failed: %v\n%s", args, err, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
// The mock uses a random port; only override the endpoint, retaining all
|
||||
// generated arguments for the real add parser and persistence path.
|
||||
run(append(args[1:], "--base-url", server.URL+"/v1")...)
|
||||
if output := run("exec", "--cwd", root, "Reply with adoption ok"); !strings.Contains(output, "adoption ok") {
|
||||
t.Fatalf("completion output = %q", output)
|
||||
}
|
||||
select {
|
||||
case model := <-requests:
|
||||
if model != wantModel {
|
||||
t.Fatalf("fresh process sent model %q, want eligible model %q", model, wantModel)
|
||||
}
|
||||
default:
|
||||
t.Fatal("fresh process did not send a completion request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAdoptionCLIProcess(t *testing.T) {
|
||||
if os.Getenv("ZERO_TEST_LOCAL_ADOPTION") != "1" {
|
||||
return
|
||||
}
|
||||
for i, arg := range os.Args {
|
||||
if arg == "--" {
|
||||
os.Exit(Run(os.Args[i+1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
}
|
||||
t.Fatal("missing helper argument separator")
|
||||
}
|
||||
@@ -40,7 +40,7 @@ type providerDetectReport struct {
|
||||
}
|
||||
|
||||
// runProvidersDetect probes the machine for running local, OpenAI-compatible
|
||||
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
|
||||
// model runtimes (Ollama, LM Studio, Atomic Chat) and prints a no-key adopt command for each
|
||||
// one it finds, followed by the next-step actions for every already-configured
|
||||
// provider. It is the onboarding-advice surface — "what can I do right now?" —
|
||||
// and never errors on a machine with nothing running locally (it just reports an
|
||||
@@ -146,6 +146,8 @@ func formatProviderDetectReport(report providerDetectReport) string {
|
||||
}
|
||||
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
|
||||
lines = append(lines, " "+runtime.Action.Label+": "+command)
|
||||
} else if detail := strings.TrimSpace(runtime.Action.Detail); detail != "" {
|
||||
lines = append(lines, " "+runtime.Action.Label+": "+detail)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,3 +105,13 @@ func TestRunProvidersDetectJSONNoRuntimesActiveProvider(t *testing.T) {
|
||||
t.Fatalf("expected only a Check action for an active keyed provider, got %#v", payload.Providers[0].Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDetectShowsGuidanceWithoutCommand(t *testing.T) {
|
||||
for _, models := range [][]string{nil, {"local-model"}, {"x&calc"}} {
|
||||
runtime := provideronboarding.DetectedLocalRuntime{LocalRuntime: provideronboarding.LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model"}, Models: models}
|
||||
out := formatProviderDetectReport(buildProviderDetectReport(config.ResolvedConfig{}, []provideronboarding.DetectedLocalRuntime{runtime}))
|
||||
if strings.Contains(out, "providers add") || !strings.Contains(out, runtime.SetupAction().Detail) {
|
||||
t.Fatalf("missing actionable guidance: %s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,6 +434,14 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile,
|
||||
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
|
||||
}
|
||||
}
|
||||
// Discovery fallback can pass the nonempty catalog placeholder through
|
||||
// either CLI setup path. Never persist it as an Atomic Chat model.
|
||||
if descriptor.ID == "atomic-chat-local" {
|
||||
model := strings.TrimSpace(options.model)
|
||||
if model == "" || model == "local-model" {
|
||||
return config.ProviderProfile{}, fmt.Errorf("provider %q serves a locally loaded model; pass --model <id> (run `zero providers detect` to see the served model)", descriptor.ID)
|
||||
}
|
||||
}
|
||||
profile := config.ProviderProfile{
|
||||
Name: name,
|
||||
ProviderKind: providerKindForDescriptor(descriptor),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
"mvdan.cc/sh/v3/shell"
|
||||
)
|
||||
|
||||
// Regression for issue #555's follow-up: `zero providers check` must not
|
||||
@@ -66,3 +69,58 @@ func TestValidateProviderRuntimeReadyCustomEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// atomic-chat-local without --model would persist the catalog placeholder
|
||||
// "local-model", which the Atomic Chat server never serves, so the first
|
||||
// completion fails. Adding it must require a real model instead.
|
||||
func TestProviderProfileForAddRequiresModelForAtomicChatLocal(t *testing.T) {
|
||||
if _, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local"}); err == nil {
|
||||
t.Fatalf("providerProfileForAdd(atomic-chat-local, no --model) = nil error, want a require-model error")
|
||||
} else if !strings.Contains(err.Error(), "--model") {
|
||||
t.Fatalf("error should tell the user to pass --model, got %v", err)
|
||||
}
|
||||
|
||||
// The interactive wizards and the no-id detect fallback resolve the model to
|
||||
// the catalog DefaultModel and pass it through as a non-empty value, so the
|
||||
// placeholder itself must be rejected, not just an empty --model.
|
||||
if _, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local", model: "local-model"}); err == nil {
|
||||
t.Fatalf("providerProfileForAdd(atomic-chat-local, --model local-model) = nil error, want reject of the catalog placeholder")
|
||||
}
|
||||
|
||||
profile, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local", model: "unsloth/gemma-4-E2B-it-GGUF"})
|
||||
if err != nil {
|
||||
t.Fatalf("providerProfileForAdd(atomic-chat-local, --model) returned error: %v", err)
|
||||
}
|
||||
if profile.Model != "unsloth/gemma-4-E2B-it-GGUF" {
|
||||
t.Fatalf("profile.Model = %q, want the explicit model", profile.Model)
|
||||
}
|
||||
if profile.Model == "local-model" {
|
||||
t.Fatalf("profile persisted the catalog placeholder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectedModelActionSurvivesAddParser(t *testing.T) {
|
||||
for _, catalogID := range []string{"atomic-chat-local", "lmstudio", "ollama"} {
|
||||
for _, modelID := range []string{"-loaded-model", "--set-active", "--model", "-loaded model", "ordinary/model", "model with spaces"} {
|
||||
t.Run(catalogID+"/"+modelID, func(t *testing.T) {
|
||||
detected := provideronboarding.DetectedLocalRuntime{
|
||||
LocalRuntime: provideronboarding.LocalRuntime{CatalogID: catalogID, Name: "Local Runtime", DefaultModel: "local-model"},
|
||||
Models: []string{modelID},
|
||||
}
|
||||
command := detected.SetupAction().Command
|
||||
args, err := shell.Fields(command, func(string) string { return "" })
|
||||
if err != nil || len(args) < 4 {
|
||||
t.Fatalf("invalid adoption command %q: %v", command, err)
|
||||
}
|
||||
options, help, err := parseProviderAddArgs(args[3:])
|
||||
if err != nil || help {
|
||||
t.Fatalf("generated adoption command rejected by add parser: %q: %v", command, err)
|
||||
}
|
||||
profile, err := providerProfileForAdd(options)
|
||||
if err != nil || profile.Model != modelID || !options.setActive || profile.Name != "Local Runtime" {
|
||||
t.Fatalf("adoption changed model or options: model=%q name=%q active=%v err=%v", profile.Model, profile.Name, options.setActive, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// rootFlagOrderings is every ordering of the given flag groups, each group
|
||||
// kept intact so a flag stays next to its value.
|
||||
func rootFlagOrderings(groups [][]string) [][]string {
|
||||
if len(groups) == 0 {
|
||||
return [][]string{{}}
|
||||
}
|
||||
var orderings [][]string
|
||||
for index, group := range groups {
|
||||
rest := append(append([][]string{}, groups[:index]...), groups[index+1:]...)
|
||||
for _, tail := range rootFlagOrderings(rest) {
|
||||
orderings = append(orderings, append(append([]string{}, group...), tail...))
|
||||
}
|
||||
}
|
||||
return orderings
|
||||
}
|
||||
|
||||
// ROOT FLAGS COMPOSE IN ANY ORDER. Each leading-flag splitter stops at the first
|
||||
// token it does not own, so running them once in a fixed sequence made the
|
||||
// order the operator wrote them in load-bearing: `zero --allow-escalation
|
||||
// --theme auto` stranded `--theme auto` as an unknown command and exited with
|
||||
// an argument error instead of launching. Every ordering of the three root
|
||||
// flags, with --skip-permissions-unsafe absent or at any position, has to reach
|
||||
// the TUI with all three applied.
|
||||
func TestRootFlagsComposeInAnyOrder(t *testing.T) {
|
||||
extra := t.TempDir()
|
||||
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groups := [][]string{{"--add-dir", extra}, {"--theme", "auto"}, {"--allow-escalation"}}
|
||||
cases := rootFlagOrderings(groups)
|
||||
cases = append(cases, rootFlagOrderings(append(groups, []string{"--skip-permissions-unsafe"}))...)
|
||||
if len(cases) != 6+24 {
|
||||
t.Fatalf("SETUP INVALID: %d orderings, want 30", len(cases))
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
options := captureTUIOptions(t, args...)
|
||||
wantMode := agent.PermissionModeAsk
|
||||
for _, arg := range args {
|
||||
if arg == "--skip-permissions-unsafe" {
|
||||
wantMode = agent.PermissionModeUnsafe
|
||||
}
|
||||
}
|
||||
if options.PermissionMode != wantMode {
|
||||
t.Errorf("PermissionMode = %q, want %q", options.PermissionMode, wantMode)
|
||||
}
|
||||
if !options.AllowEscalation {
|
||||
t.Error("escalation not enabled")
|
||||
}
|
||||
if options.Theme != "auto" {
|
||||
t.Errorf("Theme = %q, want auto", options.Theme)
|
||||
}
|
||||
if options.AgentOptions.Sandbox == nil {
|
||||
t.Fatal("no sandbox engine on the launched options")
|
||||
}
|
||||
roots := options.AgentOptions.Sandbox.Scope().Roots()
|
||||
found := false
|
||||
for _, root := range roots {
|
||||
if root == resolvedExtra {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("scope roots = %v, want the --add-dir root %q", roots, resolvedExtra)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The last --theme wins even with another root flag between two of them, and a
|
||||
// --theme written before --skip-permissions-unsafe reaches the TUI: the unsafe
|
||||
// path used to launch with only the theme written after the flag, dropping one
|
||||
// written before it.
|
||||
func TestRootThemeLastOccurrenceWinsAcrossOtherFlags(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"--theme", "light", "--allow-escalation", "--theme", "auto"}, "auto"},
|
||||
{[]string{"--theme", "auto", "--skip-permissions-unsafe"}, "auto"},
|
||||
{[]string{"--theme", "light", "--skip-permissions-unsafe", "--theme", "auto"}, "auto"},
|
||||
} {
|
||||
t.Run(strings.Join(testCase.args, " "), func(t *testing.T) {
|
||||
options := captureTUIOptions(t, testCase.args...)
|
||||
if options.Theme != testCase.want {
|
||||
t.Fatalf("Theme = %q, want %q", options.Theme, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// THE ROOT FLAG REACHES EXEC. A --allow-escalation written before the
|
||||
// subcommand is forwarded the way --add-dir is, so `zero --allow-escalation -p
|
||||
// "..."` runs with the opt-in the help text promises instead of silently
|
||||
// without it. The negative control is the same run without the flag, which
|
||||
// must not advertise escalate_model.
|
||||
func TestRootAllowEscalationForwardsIntoExec(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"exec subcommand", []string{"--allow-escalation", "exec", "say hi"}},
|
||||
{"prompt flag", []string{"--allow-escalation", "-p", "say hi"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if !execAdvertisesEscalateModel(t, testCase.args) {
|
||||
t.Fatalf("%v ran without escalate_model advertised: the root flag was dropped before exec", testCase.args)
|
||||
}
|
||||
if execAdvertisesEscalateModel(t, testCase.args[1:]) {
|
||||
t.Fatalf("%v advertised escalate_model without the flag", testCase.args[1:])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Everywhere else the flag is rejected loudly rather than discarded, the way
|
||||
// --add-dir is: help and version run no agent and could only ignore it.
|
||||
func TestRootAllowEscalationIsRejectedWhereNothingConsumesIt(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--allow-escalation", "version"},
|
||||
{"--allow-escalation", "help"},
|
||||
{"--allow-escalation", "completions", "bash"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
launched := false
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
runTUI: func(context.Context, tui.Options) int { launched = true; return exitSuccess },
|
||||
})
|
||||
if exitCode == exitSuccess || launched {
|
||||
t.Fatalf("exit = %d launched = %v: the flag was discarded instead of rejected", exitCode, launched)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--allow-escalation is only supported for the interactive TUI and exec") {
|
||||
t.Fatalf("stderr does not name the flag: %s", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// execAdvertisesEscalateModel dispatches args through the root command with a
|
||||
// provider that records the tools advertised on the first request, and reports
|
||||
// whether escalate_model was among them.
|
||||
func execAdvertisesEscalateModel(t *testing.T, args []string) bool {
|
||||
t.Helper()
|
||||
isolateCLIUserState(t)
|
||||
provider := &toolListingProvider{}
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return provider, nil
|
||||
},
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
||||
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("%v: exit = %d, stderr = %s", args, exitCode, stderr.String())
|
||||
}
|
||||
if provider.requests == 0 {
|
||||
t.Fatalf("SETUP INVALID: %v never reached the provider", args)
|
||||
}
|
||||
for _, name := range provider.toolNames {
|
||||
if name == "escalate_model" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolListingProvider answers every request with a one-line text reply and
|
||||
// keeps the tool names advertised on the first one.
|
||||
type toolListingProvider struct {
|
||||
requests int
|
||||
toolNames []string
|
||||
}
|
||||
|
||||
func (provider *toolListingProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.requests++
|
||||
if provider.requests == 1 {
|
||||
for _, tool := range request.Tools {
|
||||
provider.toolNames = append(provider.toolNames, tool.Name)
|
||||
}
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 2)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "hi"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
@@ -168,11 +168,18 @@ func TestBuildServeScopeKeepsLexicalPaths(t *testing.T) {
|
||||
}
|
||||
linkWorkspace := filepath.Join(base, "link-workspace")
|
||||
linkExtra := filepath.Join(base, "link-extra")
|
||||
// SKIPPED, NOT FAILED, WHEN THE PLATFORM WILL NOT MAKE THE LINK. Creating a
|
||||
// symlink on Windows needs SeCreateSymbolicLinkPrivilege, which an ordinary
|
||||
// developer account does not hold, so this reported a failure with nothing
|
||||
// wrong in the code: "A required privilege is not held by the client". CI
|
||||
// runners are elevated, so it only ever showed up locally, where it cost a
|
||||
// round of triage before being ruled environmental. The sibling tests that
|
||||
// need a link already answer this way.
|
||||
if err := os.Symlink(realWorkspace, linkWorkspace); err != nil {
|
||||
t.Fatal(err)
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink(realExtra, linkExtra); err != nil {
|
||||
t.Fatal(err)
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
|
||||
scope, err := buildServeScope(linkWorkspace, []string{linkExtra})
|
||||
|
||||
@@ -42,7 +42,7 @@ func runSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
|
||||
return exitSuccess
|
||||
}
|
||||
if strings.TrimSpace(options.catalogID) == "" {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, "", nil, "", true)
|
||||
return runInteractiveTUIWithSetup(stderr, deps, "", nil, "", true, false)
|
||||
}
|
||||
|
||||
result, err := saveSetupProvider(deps, tui.SetupSelection{
|
||||
|
||||
@@ -367,3 +367,14 @@ func TestVerifySetupProviderDistinguishesMissingFromRejectedKey(t *testing.T) {
|
||||
t.Fatal("a keyless local provider should still be probed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderRejectsAtomicPlaceholderBeforeConfigAccess(t *testing.T) {
|
||||
for _, model := range []string{"", "local-model", " local-model "} {
|
||||
accessed := false
|
||||
deps := appDeps{userConfigPath: func() (string, error) { accessed = true; return filepath.Join(t.TempDir(), "config.json"), nil }}
|
||||
_, err := saveSetupProvider(deps, tui.SetupSelection{CatalogID: "atomic-chat-local", Model: model}, setupSaveOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "--model") || accessed {
|
||||
t.Fatalf("invalid model %q reached config access: accessed=%v err=%v", model, accessed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// summarizerFactory adapts the resolved profile and the authenticated provider
|
||||
// builder into agent.Options.Summarizer; the selection rules live in
|
||||
// providers.CompactionSummarizerFactory, shared with the TUI.
|
||||
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
|
||||
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
)
|
||||
|
||||
// captureTUIOptions runs the root command with the TUI launch intercepted, and
|
||||
// returns the options the interactive session would have started with.
|
||||
func captureTUIOptions(t *testing.T, args ...string) tui.Options {
|
||||
t.Helper()
|
||||
isolateCLIUserState(t)
|
||||
var captured tui.Options
|
||||
var stdout, stderr bytes.Buffer
|
||||
workspace := t.TempDir()
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
captured = options
|
||||
return exitSuccess
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
return captured
|
||||
}
|
||||
|
||||
func hasEscalateModel(options tui.Options) bool {
|
||||
if options.AgentOptions.Registry == nil {
|
||||
return false
|
||||
}
|
||||
_, registered := options.AgentOptions.Registry.Get("escalate_model")
|
||||
return registered
|
||||
}
|
||||
|
||||
// ESCALATION IS OFF UNLESS THE OPERATOR ASKS FOR IT.
|
||||
//
|
||||
// Escalation moves a run onto a different model, changing what it costs and which
|
||||
// provider sees the conversation, so the interactive surface answers this the same
|
||||
// conservative way `zero exec --allow-escalation` already does.
|
||||
func TestInteractiveTUIRegistersEscalateModelOnlyWithTheFlag(t *testing.T) {
|
||||
if hasEscalateModel(captureTUIOptions(t)) {
|
||||
t.Error("escalate_model was registered without --allow-escalation")
|
||||
}
|
||||
if !hasEscalateModel(captureTUIOptions(t, "--allow-escalation")) {
|
||||
t.Error("--allow-escalation did not register escalate_model")
|
||||
}
|
||||
}
|
||||
|
||||
// THE TOOL AND THE SWITCHERS ARE ONE FEATURE, NOT TWO.
|
||||
//
|
||||
// This is the failure the whole change exists to avoid. The agent loop performs a
|
||||
// switch only when a switcher is wired, so registering escalate_model without
|
||||
// wiring one ships a tool the model can call and the loop will silently ignore:
|
||||
// the run reports an escalation that never happened. The reverse is merely dead
|
||||
// code. Both halves ride on the same flag and this asserts they cannot be
|
||||
// separated, whichever half a future change touches.
|
||||
func TestInteractiveTUIEscalationToolAndSwitchersAreWiredTogether(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "default", args: nil, want: false},
|
||||
{name: "flagged", args: []string{"--allow-escalation"}, want: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
options := captureTUIOptions(t, testCase.args...)
|
||||
if got := hasEscalateModel(options); got != testCase.want {
|
||||
t.Fatalf("escalate_model registered = %v, want %v", got, testCase.want)
|
||||
}
|
||||
if options.AllowEscalation != testCase.want {
|
||||
t.Errorf("AllowEscalation = %v, want %v: the tool is registered on one gate and the switchers on another, so they can drift apart",
|
||||
options.AllowEscalation, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The flag is accepted on either side of --skip-permissions-unsafe, like the
|
||||
// other root flags that may appear there.
|
||||
func TestInteractiveTUIAcceptsAllowEscalationAroundUnsafe(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--allow-escalation", "--skip-permissions-unsafe"},
|
||||
{"--skip-permissions-unsafe", "--allow-escalation"},
|
||||
} {
|
||||
options := captureTUIOptions(t, args...)
|
||||
if !options.AllowEscalation {
|
||||
t.Errorf("%v did not enable escalation", args)
|
||||
}
|
||||
if !hasEscalateModel(options) {
|
||||
t.Errorf("%v did not register escalate_model", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An =value form is a loud error rather than a silent enable, so a mistyped
|
||||
// --allow-escalation=false cannot turn the feature ON.
|
||||
func TestAllowEscalationRejectsAValue(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"--allow-escalation=false"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
runTUI: func(context.Context, tui.Options) int { return exitSuccess },
|
||||
})
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatal("--allow-escalation=false was accepted; a mistyped disable must not silently enable escalation")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--allow-escalation takes no value") {
|
||||
t.Errorf("stderr does not explain the flag: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpDocumentsAllowEscalation(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{}); exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "--allow-escalation") {
|
||||
t.Error("the root help does not mention --allow-escalation, so the flag is undiscoverable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// userStateEnvNames is every per-user base directory the root command can
|
||||
// resolve against, across platforms. One throwaway root behind all of them
|
||||
// lands the resolution inside it whichever branch it takes.
|
||||
var userStateEnvNames = []string{
|
||||
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME",
|
||||
}
|
||||
|
||||
// isolateCLIUserState points every user-scoped path the root command touches at
|
||||
// throwaway directories, and keeps credential work off the host keyring.
|
||||
//
|
||||
// A TEMPORARY WORKING DIRECTORY IS NOT ISOLATION. runWithDeps fills the
|
||||
// dependencies a test leaves out with the production ones, and the launch paths
|
||||
// read user config, open stores, refresh the models.dev cache and, for an
|
||||
// interactive run, migrate any inline plaintext API key into the credential
|
||||
// store before they ever reach an injected callback. Without this, running these
|
||||
// tests on a developer machine rewrites that developer's config.json and moves
|
||||
// their key into their keychain, makes a real network call, and reports results
|
||||
// that depend on whatever providers, MCP servers and plugins that machine has.
|
||||
func isolateCLIUserState(t *testing.T) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, name := range userStateEnvNames {
|
||||
t.Setenv(name, root)
|
||||
}
|
||||
// The models.dev overlay cache, and the background refresh that writes it:
|
||||
// a wiring test has no business making a network call or leaving a cache
|
||||
// file behind.
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(root, "modelsdev.json"))
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
|
||||
// Credentials resolve keyring-first. This keeps a migrated key in a file
|
||||
// under the fixture root rather than in the developer's OS keychain.
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
}
|
||||
|
||||
// AND THE ISOLATION IS ITSELF PINNED, ONE HELPER AT A TIME. A seeded config
|
||||
// outside the helper fixture roots stands in for a developer's real one: the
|
||||
// launch helper must leave it exactly as it found it, inline API key included,
|
||||
// must not write a credential file beside it, and must have moved the per-user
|
||||
// paths off that root before running anything.
|
||||
//
|
||||
// EACH HELPER GETS ITS OWN SUBTEST because t.Setenv restores at the end of the
|
||||
// test, not when the helper returns: exercising both in one test would let the
|
||||
// first helper isolation cover for a second helper that had none.
|
||||
func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
exercise func(*testing.T)
|
||||
}{
|
||||
{"captureTUIOptions", func(t *testing.T) { _ = captureTUIOptions(t, "--allow-escalation") }},
|
||||
{"execAdvertisesEscalateModel", func(t *testing.T) {
|
||||
_ = execAdvertisesEscalateModel(t, []string{"--allow-escalation", "exec", "say hi"})
|
||||
}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
seedRoot, configPath, seeded, before := seedEmulatedUserConfig(t)
|
||||
|
||||
testCase.exercise(t)
|
||||
|
||||
after, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != string(seeded) {
|
||||
t.Errorf("the seeded user config was rewritten:\n before: %s\n after: %s", seeded, after)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != len(before) {
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Errorf("the user config directory gained entries: %v", names)
|
||||
}
|
||||
|
||||
// The checks above catch what this platform happens to write. This
|
||||
// one catches the missing isolation itself, so a startup path that
|
||||
// writes somewhere else, or only sometimes, is covered too.
|
||||
resolved, err := config.DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.HasPrefix(resolved, seedRoot) {
|
||||
t.Errorf("user config still resolves inside the seeded root (%s): the helper installed no isolation", resolved)
|
||||
}
|
||||
if os.Getenv("ZERO_DISABLE_MODELS_FETCH") == "" {
|
||||
t.Error("the helper left the background models.dev refresh enabled, so this test can make a network call and write a cache file")
|
||||
}
|
||||
if os.Getenv("ZERO_CRED_STORAGE") == "" {
|
||||
t.Error("the helper left credential storage resolving keyring-first, so a migrated key can reach the host keychain")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// seedEmulatedUserConfig points the per-user base directories at a throwaway
|
||||
// root and writes a config carrying an inline plaintext API key there, which is
|
||||
// what the startup migration rewrites. It returns that root, the config path,
|
||||
// its bytes, and the directory listing to compare against.
|
||||
func seedEmulatedUserConfig(t *testing.T) (string, string, []byte, []os.DirEntry) {
|
||||
t.Helper()
|
||||
seedRoot := t.TempDir()
|
||||
for _, name := range userStateEnvNames {
|
||||
t.Setenv(name, seedRoot)
|
||||
}
|
||||
configPath, err := config.DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(configPath, seedRoot) {
|
||||
t.Fatalf("SETUP INVALID: user config resolved to %q, outside the seeded root %q", configPath, seedRoot)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeded := []byte(`{"providers":[{"name":"anthropic","providerKind":"anthropic","model":"claude-haiku-4.5","apiKey":"sk-seeded-plaintext-key"}]}`)
|
||||
if err := os.WriteFile(configPath, seeded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadDir(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return seedRoot, configPath, seeded, before
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func readTestConfig(t *testing.T, path string) FileConfig {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("invalid config JSON after concurrent writes: %v\n%s", err, data)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func seedTestConfig(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := UpsertProvider(path, ProviderProfile{Name: "seed", Model: "seed-model"}, true); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentMutationsDoNotLoseUpdates is the issue #832 regression. Each
|
||||
// mutator loads the whole document, edits one field, and publishes a complete
|
||||
// replacement by rename. Without a lock spanning load-through-publish, two
|
||||
// writers that loaded the same revision both write a full document and the
|
||||
// second rename silently discards the first one's acknowledged update.
|
||||
//
|
||||
// Every mutation below touches an INDEPENDENT field, so a correct
|
||||
// implementation ends with all of them present. A lost update shows up as a
|
||||
// zero value in exactly one of the fields, with valid JSON either way — which
|
||||
// is what made the bug silent.
|
||||
func TestConcurrentMutationsDoNotLoseUpdates(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
seedTestConfig(t, path)
|
||||
|
||||
mutations := []struct {
|
||||
name string
|
||||
apply func() error
|
||||
check func(FileConfig) error
|
||||
}{
|
||||
{
|
||||
name: "theme",
|
||||
apply: func() error { _, err := SetTheme(path, "dracula"); return err },
|
||||
check: func(cfg FileConfig) error {
|
||||
if cfg.Preferences.Theme != "dracula" {
|
||||
return fmt.Errorf("theme = %q, want dracula", cfg.Preferences.Theme)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pet",
|
||||
apply: func() error { _, err := SetPet(path, "otter"); return err },
|
||||
check: func(cfg FileConfig) error {
|
||||
if cfg.Preferences.Pet != "otter" {
|
||||
return fmt.Errorf("pet = %q, want otter", cfg.Preferences.Pet)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recaps",
|
||||
apply: func() error { _, err := SetRecapsEnabled(path, true); return err },
|
||||
check: func(cfg FileConfig) error {
|
||||
if cfg.Preferences.Recaps == nil || !*cfg.Preferences.Recaps {
|
||||
return fmt.Errorf("recaps = %v, want true", cfg.Preferences.Recaps)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "favorites",
|
||||
apply: func() error { _, err := SetFavoriteModels(path, []string{"fav-model"}); return err },
|
||||
check: func(cfg FileConfig) error {
|
||||
if len(cfg.Preferences.FavoriteModels) != 1 || cfg.Preferences.FavoriteModels[0] != "fav-model" {
|
||||
return fmt.Errorf("favorites = %v, want [fav-model]", cfg.Preferences.FavoriteModels)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provider",
|
||||
apply: func() error {
|
||||
_, err := UpsertProvider(path, ProviderProfile{Name: "added", Model: "added-model"}, false)
|
||||
return err
|
||||
},
|
||||
check: func(cfg FileConfig) error {
|
||||
for _, provider := range cfg.Providers {
|
||||
if provider.Name == "added" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("provider %q missing from %d providers", "added", len(cfg.Providers))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// SetPet edits the raw bytes rather than round-tripping the struct, so it is
|
||||
// included deliberately: it must take the same lock as the struct writers or
|
||||
// it would clobber, and be clobbered by, everything else.
|
||||
errs := make([]error, len(mutations))
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for index, mutation := range mutations {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs[index] = mutation.apply()
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for index, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("%s mutation failed: %v", mutations[index].name, err)
|
||||
}
|
||||
}
|
||||
cfg := readTestConfig(t, path)
|
||||
for _, mutation := range mutations {
|
||||
if err := mutation.check(cfg); err != nil {
|
||||
t.Errorf("%s update was lost: %v", mutation.name, err)
|
||||
}
|
||||
}
|
||||
if cfg.ActiveProvider != "seed" {
|
||||
t.Errorf("activeProvider = %q, want the seeded value preserved", cfg.ActiveProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentSameFieldMutationsSerialize proves the lock serializes writers
|
||||
// that contend on ONE field: every call must succeed and the file must end with
|
||||
// exactly one of the written values, never a merged or truncated document.
|
||||
func TestConcurrentSameFieldMutationsSerialize(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
seedTestConfig(t, path)
|
||||
|
||||
const writers = 24
|
||||
errs := make([]error, writers)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for index := range writers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, errs[index] = SetTheme(path, fmt.Sprintf("theme-%02d", index))
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for index, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("writer %d failed: %v", index, err)
|
||||
}
|
||||
}
|
||||
cfg := readTestConfig(t, path)
|
||||
if !strings.HasPrefix(cfg.Preferences.Theme, "theme-") {
|
||||
t.Fatalf("theme = %q, want one of the written values", cfg.Preferences.Theme)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentProviderUpsertsAllSurvive is the shape the issue describes most
|
||||
// directly: N distinct providers added at once. Each add is an independent
|
||||
// successful mutation, so all N must be present afterwards.
|
||||
func TestConcurrentProviderUpsertsAllSurvive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
seedTestConfig(t, path)
|
||||
|
||||
const providers = 16
|
||||
errs := make([]error, providers)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for index := range providers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, errs[index] = UpsertProvider(path, ProviderProfile{
|
||||
Name: fmt.Sprintf("provider-%02d", index),
|
||||
Model: fmt.Sprintf("model-%02d", index),
|
||||
}, false)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for index, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("upsert %d failed: %v", index, err)
|
||||
}
|
||||
}
|
||||
cfg := readTestConfig(t, path)
|
||||
present := map[string]string{}
|
||||
for _, provider := range cfg.Providers {
|
||||
present[provider.Name] = provider.Model
|
||||
}
|
||||
for index := range providers {
|
||||
name := fmt.Sprintf("provider-%02d", index)
|
||||
want := fmt.Sprintf("model-%02d", index)
|
||||
if got, ok := present[name]; !ok {
|
||||
t.Errorf("provider %s was lost (config holds %d providers)", name, len(cfg.Providers))
|
||||
} else if got != want {
|
||||
t.Errorf("provider %s model = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossProcessMutationExcludesAndPreserves is the coordinated two-process
|
||||
// test the issue asks for. Goroutines share this process's descriptors, so only
|
||||
// a second OS process shows the lock is held by the kernel rather than by
|
||||
// in-process state.
|
||||
//
|
||||
// The child announces itself and then mutates; the parent holds the lock across
|
||||
// that window and asserts the child's write CANNOT land, then does its own
|
||||
// mutation and releases. Both updates must survive. The exclusion window is
|
||||
// what makes this deterministic in the passing direction: with the lock held,
|
||||
// the child is blocked in its retry loop and the theme provably stays unset.
|
||||
func TestCrossProcessMutationExcludesAndPreserves(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
seedTestConfig(t, path)
|
||||
|
||||
unlock, err := lockConfigFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire lock: %v", err)
|
||||
}
|
||||
released := false
|
||||
release := func() {
|
||||
if !released {
|
||||
released = true
|
||||
unlock()
|
||||
}
|
||||
}
|
||||
defer release()
|
||||
|
||||
child := exec.Command(os.Args[0], "-test.run=^TestConfigConcurrentHelperProcess$")
|
||||
child.Env = append(os.Environ(),
|
||||
"ZERO_CONFIG_CONCURRENT_HELPER=1",
|
||||
"ZERO_CONFIG_CONCURRENT_PATH="+path,
|
||||
)
|
||||
stdout, err := child.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := child.Start(); err != nil {
|
||||
t.Fatalf("start helper: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if child.ProcessState == nil {
|
||||
_ = child.Process.Kill()
|
||||
_ = child.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
ready := make([]byte, len("ready\n"))
|
||||
if _, err := io.ReadFull(stdout, ready); err != nil {
|
||||
t.Fatalf("helper never signalled ready: %v", err)
|
||||
}
|
||||
|
||||
// The child is now live and contending for a lock this process holds. Its
|
||||
// write must not appear while we hold it.
|
||||
for range 20 {
|
||||
if theme := readTestConfig(t, path).Preferences.Theme; theme != "" {
|
||||
t.Fatalf("child wrote %q while the lock was held elsewhere; mutations are not excluded", theme)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
if _, err := upsertProviderLocked(path, ProviderProfile{Name: "parent", Model: "parent-model"}, false); err != nil {
|
||||
t.Fatalf("parent mutation: %v", err)
|
||||
}
|
||||
release()
|
||||
|
||||
if err := child.Wait(); err != nil {
|
||||
t.Fatalf("helper process failed: %v", err)
|
||||
}
|
||||
|
||||
cfg := readTestConfig(t, path)
|
||||
if cfg.Preferences.Theme != "child-theme" {
|
||||
t.Errorf("child update was lost: theme = %q, want child-theme", cfg.Preferences.Theme)
|
||||
}
|
||||
found := false
|
||||
for _, provider := range cfg.Providers {
|
||||
if provider.Name == "parent" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("parent update was lost: provider %q missing from %d providers", "parent", len(cfg.Providers))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigConcurrentHelperProcess is the child half of the cross-process
|
||||
// test. It is skipped unless the parent selected it through the environment.
|
||||
func TestConfigConcurrentHelperProcess(t *testing.T) {
|
||||
if os.Getenv("ZERO_CONFIG_CONCURRENT_HELPER") == "" {
|
||||
t.Skip("helper process for TestCrossProcessMutationExcludesAndPreserves")
|
||||
}
|
||||
path := os.Getenv("ZERO_CONFIG_CONCURRENT_PATH")
|
||||
if path == "" {
|
||||
t.Fatal("ZERO_CONFIG_CONCURRENT_PATH is required")
|
||||
}
|
||||
// Announce BEFORE contending, so the parent's exclusion window starts with
|
||||
// this process already running rather than still being forked.
|
||||
fmt.Println("ready")
|
||||
if _, err := SetTheme(path, "child-theme"); err != nil {
|
||||
t.Fatalf("child mutation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMutationReportsUnlockFailure pins the AGENTS.md rule that a mutator must
|
||||
// never report success when unlock failed. A failed release can leave the lock
|
||||
// held for the rest of the process, so returning (cfg, nil) after one would
|
||||
// claim a state the next mutation cannot reproduce.
|
||||
//
|
||||
// lockutil.Release is idempotent and reports nil once released, so a real
|
||||
// release failure cannot be provoked through the public API — hence the seam.
|
||||
// The mutation itself must still have been published: the release failure
|
||||
// annotates the result, it does not undo the write.
|
||||
func TestMutationReportsUnlockFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
seedTestConfig(t, path)
|
||||
|
||||
releaseErr := errors.New("release exploded")
|
||||
original := lockConfigFileFn
|
||||
t.Cleanup(func() { lockConfigFileFn = original })
|
||||
lockConfigFileFn = func(p string) (func() error, error) {
|
||||
unlock, err := original(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
_ = unlock()
|
||||
return releaseErr
|
||||
}, nil
|
||||
}
|
||||
|
||||
cfg, err := SetTheme(path, "dracula")
|
||||
if !errors.Is(err, releaseErr) {
|
||||
t.Fatalf("SetTheme err = %v, want it to carry the release failure", err)
|
||||
}
|
||||
if cfg.Preferences.Theme != "dracula" {
|
||||
t.Errorf("returned config lost the mutation: theme = %q", cfg.Preferences.Theme)
|
||||
}
|
||||
if got := readTestConfig(t, path).Preferences.Theme; got != "dracula" {
|
||||
t.Errorf("mutation was not published despite only the release failing: theme = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMutationErrorSurvivesUnlockFailure proves the two errors are joined
|
||||
// rather than chosen between: a release failure must not mask the mutation
|
||||
// error that actually explains what went wrong.
|
||||
func TestMutationErrorSurvivesUnlockFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
seedTestConfig(t, path)
|
||||
|
||||
releaseErr := errors.New("release exploded")
|
||||
original := lockConfigFileFn
|
||||
t.Cleanup(func() { lockConfigFileFn = original })
|
||||
lockConfigFileFn = func(p string) (func() error, error) {
|
||||
unlock, err := original(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
_ = unlock()
|
||||
return releaseErr
|
||||
}, nil
|
||||
}
|
||||
|
||||
// An unknown provider is the mutation error; the release failure is joined
|
||||
// onto it, and neither one disappears.
|
||||
_, err := SetActiveProvider(path, "definitely-not-configured")
|
||||
if !errors.Is(err, releaseErr) {
|
||||
t.Fatalf("err = %v, want it to carry the release failure", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "definitely-not-configured") {
|
||||
t.Fatalf("err = %v, want it to still name the missing provider", err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -80,12 +81,21 @@ func ForgetProviderKey(provider string) (bool, error) {
|
||||
// config at path, so credential checks no longer claim a stored key after one is
|
||||
// removed. No-op when path/provider is empty, the config is absent, or the marker
|
||||
// is already unset.
|
||||
func ClearProviderKeyStored(path, provider string) (bool, error) {
|
||||
func ClearProviderKeyStored(path, provider string) (cleared bool, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
provider = strings.TrimSpace(provider)
|
||||
if path == "" || provider == "" {
|
||||
return false, nil
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -116,11 +126,22 @@ func ClearProviderKeyStored(path, provider string) (bool, error) {
|
||||
// leaves the plaintext key in place and never strands a credential. Returns how
|
||||
// many keys were migrated; a no-op (0, nil) when path is empty/absent or nothing
|
||||
// needs migrating. Safe to run on every startup (idempotent).
|
||||
func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error) {
|
||||
func MigratePlaintextProviderKeys(path string, store APIKeySetter) (count int, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || store == nil {
|
||||
return 0, nil
|
||||
}
|
||||
// This runs on every startup, so it is the most likely writer to be racing
|
||||
// an interactive mutation in another Zero process.
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
|
||||
@@ -257,6 +257,21 @@ func TestProviderProfileMissingCredentialEnv(t *testing.T) {
|
||||
profile: ProviderProfile{Name: "local", CatalogID: "ollama"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// A profile saved against the hosted atomic-chat preset keeps its
|
||||
// remote base URL and key, so it must still be reported as missing a
|
||||
// credential. The keyless local runtime is a separate catalog ID
|
||||
// (atomic-chat-local) precisely so this identity is never repurposed.
|
||||
name: "hosted atomic-chat profile still requires its key",
|
||||
profile: ProviderProfile{Name: "atomic-chat", CatalogID: "atomic-chat", BaseURL: "https://api.atomic.chat/v1"},
|
||||
wantEnv: "ATOMIC_CHAT_API_KEY",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "local atomic chat runtime needs no credential",
|
||||
profile: ProviderProfile{Name: "atomic-local", CatalogID: "atomic-chat-local"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "credential resolved via inline key",
|
||||
profile: ProviderProfile{Name: "openai", ProviderKind: ProviderKindOpenAI, APIKey: "sk-test"},
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -41,7 +42,7 @@ func ValidateFile(path string) (FileConfig, []Issue) {
|
||||
// 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) {
|
||||
func SetProviderDescription(path string, name string, description string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
@@ -50,6 +51,17 @@ func SetProviderDescription(path string, name string, description string) (FileC
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
// Locks like the production mutators so this seam can stand in for one in a
|
||||
// concurrency test rather than being the one unsynchronized writer.
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lockutil"
|
||||
)
|
||||
|
||||
// ErrLockUnavailable reports that the config lock was still held by another
|
||||
// process (or goroutine) when configLockTimeout expired. A caller that would
|
||||
// otherwise make a SECOND config write after a failed one branches on this: the
|
||||
// lock has not been released, so the second attempt can only spend another full
|
||||
// timeout to fail the same way, turning one keystroke into two stalls.
|
||||
var ErrLockUnavailable = errors.New("config lock unavailable")
|
||||
|
||||
const (
|
||||
configLockTimeout = 10 * time.Second
|
||||
configLockRetryDelay = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
// lockConfigFile serializes a config read-modify-write across processes.
|
||||
//
|
||||
// Every mutator in this package loads the whole document, edits independent
|
||||
// fields, and publishes a complete replacement by rename. The rename is atomic,
|
||||
// so a reader never sees partial JSON — but two processes that both loaded the
|
||||
// same revision each write a full document, and the second rename silently
|
||||
// discards the first one's acknowledged update (issue #832). Startup work makes
|
||||
// this easy to hit: MigratePlaintextProviderKeys rewrites the config on every
|
||||
// launch, so it collides with any concurrent mutation from another Zero.
|
||||
//
|
||||
// The lock must therefore span load, mutation, validation AND publication:
|
||||
// holding it only around the write would still let both processes read the same
|
||||
// stale revision first. Callers acquire before their first read, which is what
|
||||
// makes the re-read inside the lock authoritative.
|
||||
//
|
||||
// The lock file is a sibling of the config, never the config itself. The kernel
|
||||
// holds an advisory lock against an inode, and publishing the config by rename
|
||||
// installs a NEW inode — locking the config directly would leave each process
|
||||
// holding a different one. lockutil keeps the sibling's path stable for the
|
||||
// same reason, and never removes it.
|
||||
//
|
||||
// The returned release function reports its own failure rather than swallowing
|
||||
// it: a failed Release can leave the lock held for the rest of the process, so
|
||||
// a mutator that returned success after it would be reporting a state the next
|
||||
// mutation cannot reproduce. Callers join it into their result (AGENTS.md:
|
||||
// "never report success when cleanup or unlock failed").
|
||||
//
|
||||
// This also serializes goroutines within one process: each acquisition opens
|
||||
// its own file description, so a second in-process attempt contends exactly as
|
||||
// another process would. The lock is NOT reentrant, so an exported mutator that
|
||||
// needs another mutator's work calls the unexported *Locked form instead of
|
||||
// re-entering through the public function.
|
||||
// LockFile exposes lockConfigFile to packages that edit the SAME user config
|
||||
// document without going through this package's mutators — internal/cli's MCP
|
||||
// editor reads, edits and republishes it with the identical temp-file+rename
|
||||
// shape. A writer that skipped this lock would reintroduce the lost update for
|
||||
// every field, so the lock has to be one authority across packages rather than
|
||||
// a private detail of this one.
|
||||
func LockFile(path string) (func() error, error) {
|
||||
return lockConfigFileFn(path)
|
||||
}
|
||||
|
||||
// lockConfigFileFn is the seam the mutators call. Release failures cannot be
|
||||
// provoked through the public API — lockutil.Release is idempotent and reports
|
||||
// nil once released — so substituting this is the only way to assert that a
|
||||
// failed unlock actually reaches the caller instead of being swallowed.
|
||||
var lockConfigFileFn = lockConfigFile
|
||||
|
||||
func lockConfigFile(path string) (func() error, error) {
|
||||
lockPath := path + ".lock"
|
||||
if dir := filepath.Dir(lockPath); dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create config directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(configLockTimeout)
|
||||
for {
|
||||
lock, err := lockutil.TryAcquireFileLock(lockPath)
|
||||
if err == nil {
|
||||
return lock.Release, nil
|
||||
}
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, fmt.Errorf("config: acquire config lock: %w", err)
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return nil, fmt.Errorf("config: timed out acquiring config lock for %s: %w", path, ErrLockUnavailable)
|
||||
}
|
||||
time.Sleep(configLockRetryDelay)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -119,6 +120,12 @@ type PreferencesConfig struct {
|
||||
// the user turned idle recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
Recaps *bool `json:"recaps,omitempty"`
|
||||
// CompactionModel routes compaction summarization calls to this model
|
||||
// instead of the session's main model (summaries at main-model prices are
|
||||
// the most expensive recurring event in long runs). Empty = automatic: a
|
||||
// curated cheap model on official endpoints, the main model elsewhere.
|
||||
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
|
||||
CompactionModel string `json:"compactionModel,omitempty"`
|
||||
}
|
||||
|
||||
// RecentModelEntry is one provider-qualified model selection recorded in
|
||||
@@ -357,6 +364,9 @@ type FileConfig struct {
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
// Extra preserves top-level fields written by newer Zero versions or
|
||||
// extensions so a read-modify-write through this version is non-destructive.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
@@ -394,7 +404,32 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
if !cfg.STT.Empty() {
|
||||
raw.STT = &cfg.STT
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
known, err := json.Marshal(raw)
|
||||
if err != nil || len(cfg.Extra) == 0 {
|
||||
return known, err
|
||||
}
|
||||
var merged map[string]json.RawMessage
|
||||
if err := json.Unmarshal(known, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range cfg.Extra {
|
||||
if !fileConfigKnownJSONKey(key) {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
func fileConfigKnownJSONKey(key string) bool {
|
||||
if strings.EqualFold(key, "mcpServers") || strings.EqualFold(key, "mcp_servers") {
|
||||
return true
|
||||
}
|
||||
for _, field := range knownJSONFields(reflect.TypeOf(FileConfig{})) {
|
||||
if strings.EqualFold(key, field.canonical) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ResolveOptions struct {
|
||||
@@ -510,6 +545,18 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &extra); err != nil {
|
||||
return err
|
||||
}
|
||||
for key := range extra {
|
||||
if fileConfigKnownJSONKey(key) {
|
||||
delete(extra, key)
|
||||
}
|
||||
}
|
||||
if len(extra) == 0 {
|
||||
extra = nil
|
||||
}
|
||||
cfg.ActiveProvider = raw.ActiveProvider
|
||||
cfg.Providers = raw.Providers
|
||||
// A negative maxTurns is unambiguously invalid; without this it would be
|
||||
@@ -531,6 +578,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
cfg.STT = raw.STT
|
||||
cfg.CrossSessionInbound = raw.CrossSessionInbound
|
||||
cfg.Extra = extra
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
|
||||
@@ -43,3 +43,58 @@ func TestToolsConfigPresentOnOverridesAndResolved(t *testing.T) {
|
||||
t.Fatalf("ResolvedConfig.Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigExtraCannotOverrideKnownFields(t *testing.T) {
|
||||
cfg := FileConfig{
|
||||
MaxTurns: 3,
|
||||
Extra: map[string]json.RawMessage{
|
||||
"maxTurns": json.RawMessage(`99`),
|
||||
"MaxTurns": json.RawMessage(`100`),
|
||||
"future": json.RawMessage(`{"enabled":true}`),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(persisted["maxTurns"]); got != "3" {
|
||||
t.Fatalf("maxTurns = %s, want 3", got)
|
||||
}
|
||||
if _, exists := persisted["MaxTurns"]; exists {
|
||||
t.Fatalf("case-variant extra overrode a known field: %s", data)
|
||||
}
|
||||
if got := string(persisted["future"]); got != `{"enabled":true}` {
|
||||
t.Fatalf("future = %s, want preserved object", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigUnicodeFoldedKnownFieldRoundTrip(t *testing.T) {
|
||||
const data = `{"mcpſervers":{"docs":{"url":"https://example.com/mcp"}}}`
|
||||
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal([]byte(data), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := cfg.MCP.Servers["docs"]; !exists {
|
||||
t.Fatalf("MCP.Servers = %#v, want docs server", cfg.MCP.Servers)
|
||||
}
|
||||
if cfg.Extra != nil {
|
||||
t.Fatalf("Extra = %#v, want Unicode-folded known field excluded", cfg.Extra)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roundTripped FileConfig
|
||||
if err := json.Unmarshal(encoded, &roundTripped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := roundTripped.MCP.Servers["docs"]; !exists {
|
||||
t.Fatalf("round-tripped MCP.Servers = %#v, want docs server", roundTripped.MCP.Servers)
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ type knownField struct {
|
||||
}
|
||||
|
||||
func derefType(t reflect.Type) reflect.Type {
|
||||
for t != nil && t.Kind() == reflect.Ptr {
|
||||
for t != nil && t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t
|
||||
|
||||
+219
-18
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,11 +12,28 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
|
||||
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
return upsertProviderLocked(path, profile, setActive)
|
||||
}
|
||||
|
||||
// upsertProviderLocked is UpsertProvider's body for a caller that already holds
|
||||
// the config lock. EnsureCatalogProvider reads the document and then upserts
|
||||
// into it, and the lock is not reentrant, so it must reach the work this way
|
||||
// rather than through the exported function.
|
||||
func upsertProviderLocked(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
|
||||
profile.Name = strings.TrimSpace(profile.Name)
|
||||
if profile.Name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
@@ -72,7 +90,7 @@ type EnsuredProvider struct {
|
||||
// Name or CatalogID already matches is left completely untouched (its name,
|
||||
// credentials, and model are the user's), and a created profile is NOT marked
|
||||
// active unless no provider was active at all.
|
||||
func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, error) {
|
||||
func EnsureCatalogProvider(path string, catalogID string) (result EnsuredProvider, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return EnsuredProvider{}, fmt.Errorf("config path is required")
|
||||
@@ -81,6 +99,18 @@ func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, erro
|
||||
if err != nil {
|
||||
return EnsuredProvider{}, err
|
||||
}
|
||||
// One lock spans the existence scan AND the upsert: releasing between them
|
||||
// would let another process create the same catalog profile in the window,
|
||||
// and the second writer would clobber the first.
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return EnsuredProvider{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
@@ -104,7 +134,7 @@ func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, erro
|
||||
BaseURL: descriptor.DefaultBaseURL,
|
||||
Model: descriptor.DefaultModel,
|
||||
}
|
||||
written, err := UpsertProvider(path, profile, false)
|
||||
written, err := upsertProviderLocked(path, profile, false)
|
||||
if err != nil {
|
||||
return EnsuredProvider{}, err
|
||||
}
|
||||
@@ -115,11 +145,20 @@ func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, erro
|
||||
// credential store. It also clears inline/env key fields so the stored key is the
|
||||
// runtime credential; an old apiKeyEnv value must not keep overriding a freshly
|
||||
// captured key from `zero auth openrouter` or provider setup.
|
||||
func MarkProviderAPIKeyStored(path string, provider string) error {
|
||||
func MarkProviderAPIKeyStored(path string, provider string) (err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
provider = strings.TrimSpace(provider)
|
||||
if provider == "" {
|
||||
return fmt.Errorf("provider name is required")
|
||||
@@ -144,11 +183,20 @@ func MarkProviderAPIKeyStored(path string, provider string) error {
|
||||
return fmt.Errorf("provider %q not found", provider)
|
||||
}
|
||||
|
||||
func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
func SetActiveProvider(path string, name string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
@@ -177,6 +225,51 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// SetActiveProviderModel persists a provider selection as one lock-held
|
||||
// read-modify-write. The active provider and that provider's model are one UI
|
||||
// choice; publishing them separately can leave a partially applied selection
|
||||
// if another writer wins the lock between calls.
|
||||
func SetActiveProviderModel(path string, name string, model string) (result FileConfig, err 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")
|
||||
}
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return FileConfig{}, fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
|
||||
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.ActiveProvider = cfg.Providers[index].Name
|
||||
cfg.Providers[index].Model = model
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -210,11 +303,20 @@ func ProviderPersisted(path string, name string) (bool, error) {
|
||||
// a profile that no longer exists. The caller owns cleaning up the credential
|
||||
// store entry — config stays pure of secret I/O on the read path, and remove
|
||||
// keeps that symmetry by only touching config.json.
|
||||
func RemoveProvider(path string, name string) (FileConfig, error) {
|
||||
func RemoveProvider(path string, name string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
@@ -261,11 +363,20 @@ func RemoveProvider(path string, name string) (FileConfig, error) {
|
||||
// resolves. OAuth tokens are deliberately not migrated: the runtime's login
|
||||
// candidates fall back to the profile's CatalogID, which every OAuth-capable
|
||||
// catalog profile carries, so a rename keeps the login reachable.
|
||||
func RenameProvider(path string, oldName string, newName string) (FileConfig, error) {
|
||||
func RenameProvider(path string, oldName string, newName string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
@@ -347,11 +458,20 @@ type ProviderEdit struct {
|
||||
// and, unlike UpsertProvider's exact-name merge, the case-insensitive match
|
||||
// here makes a case-only rename (groq -> Groq) an in-place update instead of
|
||||
// an appended duplicate profile.
|
||||
func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
|
||||
func EditProvider(path string, edit ProviderEdit) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
oldName := strings.TrimSpace(edit.Name)
|
||||
if oldName == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
@@ -461,11 +581,20 @@ func migrateStoredProviderKey(configPath string, oldName string, newName string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
|
||||
func SetProviderModel(path string, name string, model string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
@@ -498,11 +627,20 @@ func SetProviderModel(path string, name string, model string) (FileConfig, error
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
func SetFavoriteModels(path string, models []string) (FileConfig, error) {
|
||||
func SetFavoriteModels(path string, models []string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
@@ -524,11 +662,20 @@ func SetFavoriteModels(path string, models []string) (FileConfig, error) {
|
||||
// mirroring SetFavoriteModels (read-modify-atomic-write). Unlike favorites,
|
||||
// order is preserved (newest first) rather than sorted, since it reflects
|
||||
// switch recency, not an alphabetical preference list.
|
||||
func SetRecentModels(path string, entries []RecentModelEntry) (FileConfig, error) {
|
||||
func SetRecentModels(path string, entries []RecentModelEntry) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
@@ -548,11 +695,20 @@ func SetRecentModels(path string, entries []RecentModelEntry) (FileConfig, error
|
||||
|
||||
// SetRecapsEnabled persists the idle recap preference, mirroring
|
||||
// SetFavoriteModels (read-modify-atomic-write).
|
||||
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
|
||||
func SetRecapsEnabled(path string, enabled bool) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
@@ -571,11 +727,20 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
|
||||
|
||||
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
|
||||
// (read-modify-atomic-write). A blank theme clears the stored preference.
|
||||
func SetTheme(path string, theme string) (FileConfig, error) {
|
||||
func SetTheme(path string, theme string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
@@ -593,11 +758,20 @@ func SetTheme(path string, theme string) (FileConfig, error) {
|
||||
|
||||
// SetPet persists only the terminal-pet preference while preserving every
|
||||
// unrelated user setting through the config writer's atomic replace path.
|
||||
func SetPet(path string, pet string) (FileConfig, error) {
|
||||
func SetPet(path string, pet string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
cfg := FileConfig{}
|
||||
data := []byte("{}")
|
||||
if existing, err := os.ReadFile(path); err == nil {
|
||||
@@ -610,7 +784,7 @@ func SetPet(path string, pet string) (FileConfig, error) {
|
||||
}
|
||||
pet = strings.TrimSpace(pet)
|
||||
cfg.Preferences.Pet = pet
|
||||
data, err := setPetPreferenceJSON(data, pet)
|
||||
data, err = setPetPreferenceJSON(data, pet)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
@@ -624,11 +798,20 @@ func SetPet(path string, pet string) (FileConfig, error) {
|
||||
// SetTheme (read-modify-atomic-write). provider must be one of the known STT
|
||||
// provider kinds; a local provider stores the model as stt.localModelPath,
|
||||
// otherwise as stt.model. A blank model clears the stored value for that slot.
|
||||
func SetSTTModel(path string, provider STTProviderKind, model string) (FileConfig, error) {
|
||||
func SetSTTModel(path string, provider STTProviderKind, model string) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
@@ -660,11 +843,20 @@ func SetSTTModel(path string, provider STTProviderKind, model string) (FileConfi
|
||||
// (read-modify-atomic-write). streaming selects the pipeline matching the
|
||||
// downloaded model (a streaming transducer vs a batch model). Called after a
|
||||
// download completes.
|
||||
func SetSTTLocalEngine(path, binary, serverBinary, modelPath string, streaming bool) (FileConfig, error) {
|
||||
func SetSTTLocalEngine(path, binary, serverBinary, modelPath string, streaming bool) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
@@ -697,11 +889,20 @@ func SetSTTLocalEngine(path, binary, serverBinary, modelPath string, streaming b
|
||||
}
|
||||
|
||||
// SetSTTProvider persists just the dictation batch provider, mirroring SetTheme.
|
||||
func SetSTTProvider(path string, provider STTProviderKind) (FileConfig, error) {
|
||||
func SetSTTProvider(path string, provider STTProviderKind) (result FileConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
unlock, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed unlock would claim a state the
|
||||
// next mutation cannot reproduce.
|
||||
defer func() { err = errors.Join(err, unlock()) }()
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
|
||||
@@ -151,6 +151,32 @@ func TestSetActiveProviderTightensExistingConfigFilePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveProviderModelPersistsSelectionTogether(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
|
||||
{Name: "Anthropic", ProviderKind: ProviderKindAnthropic, Model: "old-model"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := SetActiveProviderModel(path, " anthropic ", " claude-sonnet-4.5 ")
|
||||
if err != nil {
|
||||
t.Fatalf("SetActiveProviderModel() error = %v", err)
|
||||
}
|
||||
if cfg.ActiveProvider != "Anthropic" || cfg.Providers[1].Model != "claude-sonnet-4.5" {
|
||||
t.Fatalf("returned selection = active %q model %q", cfg.ActiveProvider, cfg.Providers[1].Model)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.ActiveProvider != "Anthropic" || persisted.Providers[1].Model != "claude-sonnet-4.5" {
|
||||
t.Fatalf("persisted selection = active %q model %q", persisted.ActiveProvider, persisted.Providers[1].Model)
|
||||
}
|
||||
if persisted.Providers[0].Model != "gpt-4.1" {
|
||||
t.Fatalf("unrelated provider changed: %#v", persisted.Providers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderModelUpdatesConfiguredProvider(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
@@ -369,6 +395,37 @@ func TestSetThemePersistsUserPreference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"preferences": {"theme": "default"},
|
||||
"futureSetting": {"a": 1}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := SetTheme(path, "dracula"); err != nil {
|
||||
t.Fatalf("SetTheme() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var futureSetting struct {
|
||||
A int `json:"a"`
|
||||
}
|
||||
if err := json.Unmarshal(persisted["futureSetting"], &futureSetting); err != nil {
|
||||
t.Fatalf("unknown field was not preserved: %v\nconfig: %s", err, data)
|
||||
}
|
||||
if futureSetting.A != 1 {
|
||||
t.Fatalf("futureSetting = %s, want {\"a\":1}", persisted["futureSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecapsPreferenceRoundTrips(t *testing.T) {
|
||||
// Default (unset) is ON.
|
||||
if !(PreferencesConfig{}).RecapsEnabled() {
|
||||
|
||||
+9
-66
@@ -5,31 +5,23 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lockutil"
|
||||
)
|
||||
|
||||
// Cross-process lock tuning. The lock is held only for a single metadata
|
||||
// read-modify-write (milliseconds), never across a job's exec, so the timeout is
|
||||
// generous and the stale threshold sits far above any real hold.
|
||||
// read-modify-write (milliseconds), never across a job's exec, so the timeout
|
||||
// is generous relative to any real hold.
|
||||
const (
|
||||
cronLockTimeout = 10 * time.Second
|
||||
cronLockStaleAfter = 60 * time.Second
|
||||
cronLockRetryDelay = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
var cronLockSeq atomic.Uint64
|
||||
|
||||
// lockJob takes a cross-process exclusive lock for one job by O_EXCL-creating a
|
||||
// sibling "<id>.lock" file next to the job directory (so Remove's RemoveAll of
|
||||
// the job dir never deletes a live lock). It serializes the read-modify-write of
|
||||
// a job's metadata across concurrent schedulers and commands. The lock file is
|
||||
// removed on release; a stale lock from a crashed holder (older than
|
||||
// cronLockStaleAfter) is reclaimed. Wall-clock time.Now is used deliberately
|
||||
// (not the injectable Store.now) so lock timing never depends on a frozen test
|
||||
// clock and the stale check compares against real file mtimes.
|
||||
// lockJob takes a cross-process advisory lock for one job on a stable sibling
|
||||
// "<id>.lock" file next to the job directory. It serializes the read-modify-write
|
||||
// of a job's metadata across concurrent schedulers and commands. The kernel
|
||||
// releases the lock when a holder exits; the file is never renamed or removed.
|
||||
func (s *Store) lockJob(id string) (func(), error) {
|
||||
if !validID(id) {
|
||||
return nil, fmt.Errorf("invalid cron job id %q", id)
|
||||
@@ -38,64 +30,15 @@ func (s *Store) lockJob(id string) (func(), error) {
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), cronLockSeq.Add(1))
|
||||
deadline := time.Now().Add(cronLockTimeout)
|
||||
for {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
lock, err := lockutil.TryAcquireFileLock(lockPath)
|
||||
if err == nil {
|
||||
// A partial write would leave a lock file without our token, so the
|
||||
// releaser could never delete it — stranding the lock. Fail closed.
|
||||
if _, werr := f.WriteString(token); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
return nil, fmt.Errorf("cron: write job lock: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
return nil, fmt.Errorf("cron: close job lock: %w", cerr)
|
||||
}
|
||||
var released bool
|
||||
return func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
}
|
||||
}, nil
|
||||
return func() { _ = lock.Release() }, nil
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry, exactly like ErrExist — otherwise the lock
|
||||
// spuriously fails under concurrency on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, fmt.Errorf("cron: acquire job lock: %w", err)
|
||||
}
|
||||
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
|
||||
// Remove lets two racers both "reclaim" and recreate, so both hold the lock;
|
||||
// reclaimStaleLock renames the file aside (only one rename of a given file
|
||||
// wins) and restores it if it turns out fresh, so a live lock is never deleted
|
||||
// out from under its holder.
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > cronLockStaleAfter {
|
||||
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
|
||||
info, err := os.Stat(reclaimedPath)
|
||||
return err == nil && time.Since(info.ModTime()) <= cronLockStaleAfter
|
||||
})
|
||||
if rerr != nil {
|
||||
// Reclaim hit a hard failure: the rename aside failed outright, or a
|
||||
// live holder's lock could not be put back (the lock path may be
|
||||
// missing, so re-acquiring would break mutual exclusion). Fail closed
|
||||
// instead of spinning to the deadline.
|
||||
return nil, fmt.Errorf("cron: reclaim stale job lock: %w", rerr)
|
||||
}
|
||||
if cleared {
|
||||
continue // cleared a genuinely stale lock; retry the O_EXCL create now
|
||||
}
|
||||
// Lost the reclaim race (or it was actually fresh) — fall through to the
|
||||
// bounded wait instead of hot-spinning on a reclaim that never wins (L13).
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return nil, fmt.Errorf("cron: timed out acquiring job lock for %q", id)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestMutateRemovedJobReturnsNotFound(t *testing.T) {
|
||||
if !errors.Is(err, ErrJobNotFound) {
|
||||
t.Fatalf("Mutate on removed job = %v, want ErrJobNotFound", err)
|
||||
}
|
||||
// And the lock file left no litter / did not resurrect the job dir.
|
||||
// The stable advisory-lock file does not resurrect the removed job dir.
|
||||
if _, gerr := store.Get(job.ID); !errors.Is(gerr, ErrJobNotFound) {
|
||||
t.Fatalf("job should remain removed, Get = %v", gerr)
|
||||
}
|
||||
|
||||
+35
-67
@@ -3,107 +3,60 @@ package daemon
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lockutil"
|
||||
)
|
||||
|
||||
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
|
||||
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
|
||||
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
|
||||
// daemon recovers from an unclean shutdown without manual cleanup.
|
||||
// Single-instance lock. The stable PID file carries a kernel advisory lock for
|
||||
// the daemon's full lifetime. Process exit releases the lock automatically, so
|
||||
// stale metadata needs no rename/remove recovery protocol.
|
||||
|
||||
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
|
||||
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
|
||||
|
||||
// fileLock is an acquired single-instance lock.
|
||||
type fileLock struct {
|
||||
path string
|
||||
lock *lockutil.FileLock
|
||||
}
|
||||
|
||||
// processAlive reports whether pid is a live process. Implemented per-platform
|
||||
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
|
||||
var processAlive = osProcessAlive
|
||||
|
||||
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
|
||||
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
|
||||
// stub); nil uses the real processAlive.
|
||||
// acquireLock takes the single-instance advisory lock at path. isAlive is used
|
||||
// only to enrich a contention error with the current holder's PID; the kernel
|
||||
// lock, not PID metadata, is authoritative.
|
||||
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
if isAlive == nil {
|
||||
isAlive = processAlive
|
||||
}
|
||||
// At most two passes: create, or detect-stale-then-retry once.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
// A failed PID write would leave a malformed lock file that another
|
||||
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
|
||||
// the single-instance guarantee — so on write failure, remove it and fail.
|
||||
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = lockutil.RemoveLockFile(path)
|
||||
return nil, werr
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = lockutil.RemoveLockFile(path)
|
||||
return nil, cerr
|
||||
}
|
||||
return &fileLock{path: path}, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
lock, err := lockutil.TryAcquireFileLock(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, err
|
||||
}
|
||||
pid, perr := readPidFile(path)
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
// Stale lock (dead PID or unreadable) — reclaim it atomically, then retry the
|
||||
// O_EXCL create. A blind Remove here races: two daemons starting at once could
|
||||
// both read the stale PID, both Remove, and then one Removes the OTHER's
|
||||
// freshly-created lock — leaving both "holding" the single-instance lock.
|
||||
// reclaimStaleLock renames the file aside so only one racer wins the rename,
|
||||
// and restores it if a live holder reacquired in the gap (D6).
|
||||
if _, rerr := reclaimStaleLock(path, isAlive); rerr != nil {
|
||||
// Reclaim hit a hard failure: the rename aside failed outright, or a
|
||||
// live holder's lock could not be put back (the lock path may be
|
||||
// missing, so re-acquiring would break the single-instance guarantee).
|
||||
// Fail closed instead of spinning to the deadline.
|
||||
return nil, fmt.Errorf("daemon: reclaim stale lock: %w", rerr)
|
||||
}
|
||||
return nil, ErrAlreadyRunning
|
||||
}
|
||||
return nil, ErrAlreadyRunning
|
||||
if err := lock.WriteMetadata([]byte(fmt.Sprintf("%d\n", os.Getpid()))); err != nil {
|
||||
return nil, errors.Join(err, lock.Release())
|
||||
}
|
||||
return &fileLock{lock: lock}, nil
|
||||
}
|
||||
|
||||
// daemonLockSeq makes each reclaim attempt's sidelined filename unique per process.
|
||||
var daemonLockSeq atomic.Uint64
|
||||
|
||||
// reclaimStaleLock atomically reclaims a single-instance lock whose recorded
|
||||
// PID is dead, via lockutil.ReclaimStaleLock: the lock file is renamed aside
|
||||
// (only one racer can win the rename) and stolen only if the moved file's PID
|
||||
// is still dead; a holder that reacquired the lock in the gap between the
|
||||
// stale check and the rename carries a LIVE pid and is restored rather than
|
||||
// stolen. Returns true only when a genuinely stale lock was removed. A
|
||||
// non-nil error means the caller must fail closed instead of re-acquiring
|
||||
// (see lockutil.ReclaimStaleLock).
|
||||
func reclaimStaleLock(path string, isAlive func(pid int) bool) (bool, error) {
|
||||
suffix := fmt.Sprintf("%d-%d", os.Getpid(), daemonLockSeq.Add(1))
|
||||
return lockutil.ReclaimStaleLock(path, suffix, func(reclaimedPath string) bool {
|
||||
pid, err := readPidFile(reclaimedPath)
|
||||
return err == nil && pid > 0 && isAlive(pid)
|
||||
})
|
||||
}
|
||||
|
||||
// release removes the lock file. Safe to call once. An already-missing lock
|
||||
// file is not an error (lockutil.RemoveLockFile swallows it on every platform).
|
||||
// release drops the kernel lock and closes its handle. The stable PID file is
|
||||
// deliberately retained so contenders always address the same inode.
|
||||
func (l *fileLock) release() error {
|
||||
if l == nil || l.path == "" {
|
||||
if l == nil || l.lock == nil {
|
||||
return nil
|
||||
}
|
||||
return lockutil.RemoveLockFile(l.path)
|
||||
return l.lock.Release()
|
||||
}
|
||||
|
||||
// readPidFile reads and parses the PID recorded in a lock file.
|
||||
@@ -112,5 +65,20 @@ func readPidFile(path string) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
metadata := strings.TrimSpace(string(data))
|
||||
if pid, err := strconv.Atoi(metadata); err == nil {
|
||||
return pid, nil
|
||||
}
|
||||
pidText, sequenceText, ok := strings.Cut(metadata, "-")
|
||||
if !ok || pidText == "" || sequenceText == "" || strings.Contains(sequenceText, "-") {
|
||||
return 0, fmt.Errorf("daemon: parse lock PID metadata %q", metadata)
|
||||
}
|
||||
if _, err := strconv.ParseUint(sequenceText, 10, 64); err != nil {
|
||||
return 0, fmt.Errorf("daemon: parse lock holder sequence: %w", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(pidText)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("daemon: parse lock PID: %w", err)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
@@ -32,27 +32,48 @@ func TestLockSingleInstance(t *testing.T) {
|
||||
_ = l2.release()
|
||||
}
|
||||
|
||||
func TestLockStaleRecovery(t *testing.T) {
|
||||
// A PID liveness probe is diagnostic only. Even if it reports the recorded PID
|
||||
// dead, a contended kernel lock proves that the holder is active and must win.
|
||||
// The old rename-aside protocol trusted this stale observation and admitted a
|
||||
// second daemon while the first one was still inside its critical section.
|
||||
func TestLockDoesNotOverrideKernelHolderWithStalePIDProbe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
// Simulate a stale lock from a crashed daemon: a PID file whose process is
|
||||
// dead.
|
||||
first, err := acquireLock(path, func(int) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatalf("first acquireLock: %v", err)
|
||||
}
|
||||
defer first.release()
|
||||
|
||||
second, err := acquireLock(path, func(int) bool { return false })
|
||||
if second != nil {
|
||||
_ = second.release()
|
||||
}
|
||||
if !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("contended acquire with stale PID probe = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockIgnoresStaleMetadataWhenKernelLockIsFree(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
// A crashed daemon may leave PID metadata behind, but its kernel lock is
|
||||
// released automatically, so the next daemon can acquire without moving it.
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write stale lock: %v", err)
|
||||
}
|
||||
dead := func(int) bool { return false }
|
||||
l, err := acquireLock(path, dead)
|
||||
if err != nil {
|
||||
t.Fatalf("stale-lock recovery failed: %v", err)
|
||||
t.Fatalf("acquire over stale metadata: %v", err)
|
||||
}
|
||||
// The lock now records OUR pid, not the stale one.
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
|
||||
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
|
||||
t.Fatalf("lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
|
||||
}
|
||||
_ = l.release()
|
||||
}
|
||||
|
||||
func TestLockReleaseRemovesFile(t *testing.T) {
|
||||
func TestLockReleaseKeepsStableFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
l, err := acquireLock(path, func(int) bool { return true })
|
||||
if err != nil {
|
||||
@@ -61,46 +82,37 @@ func TestLockReleaseRemovesFile(t *testing.T) {
|
||||
if err := l.release(); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("lock file still present after release: %v", err)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("release removed stable lock file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimStaleLockRemovesDeadHolder(t *testing.T) {
|
||||
func TestReadPidFileMetadataFormats(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write stale lock: %v", err)
|
||||
}
|
||||
if ok, err := reclaimStaleLock(path, func(int) bool { return false }); err != nil || !ok {
|
||||
t.Fatalf("reclaimStaleLock must report a genuinely stale (dead-PID) lock reclaimed (ok=%v err=%v)", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("a reclaimed stale lock must be removed, stat = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimStaleLockRestoresLiveHolder(t *testing.T) {
|
||||
// If a holder reacquires the lock in the gap between the stale check and the
|
||||
// rename, the moved file carries a LIVE pid; reclaim must restore it untouched
|
||||
// rather than steal it — otherwise two daemons both "hold" the lock (D6).
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write lock: %v", err)
|
||||
}
|
||||
if ok, err := reclaimStaleLock(path, func(int) bool { return true }); err != nil || ok {
|
||||
t.Fatalf("reclaimStaleLock must NOT report a live-PID lock reclaimed (ok=%v err=%v)", ok, err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("live lock must be restored in place, read = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) != "4242" {
|
||||
t.Fatalf("restored lock content = %q, want %q (unchanged)", strings.TrimSpace(string(data)), "4242")
|
||||
}
|
||||
// No sidelined ".stale" leftovers.
|
||||
matches, _ := filepath.Glob(path + ".stale.*")
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("reclaim left sidelined files: %v", matches)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
metadata string
|
||||
wantPID int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "legacy PID", metadata: "4242\n", wantPID: 4242},
|
||||
{name: "PID and holder sequence", metadata: "4242-7\n", wantPID: 4242},
|
||||
{name: "missing sequence", metadata: "4242-", wantErr: true},
|
||||
{name: "non-numeric sequence", metadata: "4242-next", wantErr: true},
|
||||
{name: "extra component", metadata: "4242-7-8", wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(test.metadata), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readPidFile(path)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("readPidFile() error = %v, wantErr %v", err, test.wantErr)
|
||||
}
|
||||
if !test.wantErr && got != test.wantPID {
|
||||
t.Fatalf("readPidFile() = %d, want %d", got, test.wantPID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,12 +124,16 @@ func TestServerEndToEnd(t *testing.T) {
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
// Socket, status, and lock files are removed on exit.
|
||||
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
|
||||
// Runtime endpoints are removed on exit. The advisory lock file remains as
|
||||
// a stable inode, but the kernel lock on it has been released.
|
||||
for _, p := range []string{paths.Socket, paths.Status} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(paths.Lock); err != nil {
|
||||
t.Fatalf("stable lock file missing after shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSecondInstanceFails(t *testing.T) {
|
||||
|
||||
@@ -142,9 +142,10 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
|
||||
return check("provider.config", "Provider config", StatusFail, "No LLM provider is configured.", map[string]any{"help": "Set a provider in config or environment."})
|
||||
}
|
||||
// Report credential PRESENCE, never the value. Reported under a non-sensitive
|
||||
// key ("credentialConfigured"): the prior "apiKey" key was itself sensitive, so
|
||||
// check()'s redaction scrubbed the indicator to [REDACTED] — making "set"/"not
|
||||
// set" invisible. HasConfiguredCredential is the shared definition of
|
||||
// key ("authConfigured"): "apiKey" and "credentialConfigured" are themselves
|
||||
// sensitive after camelCase normalization, so check()'s redaction would scrub
|
||||
// the indicator to [REDACTED] — making "set"/"not set" invisible.
|
||||
// HasConfiguredCredential is the shared definition of
|
||||
// "key-authed" (inline key, raw auth header, or a key in the encrypted
|
||||
// credential store), matching ProviderSnapshot.APIKeySet — checking only the
|
||||
// inline fields made doctor and `zero providers list` disagree about the
|
||||
@@ -159,11 +160,11 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
|
||||
credential = "oauth login"
|
||||
}
|
||||
details := map[string]any{
|
||||
"name": profile.Name,
|
||||
"provider": profile.ProviderKind,
|
||||
"baseURL": profile.BaseURL,
|
||||
"model": profile.Model,
|
||||
"credentialConfigured": credential,
|
||||
"name": profile.Name,
|
||||
"provider": profile.ProviderKind,
|
||||
"baseURL": profile.BaseURL,
|
||||
"model": profile.Model,
|
||||
"authConfigured": credential,
|
||||
}
|
||||
// A remote provider with no credential cannot make a request, so doctor must NOT
|
||||
// report it as healthy — otherwise "Overall: pass" gives a false all-clear for the
|
||||
|
||||
@@ -388,9 +388,9 @@ func TestProviderConfigCheckCredentialPresence(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := providerConfigCheck(tc.profile).Details["credentialConfigured"]
|
||||
got := providerConfigCheck(tc.profile).Details["authConfigured"]
|
||||
if got != tc.want {
|
||||
t.Fatalf("credentialConfigured = %v, want %q (matches ProviderSnapshot.APIKeySet trimming)", got, tc.want)
|
||||
t.Fatalf("authConfigured = %v, want %q (matches ProviderSnapshot.APIKeySet trimming)", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,13 +3,93 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// requireProcessState refuses to let a broken ps pipeline masquerade as passing
|
||||
// coverage. Every test in this file proves something about zombie detection,
|
||||
// and every one of those proofs is read out of ps — so if ps cannot report
|
||||
// state, the tests verify nothing whatever they do next.
|
||||
//
|
||||
// The old code answered that with t.Skip, which is indistinguishable from
|
||||
// success in CI output. This probes BOTH ps pipelines against this test
|
||||
// process, which is by definition alive and not a zombie, so a working ps must
|
||||
// report a live state for it. Failing that:
|
||||
//
|
||||
// - In CI (or with ZERO_REQUIRE_PS=1) it is a hard failure. ps is present on
|
||||
// every image used today, so its absence means the pipeline regressed and
|
||||
// that has to surface immediately rather than as skipped-green.
|
||||
// - Elsewhere it stays a skip, so a contributor on an exotic box is not
|
||||
// blocked by an environment the project does not claim to support.
|
||||
//
|
||||
// This only covers "ps cannot report state at all". Once it passes, a later
|
||||
// failure to observe an EXPECTED state is a real defect, and the tests below
|
||||
// treat it as one.
|
||||
func requireProcessState(t *testing.T) {
|
||||
t.Helper()
|
||||
self := os.Getpid()
|
||||
var reasons []string
|
||||
// The production path: ps -A -o pid=,pgid=,stat=, parsed by
|
||||
// processTableStateMatches. This is what signalTargetRunning consults.
|
||||
if running, ok := processIsRunning(self); !ok || !running {
|
||||
reasons = append(reasons, "the process table read used by signalTargetRunning reported no live state for this test process")
|
||||
}
|
||||
// This file's independent oracle: ps -o stat= -p <pid>. It is deliberately
|
||||
// NOT the production parser, so a regression in that parser cannot hide by
|
||||
// breaking the test's own precondition check in the same way.
|
||||
if zombie, ok := processIsZombie(self); !ok || zombie {
|
||||
reasons = append(reasons, "the per-PID state read used by this test file reported no live state for this test process")
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return
|
||||
}
|
||||
reason := "cannot read process state, so ps-based zombie detection is unverifiable here: " + strings.Join(reasons, "; ")
|
||||
if requireWorkingPS() {
|
||||
t.Fatal(reason + " — failing rather than skipping because CI (or ZERO_REQUIRE_PS) is set, where ps is expected to work")
|
||||
}
|
||||
t.Skip(reason)
|
||||
}
|
||||
|
||||
// requireWorkingPS reports whether an unusable ps must fail the run instead of
|
||||
// skipping it. ZERO_REQUIRE_PS lets a developer opt into the strict behavior
|
||||
// locally to reproduce what CI does.
|
||||
func requireWorkingPS() bool {
|
||||
if strings.TrimSpace(os.Getenv("ZERO_REQUIRE_PS")) != "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("CI")) != ""
|
||||
}
|
||||
|
||||
// awaitZombie blocks until pid is an unreaped zombie. The caller must not have
|
||||
// waited on the child, so the zombie is a stable state rather than a race: it
|
||||
// persists until something reaps it. Failing to reach it therefore means the
|
||||
// child never exited or ps stopped reporting its state, and both are defects
|
||||
// rather than reasons to skip.
|
||||
func awaitZombie(t *testing.T, pid int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
zombie, ok := processIsZombie(pid)
|
||||
if ok && zombie {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
if !ok {
|
||||
t.Fatalf("pid %d left the process table without being reaped; this test never waited on it, so its zombie row must persist", pid)
|
||||
}
|
||||
t.Fatalf("pid %d did not reach the zombie state within 5s", pid)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
|
||||
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
|
||||
// zombie, so a group whose only remaining member is waiting to be reaped used to
|
||||
@@ -17,6 +97,8 @@ import (
|
||||
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
|
||||
// depended on when the reaper ran.
|
||||
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
||||
requireProcessState(t)
|
||||
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
ConfigureProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -27,23 +109,59 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
||||
// is exactly the state a terminated tree passes through.
|
||||
t.Cleanup(func() { _ = cmd.Wait() })
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if zombie, ok := processIsZombie(pid); ok && zombie {
|
||||
break
|
||||
awaitZombie(t, pid)
|
||||
|
||||
// Whether a zombie-only group can be signalled is a KERNEL difference, not a
|
||||
// ps one. On Linux the zombie still occupies the process table and
|
||||
// kill(-pid, 0) succeeds, so signalTargetRunning goes on to consult ps —
|
||||
// which is the branch this test was written for. Darwin returns EPERM for
|
||||
// the same call, so signalTargetRunning short-circuits on the kill and never
|
||||
// reaches the ps check.
|
||||
//
|
||||
// The OUTCOME is identical and is asserted on every platform below. Only the
|
||||
// branch that produces it differs, so record that instead of skipping the
|
||||
// whole test (which would drop Darwin's coverage of the outcome) or failing
|
||||
// it (which would report a supported platform as broken). Any errno other
|
||||
// than EPERM means the group is not in the state this test needs.
|
||||
psPathReachable := true
|
||||
if err := syscall.Kill(-pid, syscall.Signal(0)); err != nil {
|
||||
if !errors.Is(err, syscall.EPERM) {
|
||||
t.Fatalf("zombie group %d is unsignalable with an unexpected error (%v); the zombie case is not present, so nothing below would be meaningful", pid, err)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
|
||||
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
|
||||
psPathReachable = false
|
||||
t.Logf("kernel refuses to signal zombie-only group %d (EPERM); signalTargetRunning answers from kill(2) rather than ps here, so the ps-based zombie check is asserted directly on platforms that allow the signal", pid)
|
||||
}
|
||||
|
||||
// Where the signal IS permitted, pin the ps-based check itself. This is the
|
||||
// regression #774 was about, and asserting only signalTargetRunning would
|
||||
// let a ps-side regression hide behind the kill result.
|
||||
if psPathReachable {
|
||||
running, ok := processGroupHasRunningMember(pid)
|
||||
if !ok {
|
||||
t.Fatalf("process group %d has no rows while its zombie is unreaped; ps stopped reporting the group rather than the group being gone", pid)
|
||||
}
|
||||
if running {
|
||||
t.Fatal("the ps-based group check must not report a zombie-only group as running")
|
||||
}
|
||||
}
|
||||
|
||||
// Valid on every platform: whichever branch answers, a zombie-only group is
|
||||
// not running. On a kernel that refuses the signal this is the whole of the
|
||||
// coverage, and it is more than the previous skip provided.
|
||||
if signalTargetRunning(-pid) {
|
||||
t.Fatal("a group holding only a zombie must not count as running")
|
||||
}
|
||||
|
||||
if !psPathReachable {
|
||||
// terminateTarget's own kill(2) would hit the same EPERM and be returned
|
||||
// as a cleanup error, so this assertion cannot be made where the kernel
|
||||
// refuses the signal. That may be a real gap in TerminateProcessTree on
|
||||
// such a platform rather than a test limitation — a zombie-only tree is
|
||||
// already exited and should terminate cleanly — but confirming and
|
||||
// changing that is a production fix, not this test's business.
|
||||
t.Log("skipping the TerminateProcessTree assertion: its kill(2) would hit the same EPERM as the probe above")
|
||||
return
|
||||
}
|
||||
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
|
||||
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
|
||||
}
|
||||
@@ -53,6 +171,8 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
||||
// a live member must still be seen as running and then actually stopped, so the
|
||||
// zombie tolerance above cannot degrade into ignoring real processes.
|
||||
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
||||
requireProcessState(t)
|
||||
|
||||
cmd := exec.Command("sh", "-c", "sleep 30")
|
||||
ConfigureProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -67,7 +187,15 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
||||
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
|
||||
t.Fatalf("TerminateProcessTree: %v", err)
|
||||
}
|
||||
if running, ok := processGroupHasRunningMember(pid); ok && running {
|
||||
// ok reports whether ANY row for this group was found. This test never waits
|
||||
// on the child, so its zombie row must still be there — an unknown state
|
||||
// means ps stopped answering, not that the group is gone. Treating that as a
|
||||
// pass is what let this assertion succeed without checking anything.
|
||||
running, ok := processGroupHasRunningMember(pid)
|
||||
if !ok {
|
||||
t.Fatalf("process group %d has no rows after termination; the unreaped child's zombie row must persist, so the group's state is undeterminable rather than clean", pid)
|
||||
}
|
||||
if running {
|
||||
t.Fatal("the group still has a running member after termination")
|
||||
}
|
||||
}
|
||||
@@ -82,6 +210,8 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
||||
// "unknown" resulted every time, and signalTargetRunning conservatively (and
|
||||
// incorrectly) treated a zombie individual-PID target as still running.
|
||||
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
|
||||
requireProcessState(t)
|
||||
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
|
||||
// test process's group, so it is not its own leader and processSignalTarget
|
||||
@@ -92,40 +222,38 @@ func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
|
||||
pid := cmd.Process.Pid
|
||||
t.Cleanup(func() { _ = cmd.Wait() })
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if zombie, ok := processIsZombie(pid); ok && zombie {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
awaitZombie(t, pid)
|
||||
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
t.Fatalf("processSignalTarget: %v", err)
|
||||
}
|
||||
// The child inherited this process's group, so its PGID is this process's
|
||||
// PID and can never equal its own PID. A negative target here would mean
|
||||
// processSignalTarget misread the group, which is the very thing the
|
||||
// individual-PID fallback exists to handle.
|
||||
if target != pid {
|
||||
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
|
||||
t.Fatalf("processSignalTarget(%d) = %d, want the individual PID: the child inherited this process's group, so it is not its own leader", pid, target)
|
||||
}
|
||||
if signalTargetRunning(target) {
|
||||
t.Fatal("a zombie individual-PID target must not count as running")
|
||||
}
|
||||
}
|
||||
|
||||
// processIsZombie reports a process's zombie state via ps. ok is false when the
|
||||
// state could not be read.
|
||||
// processIsZombie reports a process's zombie state via ps. It is deliberately a
|
||||
// separate, per-PID ps invocation rather than a reuse of the production
|
||||
// process-table parser: these tests assert what that parser concludes, so
|
||||
// borrowing it to establish their own preconditions would let one regression
|
||||
// silence both sides at once.
|
||||
//
|
||||
// ok is false when no state could be read, which after requireProcessState has
|
||||
// passed means the process left the table rather than that ps is broken.
|
||||
func processIsZombie(pid int) (zombie bool, ok bool) {
|
||||
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
state := string(output)
|
||||
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
|
||||
state = state[1:]
|
||||
}
|
||||
state := strings.TrimLeft(string(output), " \n\t")
|
||||
if state == "" {
|
||||
return false, false
|
||||
}
|
||||
|
||||
+9
-63
@@ -5,93 +5,39 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lockutil"
|
||||
)
|
||||
|
||||
// Cross-process lock tuning for the audit log. The lock is held only across a
|
||||
// single read-then-append (milliseconds), so the timeout is generous and the
|
||||
// stale threshold sits far above any real hold.
|
||||
// single read-then-append (milliseconds), so the timeout is generous relative
|
||||
// to any real hold.
|
||||
const (
|
||||
auditLockTimeout = 10 * time.Second
|
||||
auditLockStaleAfter = 60 * time.Second
|
||||
auditLockRetryDelay = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
var auditLockSeq atomic.Uint64
|
||||
|
||||
// lockAudit takes a cross-process exclusive lock on the audit log by
|
||||
// O_EXCL-creating a sibling "<auditPath>.lock" file (removed on release). It makes
|
||||
// lockAudit takes a cross-process advisory lock on the audit log through a
|
||||
// stable sibling "<auditPath>.lock" file. It makes
|
||||
// the lastSequence()+append in append() atomic across processes; the audit log is
|
||||
// a shared global file, so without it two processes can read the same last
|
||||
// sequence and write duplicate numbers. A stale lock from a crashed holder (older
|
||||
// than auditLockStaleAfter) is reclaimed. Wall-clock time is used deliberately so
|
||||
// lock timing never depends on the store's injectable clock and the stale check
|
||||
// compares against real file mtimes. This mirrors internal/cron/lock.go and
|
||||
// internal/oauth/lock.go. Uses internal/lockutil for lock restore and reclaim.
|
||||
// sequence and write duplicate numbers. Kernel lock lifetime provides crash
|
||||
// recovery without moving or deleting the lock path.
|
||||
func (store *AuditStore) lockAudit() (func(), error) {
|
||||
lockPath := store.auditPath + ".lock"
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), auditLockSeq.Add(1))
|
||||
deadline := time.Now().Add(auditLockTimeout)
|
||||
for {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
lock, err := lockutil.TryAcquireFileLock(lockPath)
|
||||
if err == nil {
|
||||
// A partial write would leave a lock file without our token, so the
|
||||
// releaser could never delete it — stranding the lock. Fail closed.
|
||||
if _, werr := f.WriteString(token); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
return nil, fmt.Errorf("hooks: write audit lock: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
return nil, fmt.Errorf("hooks: close audit lock: %w", cerr)
|
||||
}
|
||||
var released bool
|
||||
return func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
}
|
||||
}, nil
|
||||
return func() { _ = lock.Release() }, nil
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat both
|
||||
// as contention and retry.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, fmt.Errorf("hooks: acquire audit lock: %w", err)
|
||||
}
|
||||
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
|
||||
// Remove lets two racers both reclaim + recreate and so both hold the lock;
|
||||
// reclaimStaleLock renames the file aside (only one rename wins) and restores
|
||||
// it if it turns out fresh, so a live lock is never deleted out from under it.
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > auditLockStaleAfter {
|
||||
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
|
||||
info, err := os.Stat(reclaimedPath)
|
||||
return err == nil && time.Since(info.ModTime()) <= auditLockStaleAfter
|
||||
})
|
||||
if rerr != nil {
|
||||
// Reclaim hit a hard failure: the rename aside failed outright, or a
|
||||
// live holder's lock could not be put back (the lock path may be
|
||||
// missing, so re-acquiring would break mutual exclusion). Fail closed
|
||||
// instead of spinning to the deadline.
|
||||
return nil, fmt.Errorf("hooks: reclaim stale audit lock: %w", rerr)
|
||||
}
|
||||
if cleared {
|
||||
continue
|
||||
}
|
||||
// Lost the reclaim race (or it was actually fresh) — fall through to the
|
||||
// bounded wait rather than hot-spinning on a reclaim that never wins.
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return nil, fmt.Errorf("hooks: timed out acquiring audit lock")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package installtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestPullRequestWorkflowsDeclareContentsRead(t *testing.T) {
|
||||
root := filepath.Join(repoRoot(t), ".github", "workflows")
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var missing []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !(strings.HasSuffix(e.Name(), ".yml") || strings.HasSuffix(e.Name(), ".yaml")) {
|
||||
continue
|
||||
}
|
||||
rel := filepath.Join(".github", "workflows", e.Name())
|
||||
body := readRepoText(t, rel)
|
||||
doc, err := parseWorkflow(body)
|
||||
if err != nil {
|
||||
t.Errorf("%s: yaml: %v", rel, err)
|
||||
continue
|
||||
}
|
||||
if !workflowOnHasPullRequest(doc.On) {
|
||||
continue
|
||||
}
|
||||
if !workflowPermissionsContentsRead(doc.Permissions) {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("pull_request workflows missing permissions contents: read: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowPermissionParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yml string
|
||||
wantPR bool
|
||||
wantPerm bool
|
||||
}{
|
||||
{
|
||||
name: "mapping trigger",
|
||||
yml: "on:\n pull_request:\npermissions:\n contents: read\n",
|
||||
wantPR: true,
|
||||
wantPerm: true,
|
||||
},
|
||||
{
|
||||
name: "inline trigger list",
|
||||
yml: "on: [pull_request]\npermissions:\n contents: read\n",
|
||||
wantPR: true,
|
||||
wantPerm: true,
|
||||
},
|
||||
{
|
||||
name: "commented permissions ignored",
|
||||
yml: "on:\n pull_request:\n# permissions:\n# contents: read\n",
|
||||
wantPR: true,
|
||||
wantPerm: false,
|
||||
},
|
||||
{
|
||||
name: "nested script text is not a trigger",
|
||||
yml: "on:\n push:\njobs:\n x:\n steps:\n - run: echo pull_request: permissions: contents: read\n",
|
||||
wantPR: false,
|
||||
wantPerm: false,
|
||||
},
|
||||
{
|
||||
name: "inline comment does not fake contents read",
|
||||
yml: "on: [pull_request]\npermissions: write-all # contents: read\n",
|
||||
wantPR: true,
|
||||
wantPerm: false,
|
||||
},
|
||||
{
|
||||
name: "empty permissions mapping",
|
||||
yml: "on: [pull_request]\npermissions: {}\n",
|
||||
wantPR: true,
|
||||
wantPerm: false,
|
||||
},
|
||||
{
|
||||
name: "write-all is not contents read",
|
||||
yml: "on: [pull_request]\npermissions: write-all\n",
|
||||
wantPR: true,
|
||||
wantPerm: false,
|
||||
},
|
||||
{
|
||||
name: "quoted on and permissions keys",
|
||||
yml: "\"on\": [pull_request]\n\"permissions\":\n \"contents\": read\n",
|
||||
wantPR: true,
|
||||
wantPerm: true,
|
||||
},
|
||||
{
|
||||
name: "nested job permissions are not top-level",
|
||||
yml: "on:\n pull_request:\njobs:\n x:\n permissions:\n contents: read\n",
|
||||
wantPR: true,
|
||||
wantPerm: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := workflowHasPullRequestTrigger(tc.yml); got != tc.wantPR {
|
||||
t.Fatalf("trigger = %v, want %v", got, tc.wantPR)
|
||||
}
|
||||
if got := workflowHasTopLevelContentsRead(tc.yml); got != tc.wantPerm {
|
||||
t.Fatalf("permissions = %v, want %v", got, tc.wantPerm)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVulncheckWindowsQuotesGobinPath(t *testing.T) {
|
||||
body := readRepoText(t, "Makefile")
|
||||
for _, want := range []string{
|
||||
`mkdir -p "$(CURDIR)/.cache/gobin"`,
|
||||
`GOBIN="$(CURDIR)/.cache/gobin"`,
|
||||
`"$(CURDIR)/.cache/gobin/govulncheck"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("vulncheck-windows missing quoted path %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type workflowFile struct {
|
||||
On any `yaml:"on"`
|
||||
Permissions any `yaml:"permissions"`
|
||||
}
|
||||
|
||||
func parseWorkflow(body string) (workflowFile, error) {
|
||||
var doc workflowFile
|
||||
err := yaml.Unmarshal([]byte(body), &doc)
|
||||
return doc, err
|
||||
}
|
||||
|
||||
func workflowHasPullRequestTrigger(body string) bool {
|
||||
doc, err := parseWorkflow(body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return workflowOnHasPullRequest(doc.On)
|
||||
}
|
||||
|
||||
func workflowHasTopLevelContentsRead(body string) bool {
|
||||
doc, err := parseWorkflow(body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return workflowPermissionsContentsRead(doc.Permissions)
|
||||
}
|
||||
|
||||
func workflowOnHasPullRequest(on any) bool {
|
||||
switch v := on.(type) {
|
||||
case string:
|
||||
return v == "pull_request"
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if workflowOnHasPullRequest(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
_, ok := v["pull_request"]
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func workflowPermissionsContentsRead(perm any) bool {
|
||||
m, ok := perm.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
contents, ok := m["contents"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
s, ok := contents.(string)
|
||||
return ok && s == "read"
|
||||
}
|
||||
@@ -8,10 +8,27 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const lockFileName = ".zero-install.lock"
|
||||
|
||||
// workspacePrefix names the per-transaction workspaces created inside an install
|
||||
// root. Dot-prefixed so it is never mistaken for an installed plugin or skill.
|
||||
const workspacePrefix = ".zero-install-txn-"
|
||||
|
||||
// markerFileName records, inside a workspace, which install the backup beside it
|
||||
// belongs to. Without it a workspace left by a killed process holds a tree
|
||||
// nothing can attribute, and so nothing can put back.
|
||||
const markerFileName = ".zero-install-txn"
|
||||
|
||||
// markerMagic is the first line of that marker. Ownership has to be proven
|
||||
// rather than inferred from filesystem shape: a user authored skill directory
|
||||
// may legitimately be named with the workspace prefix and hold a previous
|
||||
// directory, and recovery acting on one would destroy installed content. No
|
||||
// ordinary content carries this line by accident.
|
||||
const markerMagic = "zero-install-txn v1"
|
||||
|
||||
// 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) {
|
||||
@@ -28,7 +45,7 @@ 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-")
|
||||
workspace, err := os.MkdirTemp(dir, workspacePrefix)
|
||||
if err != nil {
|
||||
return "", func() {}, fmt.Errorf("create install staging dir: %w", err)
|
||||
}
|
||||
@@ -45,6 +62,12 @@ func CommitDir(target string, staged string, publish func() error) error {
|
||||
backup := filepath.Join(workspace, "previous")
|
||||
hadPrevious := false
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
// Record the target before moving its tree. The two renames below cannot
|
||||
// be made atomic, so a process killed between them leaves the only copy
|
||||
// in the backup, and without this nothing could tell which install it is.
|
||||
if err := os.WriteFile(filepath.Join(workspace, markerFileName), []byte(markerMagic+"\ntarget "+filepath.Base(target)+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("record install target: %w", err)
|
||||
}
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
return fmt.Errorf("retain previous install: %w", err)
|
||||
}
|
||||
@@ -76,7 +99,7 @@ func CommitDir(target string, staged string, publish func() error) error {
|
||||
//
|
||||
// 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-")
|
||||
workspace, err := os.MkdirTemp(filepath.Dir(target), workspacePrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create removal staging dir: %w", err)
|
||||
}
|
||||
@@ -95,15 +118,339 @@ func RemoveDir(target string, publish func() error) error {
|
||||
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))
|
||||
// Phase names which tree in an interrupted workspace the caller's published
|
||||
// metadata records.
|
||||
type Phase int
|
||||
|
||||
const (
|
||||
// PhaseUnknown means the metadata matches neither tree. Recovery refuses to
|
||||
// act and reports the workspace as unresolved.
|
||||
PhaseUnknown Phase = iota
|
||||
// PhaseCommitted means the live target is what the metadata records: the
|
||||
// publish committed, and the backup beside it is superseded.
|
||||
PhaseCommitted
|
||||
// PhasePrePublish means the metadata still records the retained backup: the
|
||||
// tree swap landed but the publish never did.
|
||||
PhasePrePublish
|
||||
)
|
||||
|
||||
// Reconciler classifies one interrupted workspace for the install named name.
|
||||
// target is the live install path, backup the retained tree beside it. An error
|
||||
// means the classification could not be made and recovery must not act.
|
||||
type Reconciler func(name string, target string, backup string) (Phase, error)
|
||||
|
||||
// Recover resolves the workspaces an earlier run was killed inside, which is
|
||||
// what a process killed mid-commit leaves: a tree retained in a workspace
|
||||
// nothing else reads. How far that commit got cannot be read off the
|
||||
// filesystem. CommitDir swaps the trees and only then publishes, so a kill
|
||||
// between the two leaves exactly what a kill after both leaves, and a phase bit
|
||||
// written after the publish only inverts which side of the write the ambiguity
|
||||
// falls on. Only the caller knows what its published metadata records, so
|
||||
// reconcile is asked which of the two trees that is, and its answer decides:
|
||||
// the live target stands and the backup beside it is retired, or the backup is
|
||||
// the truthful tree and goes back over the target. A nil reconciler means the
|
||||
// caller publishes no metadata beside the tree, so a live target is the
|
||||
// committed one. With nothing at the target there is no choice to make and the
|
||||
// backup, the only copy in existence, is put back.
|
||||
//
|
||||
// The returned error means an attributable transaction was left unresolved,
|
||||
// never that there was nothing to do. Malformed, legacy, and unattributable
|
||||
// workspaces are skipped without error, since they may be somebody's content or
|
||||
// somebody else's transaction, but a workspace we own and could not resolve is
|
||||
// reported. Every workspace is processed before returning, so one unresolved
|
||||
// transaction does not strand the rest.
|
||||
//
|
||||
// The caller must hold the install-root lock returned by Lock, and EVERY caller
|
||||
// that takes that lock must call this first and abort on its error before it
|
||||
// reads the lockfile, inspects the target, installs over it, or reports a
|
||||
// successful removal. Recovering only on the install path is worse than not
|
||||
// recovering at all: a removal would then report success while the backup it
|
||||
// never saw stayed on disk, and the next install would publish it again,
|
||||
// reinstating something the user deleted. Recovery is deliberately an explicit
|
||||
// call rather than a side effect of Lock, matching how the other staged-swap
|
||||
// transactions in this repo invoke their repair pass.
|
||||
func Recover(dir string, reconcile Reconciler) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
// A recovery set that was never enumerated is not an empty one, and the
|
||||
// caller must not go on to install over or report the removal of a tree
|
||||
// that may still be owed a restore.
|
||||
return fmt.Errorf("enumerate install workspaces: %w", err)
|
||||
}
|
||||
if hadPrevious {
|
||||
if err := os.Rename(backup, target); err != nil {
|
||||
return errors.Join(cause, fmt.Errorf("restore previous install: %w", err))
|
||||
var unresolved []error
|
||||
// Gather first, act second. A pass that acts as it walks cannot see that two
|
||||
// workspaces claim one install, and acting on both in turn publishes one over
|
||||
// the other and deletes the rest.
|
||||
type claim struct {
|
||||
workspace string
|
||||
name string
|
||||
target string
|
||||
backup string
|
||||
}
|
||||
var claims []claim
|
||||
claimants := map[string]int{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), workspacePrefix) {
|
||||
continue
|
||||
}
|
||||
workspace := filepath.Join(dir, entry.Name())
|
||||
recorded, ok, err := markerTarget(workspace)
|
||||
if err != nil {
|
||||
// A marker we could not read is not a marker that is absent. Skipping
|
||||
// silently would tell the caller there was nothing to do while the only
|
||||
// copy of an install stays in the workspace.
|
||||
unresolved = append(unresolved, fmt.Errorf("read the transaction marker in %s: %w", workspace, err))
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, target, ok := recoverableTarget(dir, recorded)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
backup := filepath.Join(workspace, "previous")
|
||||
info, err := os.Lstat(backup)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// Mid-transaction: the trees never moved, so there is nothing to
|
||||
// put back and the workspace belongs to whoever made it.
|
||||
continue
|
||||
}
|
||||
unresolved = append(unresolved, fmt.Errorf("inspect the retained install in %s: %w", workspace, err))
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
// Only a directory is something this package set aside. Renaming a
|
||||
// symlink into the install root publishes whatever it points at, from
|
||||
// anywhere on the filesystem.
|
||||
unresolved = append(unresolved, fmt.Errorf("the retained install in %s is not a directory, so recovery will not publish it", workspace))
|
||||
continue
|
||||
}
|
||||
claims = append(claims, claim{workspace: workspace, name: name, target: target, backup: backup})
|
||||
claimants[name]++
|
||||
}
|
||||
for _, c := range claims {
|
||||
if claimants[c.name] > 1 {
|
||||
// Nothing ranks two claims on one install, and publishing them in turn
|
||||
// destroys every tree but the last.
|
||||
unresolved = append(unresolved, fmt.Errorf("install %s is claimed by more than one interrupted transaction, including %s: resolve them by hand", c.name, c.workspace))
|
||||
continue
|
||||
}
|
||||
if err := recoverWorkspace(c.workspace, c.target, c.backup, c.name, reconcile); err != nil {
|
||||
unresolved = append(unresolved, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(unresolved...)
|
||||
}
|
||||
|
||||
// recoverWorkspace resolves one attributable workspace whose backup is present.
|
||||
func recoverWorkspace(workspace string, target string, backup string, name string, reconcile Reconciler) error {
|
||||
info, err := os.Lstat(target)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
// A target we could not probe is not a target that is absent. Reading
|
||||
// it as absent enters the restore branch, the one branch that moves a
|
||||
// tree, on the strength of a question that never got an answer. No test
|
||||
// injects this on Linux: the names that used to reach here and fail are
|
||||
// now refused by recoverableTarget, and a permission fault on the root
|
||||
// stops at the marker read above. It is kept for the platforms that can
|
||||
// still fail this call, Windows reserved device names among them.
|
||||
return fmt.Errorf("inspect install %s: %w", name, err)
|
||||
}
|
||||
// Nothing at the target, so the swap never finished and the backup is the
|
||||
// only copy there is. There is no second tree to weigh it against, and
|
||||
// asking could only produce an answer that throws it away.
|
||||
if err := os.Rename(backup, target); err != nil {
|
||||
return fmt.Errorf("restore interrupted install %s: %w", name, err)
|
||||
}
|
||||
cleanupWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
// An entry at the target is not an install at the target. A dangling
|
||||
// symlink satisfies Lstat, and reading it as a live install retires the
|
||||
// backup beside it, which is the only real copy there is.
|
||||
return fmt.Errorf("install %s is not a directory, so recovery cannot tell what it is: resolve %s by hand", name, workspace)
|
||||
}
|
||||
phase := PhaseCommitted
|
||||
if reconcile != nil {
|
||||
classified, err := reconcile(name, target, backup)
|
||||
if err != nil {
|
||||
// A caller that could not classify is not permission to fall back on
|
||||
// filesystem shape, which is the inference this protocol exists to
|
||||
// remove.
|
||||
return fmt.Errorf("classify interrupted install %s: %w", name, err)
|
||||
}
|
||||
phase = classified
|
||||
}
|
||||
switch phase {
|
||||
case PhaseCommitted:
|
||||
// The backup holds what the committed publish replaced. Keeping it made a
|
||||
// removal reversible by accident: the removal deleted the live target, and
|
||||
// the next recovery then read the absent target as an interrupted swap and
|
||||
// published the stale tree again. Only the workspace goes, never the live
|
||||
// install. This is the one place os.RemoveAll is right over
|
||||
// cleanupWorkspace, which refuses a workspace holding a previous precisely
|
||||
// because it cannot tell a superseded backup from one still owed a
|
||||
// restore.
|
||||
//
|
||||
// The backup goes before the workspace around it, because os.RemoveAll
|
||||
// walks the workspace in name order and so unlinks the marker first. A
|
||||
// removal that then failed inside the backup left a workspace holding a
|
||||
// tree nothing could attribute, which the next pass reads as somebody
|
||||
// else's content and stays silent about. Clearing the backup first means
|
||||
// whatever survives a failure is still ours and is still reported.
|
||||
if err := os.RemoveAll(backup); err != nil {
|
||||
return fmt.Errorf("retire superseded install %s: %w", name, err)
|
||||
}
|
||||
if err := os.RemoveAll(workspace); err != nil {
|
||||
return fmt.Errorf("retire superseded install %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
case PhasePrePublish:
|
||||
return restoreOverTarget(workspace, target, backup, name)
|
||||
default:
|
||||
// Neither tree is the one the metadata records, so recovery cannot tell
|
||||
// which one the user is owed and either guess risks destroying the other.
|
||||
return fmt.Errorf("interrupted install %s matches neither the live target nor the retained backup: resolve %s by hand", name, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreOverTarget puts the backup back over a target that is still there,
|
||||
// which is what the metadata recording the backup means: the tree swap landed
|
||||
// but the publish never did, and recovery cannot publish forward because it does
|
||||
// not know the source the interrupted install was writing.
|
||||
func restoreOverTarget(workspace string, target string, backup string, name string) error {
|
||||
// Same ordering rollback uses. Removing the superseded tree in place would
|
||||
// leave a husk at the target while the backup was still the only complete
|
||||
// copy, and a kill in that window is unrecoverable. With the renames in this
|
||||
// order every instant has either a whole tree at the target or nothing there
|
||||
// and the backup intact, which is exactly what this pass can tell apart.
|
||||
failed := filepath.Join(workspace, "failed")
|
||||
// An earlier rollback can have left a tree here. The workspace is proven ours
|
||||
// by its marker, so clearing it is safe, and leaving it is not: the rename
|
||||
// below fails for as long as it is there, and because every caller aborts on a
|
||||
// recovery error that wedges every install and removal in this root.
|
||||
if err := os.RemoveAll(failed); err != nil {
|
||||
return fmt.Errorf("clear the superseded install set aside in %s: %w", workspace, err)
|
||||
}
|
||||
if err := os.Rename(target, failed); err != nil {
|
||||
return fmt.Errorf("set aside superseded install %s: %w", name, err)
|
||||
}
|
||||
if err := os.Rename(backup, target); err != nil {
|
||||
return fmt.Errorf("restore interrupted install %s: %w", name, err)
|
||||
}
|
||||
if err := os.RemoveAll(failed); err != nil {
|
||||
return fmt.Errorf("remove superseded install %s: %w", name, err)
|
||||
}
|
||||
cleanupWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
// markerTarget reads the install name a workspace records, and reports whether
|
||||
// the workspace is one of ours at all. The prefix alone does not prove that: it
|
||||
// is a public dot prefixed name and the skill loader enumerates dot prefixed
|
||||
// directories, so a user authored skill can carry it and hold the same entries a
|
||||
// workspace does. Only the magic and version first line is evidence no ordinary
|
||||
// content produces by accident, so anything else is somebody's content and is
|
||||
// left alone.
|
||||
func markerTarget(workspace string) (string, bool, error) {
|
||||
path := filepath.Join(workspace, markerFileName)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", false, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Present but unreadable is not absent, and the difference decides whether
|
||||
// a retained tree is put back or stranded where nothing reads it.
|
||||
return "", false, err
|
||||
}
|
||||
magic, rest, ok := strings.Cut(string(data), "\n")
|
||||
if !ok || magic != markerMagic {
|
||||
return "", false, nil
|
||||
}
|
||||
line, _, ok := strings.Cut(rest, "\n")
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
name, ok := strings.CutPrefix(line, "target ")
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
return name, true, nil
|
||||
}
|
||||
|
||||
// recoverableTarget resolves a recorded target name to a path directly inside
|
||||
// dir, and returns the normalized name alongside it. Both come back because the
|
||||
// caller needs them to agree: the path is resolved from the trimmed name, so
|
||||
// asking the reconciler about the raw one would split the decision across two
|
||||
// values, and a lookup that missed on the untrimmed name would read as a publish
|
||||
// that never ran and replace the committed target with the tree it superseded.
|
||||
//
|
||||
// A name that is not a single path element could name anything on the
|
||||
// filesystem, so it is refused rather than restored over. A name carrying the
|
||||
// workspace prefix is refused for the same reason: it names another
|
||||
// transaction, not an install, and restoring over one that is still in flight
|
||||
// would destroy it.
|
||||
func recoverableTarget(dir string, name string) (string, string, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "." || name == ".." || name != filepath.Base(name) {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.HasPrefix(name, workspacePrefix) {
|
||||
return "", "", false
|
||||
}
|
||||
// A name no filesystem can resolve is not an install to recover. Left to reach
|
||||
// Lstat it answers with an error that is not not-exist, which would be reported
|
||||
// as an unresolved transaction on every pass forever, with no way for a caller
|
||||
// to clear it.
|
||||
if strings.ContainsRune(name, 0) || len(name) > 255 {
|
||||
return "", "", false
|
||||
}
|
||||
return name, filepath.Join(dir, name), true
|
||||
}
|
||||
|
||||
func rollback(target string, backup string, hadPrevious bool, cause error) error {
|
||||
if !hadPrevious {
|
||||
// A first install has no backup to protect and recorded no target, so
|
||||
// recovery never looks here and deleting in place costs nothing.
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
return errors.Join(cause, fmt.Errorf("remove failed install: %w", err))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
// Move the failed install aside before restoring rather than deleting it in
|
||||
// place. A process killed partway through an in-place delete would leave a
|
||||
// half removed tree at the target while the backup was still the only
|
||||
// complete copy, and recovery reads a target that is there as a committed
|
||||
// publish and retires the backup beside it. With the renames in this order
|
||||
// every instant of the rollback has either a whole tree at the target or
|
||||
// nothing there and the backup intact, which is exactly what recovery's two
|
||||
// branches are able to tell apart.
|
||||
failed := filepath.Join(filepath.Dir(backup), "failed")
|
||||
if err := os.Rename(target, failed); err != nil {
|
||||
// The move aside can fail too, and then the failed install stays live at
|
||||
// the target while the backup is still the only copy of what it replaced.
|
||||
// A caller with no reconciler reads a tree at the target as a committed
|
||||
// publish, so it would retire that backup, and one that has a reconciler
|
||||
// cannot classify a tree its own publish never recorded. Dropping the
|
||||
// marker leaves a workspace nothing can attribute, which recovery already
|
||||
// leaves alone, and the copy is still there to rescue by hand.
|
||||
_ = os.Remove(filepath.Join(filepath.Dir(backup), markerFileName))
|
||||
return errors.Join(cause, fmt.Errorf("remove failed install: %w", err))
|
||||
}
|
||||
if err := os.Rename(backup, target); err != nil {
|
||||
return errors.Join(cause, fmt.Errorf("restore previous install: %w", err))
|
||||
}
|
||||
_ = os.RemoveAll(failed)
|
||||
return cause
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user