mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4fff2b1de | ||
|
|
38c1e333b1 | ||
|
|
28360a2dff | ||
|
|
ad78725568 | ||
|
|
a9aa37e267 | ||
|
|
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 |
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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -70,5 +70,9 @@ 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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
+142
-51
@@ -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 {
|
||||
@@ -1033,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
|
||||
@@ -1378,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
|
||||
@@ -1394,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.
|
||||
@@ -1434,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
|
||||
|
||||
+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"}
|
||||
|
||||
+14
-49
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+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,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"
|
||||
)
|
||||
|
||||
@@ -363,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) {
|
||||
@@ -400,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 {
|
||||
@@ -516,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
|
||||
@@ -537,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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -9,7 +9,9 @@
|
||||
// through `security -i` (interactive mode), whose command parser is line-based
|
||||
// with a fixed 4096-byte line buffer, so Set rejects secrets containing
|
||||
// newlines or exceeding that budget rather than silently corrupting them;
|
||||
// Linux's secret-tool has no such restriction.
|
||||
// Linux's secret-tool has no such restriction. MaxSecretLen reports that budget
|
||||
// so a caller holding a larger value can split it across entries instead of
|
||||
// being turned away.
|
||||
package keyring
|
||||
|
||||
import (
|
||||
@@ -82,7 +84,7 @@ func (k *Keyring) Set(service, account, secret string) error {
|
||||
}
|
||||
}
|
||||
// -U updates the item if it already exists rather than failing.
|
||||
line := "add-generic-password -U -s " + securityQuote(service) + " -a " + securityQuote(account) + " -w " + securityQuote(secret) + "\n"
|
||||
line := securityAddLine(service, account, secret)
|
||||
if len(line) > securityMaxLine {
|
||||
return wrap("set", fmt.Errorf("secret too large for the macOS security tool's %d-byte command line; use the file backend instead", securityMaxLine))
|
||||
}
|
||||
@@ -162,11 +164,42 @@ func (k *Keyring) Delete(service, account string) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// MaxSecretLen reports the largest secret, in bytes, that Set accepts under
|
||||
// (service, account) on this platform. ok is false when the backend imposes no
|
||||
// practical limit, which is the case for Linux's secret-tool: it reads the
|
||||
// secret from stdin rather than a command line. On macOS the secret shares the
|
||||
// `security -i` command line with the service and account, so the budget
|
||||
// depends on both and a caller that splits an oversized value must size each
|
||||
// part against the exact account name it will be stored under.
|
||||
//
|
||||
// The budget assumes the secret needs no quote escaping. securityQuote only
|
||||
// expands `\` and `"`, neither of which appears in base64 or hex, so a caller
|
||||
// storing encoded data gets the exact figure.
|
||||
func (k *Keyring) MaxSecretLen(service, account string) (int, bool) {
|
||||
if k.goos != "darwin" {
|
||||
return 0, false
|
||||
}
|
||||
// Measuring the real command with an empty secret counts the surrounding
|
||||
// quotes too, so a secret of exactly this length lands on securityMaxLine.
|
||||
budget := securityMaxLine - len(securityAddLine(service, account, ""))
|
||||
if budget < 0 {
|
||||
budget = 0
|
||||
}
|
||||
return budget, true
|
||||
}
|
||||
|
||||
// securityMaxLine is the most `security -i` can read as one command: its line
|
||||
// buffer is MAX_LINE_LEN (4096) bytes in Apple's SecurityTool, one of which the
|
||||
// terminating NUL consumes.
|
||||
const securityMaxLine = 4095
|
||||
|
||||
// securityAddLine renders the `security -i` command that stores secret under
|
||||
// (service, account). Set writes it and MaxSecretLen measures it, so the two
|
||||
// cannot disagree about how much of the line the overhead consumes.
|
||||
func securityAddLine(service, account, secret string) string {
|
||||
return "add-generic-password -U -s " + securityQuote(service) + " -a " + securityQuote(account) + " -w " + securityQuote(secret) + "\n"
|
||||
}
|
||||
|
||||
// securityQuote wraps s for one argument of a `security -i` command line. The
|
||||
// tool's parser (split_line in Apple's SecurityTool) treats a backslash inside
|
||||
// double quotes as escaping the next character and the matching quote as the
|
||||
|
||||
@@ -375,3 +375,46 @@ func TestAvailable(t *testing.T) {
|
||||
t.Fatal("linux should be available")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxSecretLenMatchesTheDarwinSetBoundary pins the budget to the boundary
|
||||
// Set actually enforces. A caller that splits an oversized value depends on the
|
||||
// two agreeing exactly: one byte of drift either wastes an entry or produces a
|
||||
// chunk security silently splits into two garbage commands.
|
||||
func TestMaxSecretLenMatchesTheDarwinSetBoundary(t *testing.T) {
|
||||
k := newFake("darwin").keyring()
|
||||
budget, bounded := k.MaxSecretLen("zero", "oauth-tokens")
|
||||
if !bounded {
|
||||
t.Fatal("MaxSecretLen reported darwin as unbounded")
|
||||
}
|
||||
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens", strings.Repeat("a", budget)); err != nil {
|
||||
t.Errorf("Set(budget=%d bytes): %v, want acceptance at the boundary", budget, err)
|
||||
}
|
||||
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens", strings.Repeat("a", budget+1)); err == nil {
|
||||
t.Errorf("Set(%d bytes) succeeded, want rejection one byte past the budget", budget+1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxSecretLenShrinksWithTheAccountName covers why the budget takes the
|
||||
// account: on macOS the account and the secret share one command line, so a
|
||||
// longer account name leaves less room for the secret.
|
||||
func TestMaxSecretLenShrinksWithTheAccountName(t *testing.T) {
|
||||
k := newFake("darwin").keyring()
|
||||
short, _ := k.MaxSecretLen("zero", "oauth-tokens")
|
||||
long, _ := k.MaxSecretLen("zero", "oauth-tokens.a.63")
|
||||
if want := short - len(".a.63"); long != want {
|
||||
t.Fatalf("MaxSecretLen for the longer account = %d, want %d", long, want)
|
||||
}
|
||||
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens.a.63", strings.Repeat("a", long+1)); err == nil {
|
||||
t.Error("Set one byte past the longer account's budget succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxSecretLenUnboundedOffDarwin keeps the limit platform-specific:
|
||||
// secret-tool reads the secret from stdin, so there is no command line to fill.
|
||||
func TestMaxSecretLenUnboundedOffDarwin(t *testing.T) {
|
||||
for _, goos := range []string{"linux", "windows"} {
|
||||
if n, bounded := newFake(goos).keyring().MaxSecretLen("zero", "oauth-tokens"); bounded || n != 0 {
|
||||
t.Errorf("MaxSecretLen on %s = (%d, %v), want (0, false)", goos, n, bounded)
|
||||
}
|
||||
}
|
||||
}
|
||||
+272
-26
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
@@ -49,13 +50,21 @@ type Client struct {
|
||||
stdin io.WriteCloser
|
||||
reader *messageReader
|
||||
writer *messageWriter
|
||||
mu sync.Mutex
|
||||
closeMu sync.Mutex
|
||||
idMu sync.Mutex
|
||||
nextID int
|
||||
cleanup func()
|
||||
|
||||
writeMu sync.Mutex
|
||||
writeQueue chan writeOp
|
||||
writeClosed bool
|
||||
writeSenders sync.WaitGroup
|
||||
writerStop chan struct{}
|
||||
writerDone chan struct{}
|
||||
courtesyOverflow []writeOp
|
||||
|
||||
// dispatchMu guards the response-dispatch state shared with the single
|
||||
// reader goroutine. It is never held across a blocking read.
|
||||
// reader goroutine. It is never held across a blocking read or write.
|
||||
dispatchMu sync.Mutex
|
||||
readerOnce sync.Once
|
||||
pending map[int]chan dispatchResult
|
||||
@@ -63,6 +72,17 @@ type Client struct {
|
||||
readDone bool
|
||||
}
|
||||
|
||||
type writeOp struct {
|
||||
ctx context.Context
|
||||
message rpcMessage
|
||||
done chan error
|
||||
}
|
||||
|
||||
const writeQueueCapacity = 32
|
||||
const courtesyOverflowCap = writeQueueCapacity
|
||||
|
||||
var errMCPClientClosed = errors.New("MCP client closed")
|
||||
|
||||
// dispatchResult carries one matched JSON-RPC response (or a terminal reader
|
||||
// error) to a waiting caller.
|
||||
type dispatchResult struct {
|
||||
@@ -252,7 +272,8 @@ func (client *Client) Close() error {
|
||||
// Fail any callers still waiting on a response. The blocking read in the
|
||||
// reader goroutine is released below when stdin closes and the process
|
||||
// exits (or is killed), EOFing stdout.
|
||||
client.failAll(errors.New("MCP client closed"))
|
||||
client.failAll(errMCPClientClosed)
|
||||
client.beginWriterShutdown()
|
||||
|
||||
var err error
|
||||
stdin := client.stdin
|
||||
@@ -292,6 +313,7 @@ func (client *Client) Close() error {
|
||||
client.cleanup()
|
||||
client.cleanup = nil
|
||||
}
|
||||
client.finishWriterShutdown()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -307,20 +329,20 @@ func (client *Client) request(ctx context.Context, method string, params any, ta
|
||||
return err
|
||||
}
|
||||
|
||||
// Allocate an id, register a response channel, and write the request while
|
||||
// holding client.mu. The mutex serializes writes and id allocation but is
|
||||
// released before the (potentially unbounded) wait for the response, so a
|
||||
// hung server never holds the lock and blocks other callers/Close.
|
||||
client.mu.Lock()
|
||||
// Allocate an id and register a response channel. ID allocation and dispatch
|
||||
// registrations are fast non-blocking operations. Message transmission is
|
||||
// handled via writeMessage so a caller with a canceled context can stop
|
||||
// waiting even when the peer is not draining its input.
|
||||
client.idMu.Lock()
|
||||
id := client.nextID
|
||||
client.nextID++
|
||||
client.idMu.Unlock()
|
||||
|
||||
responses := make(chan dispatchResult, 1)
|
||||
client.dispatchMu.Lock()
|
||||
if client.readDone {
|
||||
readErr := client.readErr
|
||||
client.dispatchMu.Unlock()
|
||||
client.mu.Unlock()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
@@ -329,16 +351,14 @@ func (client *Client) request(ctx context.Context, method string, params any, ta
|
||||
client.pending[id] = responses
|
||||
client.dispatchMu.Unlock()
|
||||
|
||||
if err := client.writer.write(rpcMessage{
|
||||
if err := client.writeMessage(ctx, rpcMessage{
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: rawParams,
|
||||
}); err != nil {
|
||||
client.removePending(id)
|
||||
client.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
client.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -361,6 +381,189 @@ func (client *Client) request(ctx context.Context, method string, params any, ta
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) ensureWriter() {
|
||||
_ = client.startWriter()
|
||||
}
|
||||
|
||||
func (client *Client) startWriter() error {
|
||||
client.writeMu.Lock()
|
||||
defer client.writeMu.Unlock()
|
||||
if client.writeClosed {
|
||||
return errMCPClientClosed
|
||||
}
|
||||
if client.writeQueue != nil {
|
||||
return nil
|
||||
}
|
||||
queue := make(chan writeOp, writeQueueCapacity)
|
||||
client.writeQueue = queue
|
||||
client.writerStop = make(chan struct{})
|
||||
client.writerDone = make(chan struct{})
|
||||
go client.writeLoop(queue)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) writerStopped() bool {
|
||||
if client.writerStop == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-client.writerStop:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) beginWriterShutdown() {
|
||||
client.writeMu.Lock()
|
||||
defer client.writeMu.Unlock()
|
||||
if client.writeClosed {
|
||||
return
|
||||
}
|
||||
client.writeClosed = true
|
||||
if client.writerStop != nil {
|
||||
close(client.writerStop)
|
||||
}
|
||||
for _, op := range client.courtesyOverflow {
|
||||
if op.done != nil {
|
||||
op.done <- errMCPClientClosed
|
||||
}
|
||||
}
|
||||
client.courtesyOverflow = nil
|
||||
}
|
||||
|
||||
func (client *Client) finishWriterShutdown() {
|
||||
client.writeMu.Lock()
|
||||
queue := client.writeQueue
|
||||
done := client.writerDone
|
||||
client.writeQueue = nil
|
||||
client.writeMu.Unlock()
|
||||
if queue == nil {
|
||||
return
|
||||
}
|
||||
client.writeSenders.Wait()
|
||||
close(queue)
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) writeLoop(queue <-chan writeOp) {
|
||||
defer close(client.writerDone)
|
||||
if queue == nil {
|
||||
return
|
||||
}
|
||||
for op := range queue {
|
||||
if client.writerStopped() {
|
||||
if op.done != nil {
|
||||
op.done <- errMCPClientClosed
|
||||
}
|
||||
continue
|
||||
}
|
||||
if op.ctx != nil {
|
||||
select {
|
||||
case <-op.ctx.Done():
|
||||
if op.done != nil {
|
||||
op.done <- op.ctx.Err()
|
||||
}
|
||||
continue
|
||||
default:
|
||||
}
|
||||
}
|
||||
err := client.writer.write(op.message)
|
||||
if op.done != nil {
|
||||
op.done <- err
|
||||
}
|
||||
client.drainCourtesyOverflow()
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) drainCourtesyOverflow() {
|
||||
client.writeMu.Lock()
|
||||
defer client.writeMu.Unlock()
|
||||
if client.writeClosed || client.writeQueue == nil {
|
||||
client.courtesyOverflow = nil
|
||||
return
|
||||
}
|
||||
for len(client.courtesyOverflow) > 0 {
|
||||
select {
|
||||
case client.writeQueue <- client.courtesyOverflow[0]:
|
||||
client.courtesyOverflow = client.courtesyOverflow[1:]
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) enqueueCourtesy(message rpcMessage) {
|
||||
if err := client.startWriter(); err != nil {
|
||||
return
|
||||
}
|
||||
op := writeOp{message: message}
|
||||
client.writeMu.Lock()
|
||||
defer client.writeMu.Unlock()
|
||||
if client.writeClosed || client.writeQueue == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case client.writeQueue <- op:
|
||||
default:
|
||||
if len(client.courtesyOverflow) >= courtesyOverflowCap {
|
||||
return
|
||||
}
|
||||
client.courtesyOverflow = append(client.courtesyOverflow, op)
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) writeMessage(ctx context.Context, message rpcMessage) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.startWriter(); err != nil {
|
||||
return err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
op := writeOp{ctx: ctx, message: message, done: done}
|
||||
|
||||
client.writeMu.Lock()
|
||||
if client.writeClosed {
|
||||
client.writeMu.Unlock()
|
||||
return errMCPClientClosed
|
||||
}
|
||||
stop := client.writerStop
|
||||
queue := client.writeQueue
|
||||
client.writeSenders.Add(1)
|
||||
client.writeMu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
client.writeSenders.Done()
|
||||
return ctx.Err()
|
||||
case <-stop:
|
||||
client.writeSenders.Done()
|
||||
return errMCPClientClosed
|
||||
case queue <- op:
|
||||
client.writeSenders.Done()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-stop:
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errMCPClientClosed
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
case err := <-done:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ensureReader lazily starts the single reader goroutine. It runs once per
|
||||
// client; subsequent calls are no-ops.
|
||||
func (client *Client) ensureReader() {
|
||||
@@ -385,6 +588,20 @@ func (client *Client) readLoop() {
|
||||
client.failAll(err)
|
||||
return
|
||||
}
|
||||
// A message with a Method is a server-initiated request or notification.
|
||||
// It must never be routed as a response to a pending client request.
|
||||
if message.isRequestOrNotification() {
|
||||
if message.ID != nil && jsonRPCIDEchoable(message.ID) {
|
||||
client.enqueueCourtesy(rpcMessage{
|
||||
ID: message.ID,
|
||||
Error: &rpcError{
|
||||
Code: -32601,
|
||||
Message: fmt.Sprintf("Method %q not supported", message.Method),
|
||||
},
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if message.ID == nil {
|
||||
continue
|
||||
}
|
||||
@@ -428,6 +645,20 @@ func (client *Client) failAll(err error) {
|
||||
|
||||
// rpcMessageID extracts the integer id from a JSON-RPC id value across the
|
||||
// numeric/string encodings a server may use.
|
||||
func jsonNumberAsInt(n json.Number) (int64, bool) {
|
||||
if parsed, err := n.Int64(); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || f != math.Trunc(f) {
|
||||
return 0, false
|
||||
}
|
||||
if f > float64(math.MaxInt64) || f < float64(math.MinInt64) {
|
||||
return 0, false
|
||||
}
|
||||
return int64(f), true
|
||||
}
|
||||
|
||||
func rpcMessageID(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
@@ -435,10 +666,13 @@ func rpcMessageID(value any) (int, bool) {
|
||||
case int64:
|
||||
return int(typed), true
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed != math.Trunc(typed) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
if err != nil {
|
||||
parsed, ok := jsonNumberAsInt(typed)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(parsed), true
|
||||
@@ -454,18 +688,30 @@ func rpcMessageID(value any) (int, bool) {
|
||||
}
|
||||
|
||||
func rpcIDMatches(value any, id int) bool {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed == id
|
||||
case int64:
|
||||
return typed == int64(id)
|
||||
case float64:
|
||||
return typed == float64(id)
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
return err == nil && parsed == int64(id)
|
||||
got, ok := rpcMessageID(value)
|
||||
return ok && got == id
|
||||
}
|
||||
|
||||
// jsonRPCIDEchoable reports whether id is a valid JSON-RPC 2.0 identifier type
|
||||
// (string or finite number) that is safe to echo back.
|
||||
func jsonRPCIDEchoable(id any) bool {
|
||||
if id == nil {
|
||||
return false
|
||||
}
|
||||
switch v := id.(type) {
|
||||
case string:
|
||||
return typed == strconv.Itoa(id)
|
||||
return true
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
case float64:
|
||||
return !math.IsNaN(v) && !math.IsInf(v, 0)
|
||||
case json.Number:
|
||||
parsed, err := v.Float64()
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return false
|
||||
}
|
||||
_, err = json.Marshal(v)
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -476,7 +722,7 @@ func (client *Client) notify(method string, params any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.writer.write(rpcMessage{
|
||||
return client.writeMessage(context.Background(), rpcMessage{
|
||||
Method: method,
|
||||
Params: rawParams,
|
||||
})
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -842,3 +846,837 @@ func TestBoundedBufferCapsRetainedBytes(t *testing.T) {
|
||||
t.Fatalf("retained %q, want %q (capped at 8 bytes, head kept)", got, "hellowor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientIgnoresServerInitiatedRequestsInPendingResponses(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
|
||||
client.ensureReader()
|
||||
|
||||
// Register a pending response for ID 1
|
||||
responses := make(chan dispatchResult, 1)
|
||||
client.dispatchMu.Lock()
|
||||
client.pending[1] = responses
|
||||
client.dispatchMu.Unlock()
|
||||
|
||||
// Simulate server sending a request with ID 1 ("roots/list")
|
||||
serverReq := `{"jsonrpc":"2.0","id":1,"method":"roots/list","params":{}}` + "\n"
|
||||
if _, err := inWriter.Write([]byte(serverReq)); err != nil {
|
||||
t.Fatalf("write server request: %v", err)
|
||||
}
|
||||
|
||||
// The server request must take the separate request path: it receives a
|
||||
// method-not-found response and cannot resolve the pending client call.
|
||||
courtesyResult := make(chan dispatchResult, 1)
|
||||
go func() {
|
||||
message, err := newMessageReader(outReader).read()
|
||||
courtesyResult <- dispatchResult{message: message, err: err}
|
||||
}()
|
||||
select {
|
||||
case result := <-courtesyResult:
|
||||
if result.err != nil {
|
||||
t.Fatalf("read courtesy response: %v", result.err)
|
||||
}
|
||||
if !rpcIDMatches(result.message.ID, 1) || result.message.Error == nil || result.message.Error.Code != -32601 {
|
||||
t.Fatalf("courtesy response = %#v, want id 1 and error -32601", result.message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for method-not-found response")
|
||||
}
|
||||
select {
|
||||
case res := <-responses:
|
||||
t.Fatalf("pending request 1 received server request: %#v", res.message)
|
||||
default:
|
||||
}
|
||||
|
||||
// Now send the actual response for ID 1
|
||||
serverResp := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n"
|
||||
if _, err := inWriter.Write([]byte(serverResp)); err != nil {
|
||||
t.Fatalf("write server response: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case res := <-responses:
|
||||
if res.message.Method != "" || len(res.message.Result) == 0 {
|
||||
t.Fatalf("expected valid response, got %#v", res.message)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for actual response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStdioClientUndrainedServerDoesNotStallReadLoop proves that when a server
|
||||
// stops draining its stdin, an asynchronous courtesy -32601 reply does not stall
|
||||
// the read loop from servicing legitimate responses.
|
||||
func TestStdioClientUndrainedServerDoesNotStallReadLoop(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
|
||||
client.ensureReader()
|
||||
|
||||
// 1. Register a pending response for call ID 1
|
||||
responses := make(chan dispatchResult, 1)
|
||||
client.dispatchMu.Lock()
|
||||
client.pending[1] = responses
|
||||
client.dispatchMu.Unlock()
|
||||
|
||||
// 2. Server sends request ID 2 (we DO NOT drain outReader so server stdin pipe is blocked)
|
||||
serverReq := `{"jsonrpc":"2.0","id":2,"method":"roots/list","params":{}}` + "\n"
|
||||
go func() {
|
||||
_, _ = inWriter.Write([]byte(serverReq))
|
||||
// 3. Immediately send response for ID 1
|
||||
serverResp := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n"
|
||||
_, _ = inWriter.Write([]byte(serverResp))
|
||||
}()
|
||||
|
||||
// 4. Verify the response for ID 1 is delivered without being stalled by the undrained pipe
|
||||
select {
|
||||
case res := <-responses:
|
||||
if res.message.Method != "" || len(res.message.Result) == 0 {
|
||||
t.Fatalf("expected valid response for ID 1, got %#v", res.message)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("readLoop stalled on undrained error write; pending response was not delivered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientDropsInvalidServerRequestIDs(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
|
||||
client.ensureReader()
|
||||
|
||||
// Server sends request with boolean ID (invalid JSON-RPC id)
|
||||
serverReq := `{"jsonrpc":"2.0","id":true,"method":"roots/list","params":{}}` + "\n"
|
||||
if _, err := inWriter.Write([]byte(serverReq)); err != nil {
|
||||
t.Fatalf("write invalid server request: %v", err)
|
||||
}
|
||||
|
||||
// A following valid request acts as an ordering barrier: receiving its
|
||||
// response proves readLoop already processed the invalid request. Because the
|
||||
// writer is serial, any incorrect reply to the boolean ID would arrive first.
|
||||
validReq := `{"jsonrpc":"2.0","id":7,"method":"roots/list","params":{}}` + "\n"
|
||||
if _, err := inWriter.Write([]byte(validReq)); err != nil {
|
||||
t.Fatalf("write valid server request: %v", err)
|
||||
}
|
||||
readResult := make(chan dispatchResult, 1)
|
||||
go func() {
|
||||
message, err := newMessageReader(outReader).read()
|
||||
readResult <- dispatchResult{message: message, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-readResult:
|
||||
if result.err != nil {
|
||||
t.Fatalf("read valid request response: %v", result.err)
|
||||
}
|
||||
if !rpcIDMatches(result.message.ID, 7) || result.message.Error == nil || result.message.Error.Code != -32601 {
|
||||
t.Fatalf("response = %#v, want only valid id 7 and error -32601", result.message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for valid request response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientRepliesToValidNonIntegerServerRequestIDs(t *testing.T) {
|
||||
requests := []struct {
|
||||
name string
|
||||
wire string
|
||||
want string
|
||||
}{
|
||||
{name: "fractional", wire: `{"jsonrpc":"2.0","id":1.5,"method":"roots/list","params":{}}` + "\n", want: "1.5"},
|
||||
{name: "exponent", wire: `{"jsonrpc":"2.0","id":2e2,"method":"roots/list","params":{}}` + "\n", want: "2e2"},
|
||||
}
|
||||
for _, request := range requests {
|
||||
t.Run(request.name, func(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
})
|
||||
client.ensureReader()
|
||||
if _, err := inWriter.Write([]byte(request.wire)); err != nil {
|
||||
t.Fatalf("write server request: %v", err)
|
||||
}
|
||||
result := make(chan dispatchResult, 1)
|
||||
go func() {
|
||||
response, err := newMessageReader(outReader).read()
|
||||
result <- dispatchResult{message: response, err: err}
|
||||
}()
|
||||
select {
|
||||
case response := <-result:
|
||||
if response.err != nil {
|
||||
t.Fatalf("read method-not-found response: %v", response.err)
|
||||
}
|
||||
got, err := json.Marshal(response.message.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal id: %v", err)
|
||||
}
|
||||
if string(got) != request.want || response.message.Error == nil || response.message.Error.Code != -32601 {
|
||||
t.Fatalf("response = %#v, want id %s and error -32601", response.message, request.want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for method-not-found response")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONRPCIDEchoableAcceptsFiniteJSONNumbers(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
id any
|
||||
want bool
|
||||
}{
|
||||
{name: "fractional float", id: 1.5, want: true},
|
||||
{name: "exponent json number", id: json.Number("2e2"), want: true},
|
||||
{name: "not a number", id: math.NaN(), want: false},
|
||||
{name: "positive infinity", id: math.Inf(1), want: false},
|
||||
{name: "invalid json number", id: json.Number("not-a-number"), want: false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := jsonRPCIDEchoable(test.id); got != test.want {
|
||||
t.Fatalf("jsonRPCIDEchoable(%v) = %v, want %v", test.id, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type gatedCaptureWriter struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
startedOnce sync.Once
|
||||
releaseOnce sync.Once
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
}
|
||||
|
||||
func newGatedCaptureWriter() *gatedCaptureWriter {
|
||||
return &gatedCaptureWriter{
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *gatedCaptureWriter) Write(p []byte) (int, error) {
|
||||
writer.startedOnce.Do(func() { close(writer.started) })
|
||||
<-writer.release
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return writer.buffer.Write(p)
|
||||
}
|
||||
|
||||
func (writer *gatedCaptureWriter) Release() {
|
||||
writer.releaseOnce.Do(func() { close(writer.release) })
|
||||
}
|
||||
|
||||
func (writer *gatedCaptureWriter) Bytes() []byte {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return append([]byte(nil), writer.buffer.Bytes()...)
|
||||
}
|
||||
|
||||
func TestStdioClientDoesNotWriteCanceledQueuedRequest(t *testing.T) {
|
||||
reader := newBlockingReader()
|
||||
defer reader.Close()
|
||||
|
||||
output := newGatedCaptureWriter()
|
||||
defer output.Release()
|
||||
client := &Client{
|
||||
reader: newMessageReader(reader),
|
||||
writer: newMessageWriter(output),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
|
||||
blockerDone := make(chan error, 1)
|
||||
go func() {
|
||||
blockerDone <- client.writeMessage(context.Background(), rpcMessage{Method: "notifications/blocker"})
|
||||
}()
|
||||
select {
|
||||
case <-output.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("initial write did not reach the blocked transport")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := client.request(ctx, "tools/call", map[string]any{"name": "side_effect"}, nil); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("request() error = %v, want context deadline exceeded", err)
|
||||
}
|
||||
|
||||
output.Release()
|
||||
if err := <-blockerDone; err != nil {
|
||||
t.Fatalf("release initial write: %v", err)
|
||||
}
|
||||
if err := client.writeMessage(context.Background(), rpcMessage{Method: "notifications/sentinel"}); err != nil {
|
||||
t.Fatalf("write sentinel: %v", err)
|
||||
}
|
||||
|
||||
messages := newMessageReader(bytes.NewReader(output.Bytes()))
|
||||
first, err := messages.read()
|
||||
if err != nil {
|
||||
t.Fatalf("read initial message: %v", err)
|
||||
}
|
||||
if first.Method != "notifications/blocker" {
|
||||
t.Fatalf("first method = %q, want notifications/blocker", first.Method)
|
||||
}
|
||||
second, err := messages.read()
|
||||
if err != nil {
|
||||
t.Fatalf("read sentinel message: %v", err)
|
||||
}
|
||||
if second.Method != "notifications/sentinel" {
|
||||
t.Fatalf("second method = %q, want notifications/sentinel", second.Method)
|
||||
}
|
||||
if extra, err := messages.read(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("unexpected queued message %#v, read error = %v", extra, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStdioClientUndrainedServerDoesNotBlockCallerWithDeadline verifies that
|
||||
// when the server's input pipe is completely blocked and courtesy replies are
|
||||
// queued/dropped, a caller invoking request() with a deadline aborts cleanly
|
||||
// when the context expires rather than hanging indefinitely on a write or mutex.
|
||||
func TestStdioClientUndrainedServerDoesNotBlockCallerWithDeadline(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
|
||||
client.ensureReader()
|
||||
|
||||
// Flood server requests to saturate write queue while outReader is NOT drained.
|
||||
for i := 1; i <= 50; i++ {
|
||||
serverReq := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"roots/list","params":{}}`+"\n", i+100)
|
||||
_, err := inWriter.Write([]byte(serverReq))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write server request: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Caller with short deadline invokes request()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := client.request(ctx, "tools/list", map[string]any{}, nil)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected deadline exceeded, got: %v", err)
|
||||
}
|
||||
if elapsed > 1*time.Second {
|
||||
t.Fatalf("request took too long to abort on deadline: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientEmptyMethodDoesNotCompletePending(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
client.ensureReader()
|
||||
|
||||
responses := make(chan dispatchResult, 1)
|
||||
client.dispatchMu.Lock()
|
||||
client.pending[1] = responses
|
||||
client.dispatchMu.Unlock()
|
||||
|
||||
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":""}` + "\n")); err != nil {
|
||||
t.Fatalf("write empty-method frame: %v", err)
|
||||
}
|
||||
|
||||
courtesyResult := make(chan dispatchResult, 1)
|
||||
go func() {
|
||||
message, err := newMessageReader(outReader).read()
|
||||
courtesyResult <- dispatchResult{message: message, err: err}
|
||||
}()
|
||||
select {
|
||||
case result := <-courtesyResult:
|
||||
if result.err != nil {
|
||||
t.Fatalf("read courtesy response: %v", result.err)
|
||||
}
|
||||
if !rpcIDMatches(result.message.ID, 1) || result.message.Error == nil || result.message.Error.Code != -32601 {
|
||||
t.Fatalf("courtesy response = %#v, want id 1 and error -32601", result.message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for method-not-found response")
|
||||
}
|
||||
select {
|
||||
case res := <-responses:
|
||||
t.Fatalf("pending request 1 completed by empty-method frame: %#v", res.message)
|
||||
default:
|
||||
}
|
||||
|
||||
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n")); err != nil {
|
||||
t.Fatalf("write server response: %v", err)
|
||||
}
|
||||
select {
|
||||
case res := <-responses:
|
||||
if res.message.Method != "" || len(res.message.Result) == 0 {
|
||||
t.Fatalf("expected valid response, got %#v", res.message)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for actual response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientNullMethodDoesNotCompletePending(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
client.ensureReader()
|
||||
|
||||
responses := make(chan dispatchResult, 1)
|
||||
client.dispatchMu.Lock()
|
||||
client.pending[1] = responses
|
||||
client.dispatchMu.Unlock()
|
||||
|
||||
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":null}` + "\n")); err != nil {
|
||||
t.Fatalf("write null-method frame: %v", err)
|
||||
}
|
||||
|
||||
courtesyResult := make(chan dispatchResult, 1)
|
||||
go func() {
|
||||
message, err := newMessageReader(outReader).read()
|
||||
courtesyResult <- dispatchResult{message: message, err: err}
|
||||
}()
|
||||
select {
|
||||
case result := <-courtesyResult:
|
||||
if result.err != nil {
|
||||
t.Fatalf("read courtesy response: %v", result.err)
|
||||
}
|
||||
if !rpcIDMatches(result.message.ID, 1) || result.message.Error == nil || result.message.Error.Code != -32601 {
|
||||
t.Fatalf("courtesy response = %#v, want id 1 and error -32601", result.message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for method-not-found response")
|
||||
}
|
||||
select {
|
||||
case res := <-responses:
|
||||
t.Fatalf("pending request 1 completed by null-method frame: %#v", res.message)
|
||||
default:
|
||||
}
|
||||
|
||||
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n")); err != nil {
|
||||
t.Fatalf("write server response: %v", err)
|
||||
}
|
||||
select {
|
||||
case res := <-responses:
|
||||
if res.message.Method != "" || len(res.message.Result) == 0 {
|
||||
t.Fatalf("expected valid response, got %#v", res.message)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for actual response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientCourtesyReplySurvivesFullWriteQueue(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
client.ensureReader()
|
||||
|
||||
responses := make(chan dispatchResult, 1)
|
||||
client.dispatchMu.Lock()
|
||||
client.pending[1] = responses
|
||||
client.dispatchMu.Unlock()
|
||||
|
||||
const extra = 8
|
||||
n := writeQueueCapacity + extra
|
||||
for i := 0; i < n; i++ {
|
||||
frame := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"roots/list","params":{}}`+"\n", i+100)
|
||||
if _, err := inWriter.Write([]byte(frame)); err != nil {
|
||||
t.Fatalf("write server request %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}` + "\n")); err != nil {
|
||||
t.Fatalf("write pending response: %v", err)
|
||||
}
|
||||
select {
|
||||
case res := <-responses:
|
||||
if res.err != nil || len(res.message.Result) == 0 {
|
||||
t.Fatalf("pending response = %#v err=%v", res.message, res.err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("readLoop stalled while the write queue was full")
|
||||
}
|
||||
|
||||
drainDone := make(chan []rpcMessage, 1)
|
||||
go func() {
|
||||
reader := newMessageReader(outReader)
|
||||
var got []rpcMessage
|
||||
for len(got) < n {
|
||||
message, err := reader.read()
|
||||
if err != nil {
|
||||
drainDone <- got
|
||||
return
|
||||
}
|
||||
got = append(got, message)
|
||||
}
|
||||
drainDone <- got
|
||||
}()
|
||||
select {
|
||||
case got := <-drainDone:
|
||||
if len(got) != n {
|
||||
t.Fatalf("courtesy replies = %d, want %d (queue-full requests dropped)", len(got), n)
|
||||
}
|
||||
seen := make(map[int]bool, n)
|
||||
for _, message := range got {
|
||||
id, ok := rpcMessageID(message.ID)
|
||||
if !ok || message.Error == nil || message.Error.Code != -32601 {
|
||||
t.Fatalf("courtesy reply = %#v, want -32601", message)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if !seen[i+100] {
|
||||
t.Fatalf("missing courtesy reply for id %d after output resumed", i+100)
|
||||
}
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out draining courtesy replies after output resumed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterLoopExitsOnRepeatedClose(t *testing.T) {
|
||||
before := runtime.NumGoroutine()
|
||||
for i := 0; i < 25; i++ {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
copied := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(io.Discard, outReader)
|
||||
close(copied)
|
||||
}()
|
||||
client := &Client{
|
||||
stdin: outWriter,
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
}
|
||||
client.ensureWriter()
|
||||
if err := client.writeMessage(context.Background(), rpcMessage{Method: "notifications/ping"}); err != nil {
|
||||
t.Fatalf("writeMessage: %v", err)
|
||||
}
|
||||
if err := client.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
_ = inWriter.Close()
|
||||
<-copied
|
||||
}
|
||||
runtime.GC()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
after := runtime.NumGoroutine()
|
||||
if after > before+8 {
|
||||
t.Fatalf("goroutines leaked: before=%d after=%d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMessageReleasedOnClose(t *testing.T) {
|
||||
reader := newBlockingReader()
|
||||
defer reader.Close()
|
||||
output := newGatedCaptureWriter()
|
||||
client := &Client{
|
||||
reader: newMessageReader(reader),
|
||||
writer: newMessageWriter(output),
|
||||
}
|
||||
|
||||
blockerDone := make(chan error, 1)
|
||||
go func() {
|
||||
blockerDone <- client.writeMessage(context.Background(), rpcMessage{Method: "notifications/blocker"})
|
||||
}()
|
||||
select {
|
||||
case <-output.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("initial write did not reach the transport")
|
||||
}
|
||||
|
||||
queuedDone := make(chan error, 1)
|
||||
go func() {
|
||||
queuedDone <- client.writeMessage(context.Background(), rpcMessage{Method: "notifications/queued"})
|
||||
}()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for len(client.writeQueue) == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("queued write did not enqueue")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
closeDone := make(chan error, 1)
|
||||
go func() {
|
||||
closeDone <- client.Close()
|
||||
}()
|
||||
select {
|
||||
case <-client.writerStop:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not begin writer shutdown")
|
||||
}
|
||||
output.Release()
|
||||
select {
|
||||
case err := <-closeDone:
|
||||
if err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Close hung waiting for the writer worker")
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-queuedDone:
|
||||
if err == nil {
|
||||
t.Fatal("queued write succeeded after Close")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("queued write was not released on Close")
|
||||
}
|
||||
select {
|
||||
case <-blockerDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("blocked write was not released on Close")
|
||||
}
|
||||
|
||||
messages := newMessageReader(bytes.NewReader(output.Bytes()))
|
||||
for {
|
||||
message, err := messages.read()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read captured output: %v", err)
|
||||
}
|
||||
if message.Method == "notifications/queued" {
|
||||
t.Fatal("queued request was written after Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientPreservesLargeNumericRequestID(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
client.ensureReader()
|
||||
|
||||
const rawID = "9007199254740993"
|
||||
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":` + rawID + `,"method":"roots/list","params":{}}` + "\n")); err != nil {
|
||||
t.Fatalf("write server request: %v", err)
|
||||
}
|
||||
|
||||
lineDone := make(chan struct {
|
||||
line string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
line, err := bufio.NewReader(outReader).ReadString('\n')
|
||||
lineDone <- struct {
|
||||
line string
|
||||
err error
|
||||
}{line: line, err: err}
|
||||
}()
|
||||
select {
|
||||
case got := <-lineDone:
|
||||
if got.err != nil {
|
||||
t.Fatalf("read courtesy response: %v", got.err)
|
||||
}
|
||||
if !strings.Contains(got.line, rawID) {
|
||||
t.Fatalf("serialized courtesy response %q does not preserve id %s", got.line, rawID)
|
||||
}
|
||||
if !strings.Contains(got.line, `"-32601"`) && !strings.Contains(got.line, `-32601`) {
|
||||
t.Fatalf("serialized courtesy response %q missing -32601", got.line)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for courtesy response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCMessageIDAcceptsExponentForm(t *testing.T) {
|
||||
got, ok := rpcMessageID(json.Number("1e0"))
|
||||
if !ok || got != 1 {
|
||||
t.Fatalf("rpcMessageID(1e0) = %d, %v, want 1, true", got, ok)
|
||||
}
|
||||
got, ok = rpcMessageID(json.Number("2e2"))
|
||||
if !ok || got != 200 {
|
||||
t.Fatalf("rpcMessageID(2e2) = %d, %v, want 200, true", got, ok)
|
||||
}
|
||||
if _, ok := rpcMessageID(json.Number("1.5")); ok {
|
||||
t.Fatal("fractional json.Number should not match")
|
||||
}
|
||||
if rpcIDMatches(json.Number("1e0"), 1) != true {
|
||||
t.Fatal("rpcIDMatches(1e0, 1) = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientMatchesExponentFormResponseID(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
client.ensureReader()
|
||||
|
||||
responses := make(chan dispatchResult, 1)
|
||||
client.dispatchMu.Lock()
|
||||
client.pending[1] = responses
|
||||
client.dispatchMu.Unlock()
|
||||
|
||||
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1e0,"result":{"ok":true}}` + "\n")); err != nil {
|
||||
t.Fatalf("write exponent-id response: %v", err)
|
||||
}
|
||||
select {
|
||||
case res := <-responses:
|
||||
if res.err != nil || len(res.message.Result) == 0 {
|
||||
t.Fatalf("pending 1 not completed by id 1e0: %#v err=%v", res.message, res.err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("response id 1e0 did not resolve pending request 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientCourtesyOverflowIsBounded(t *testing.T) {
|
||||
inReader, inWriter := io.Pipe()
|
||||
outReader, outWriter := io.Pipe()
|
||||
client := &Client{
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
pending: make(map[int]chan dispatchResult),
|
||||
}
|
||||
defer func() {
|
||||
_ = inWriter.Close()
|
||||
_ = outReader.Close()
|
||||
}()
|
||||
client.ensureReader()
|
||||
|
||||
responses := make(chan dispatchResult, 1)
|
||||
client.dispatchMu.Lock()
|
||||
client.pending[1] = responses
|
||||
client.dispatchMu.Unlock()
|
||||
|
||||
flood := writeQueueCapacity + courtesyOverflowCap + 64
|
||||
for i := 0; i < flood; i++ {
|
||||
frame := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"roots/list","params":{}}`+"\n", i+100)
|
||||
if _, err := inWriter.Write([]byte(frame)); err != nil {
|
||||
t.Fatalf("write server request %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}` + "\n")); err != nil {
|
||||
t.Fatalf("write pending response: %v", err)
|
||||
}
|
||||
select {
|
||||
case res := <-responses:
|
||||
if res.err != nil {
|
||||
t.Fatalf("pending stalled: %v", res.err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("readLoop stalled during courtesy flood")
|
||||
}
|
||||
|
||||
client.writeMu.Lock()
|
||||
n := len(client.courtesyOverflow)
|
||||
client.writeMu.Unlock()
|
||||
if n > courtesyOverflowCap {
|
||||
t.Fatalf("courtesyOverflow = %d, want <= %d", n, courtesyOverflowCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterLoopCloseBeforeScheduleDoesNotHang(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
client := &Client{
|
||||
writer: newMessageWriter(io.Discard),
|
||||
}
|
||||
if err := client.startWriter(); err != nil {
|
||||
t.Fatalf("startWriter: %v", err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- client.Close()
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Close hung: writeLoop ranged over a nil queue")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,15 @@ type blockingReader struct {
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
type signalingWriter struct {
|
||||
writes chan struct{}
|
||||
}
|
||||
|
||||
func (writer *signalingWriter) Write(p []byte) (int, error) {
|
||||
writer.writes <- struct{}{}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func newBlockingReader() *blockingReader {
|
||||
return &blockingReader{release: make(chan struct{})}
|
||||
}
|
||||
@@ -82,14 +91,15 @@ func (reader *blockingReader) Close() error {
|
||||
|
||||
// A hung stdio server must not block Client.request forever: a cancelled
|
||||
// per-call context must unblock the wait and surface ctx.Err(), and it must
|
||||
// not hold client.mu (a second caller must still be able to proceed).
|
||||
// not hold shared client state that prevents a second caller from proceeding.
|
||||
func TestClientRequestUnblocksOnContextCancel(t *testing.T) {
|
||||
reader := newBlockingReader()
|
||||
defer reader.Close()
|
||||
writes := make(chan struct{}, 2)
|
||||
|
||||
client := &Client{
|
||||
reader: newMessageReader(reader),
|
||||
writer: newMessageWriter(io.Discard),
|
||||
writer: newMessageWriter(&signalingWriter{writes: writes}),
|
||||
nextID: 1,
|
||||
}
|
||||
|
||||
@@ -99,6 +109,11 @@ func TestClientRequestUnblocksOnContextCancel(t *testing.T) {
|
||||
done <- client.request(ctx, "tools/list", map[string]any{}, nil)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-writes:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first request did not reach the transport")
|
||||
}
|
||||
// The request is now parked waiting for a response that never comes.
|
||||
cancel()
|
||||
|
||||
@@ -111,21 +126,27 @@ func TestClientRequestUnblocksOnContextCancel(t *testing.T) {
|
||||
t.Fatal("request() hung on a non-responsive server")
|
||||
}
|
||||
|
||||
// The lock must be free: a second request under an already-cancelled
|
||||
// context must return immediately rather than block.
|
||||
cancelled, cancel2 := context.WithCancel(context.Background())
|
||||
cancel2()
|
||||
// The shared state must be free: prove a second live request reaches the
|
||||
// transport, then cancel it and verify cancellation releases the caller.
|
||||
secondCtx, cancel2 := context.WithCancel(context.Background())
|
||||
defer cancel2()
|
||||
second := make(chan error, 1)
|
||||
go func() {
|
||||
second <- client.request(cancelled, "tools/list", map[string]any{}, nil)
|
||||
second <- client.request(secondCtx, "tools/list", map[string]any{}, nil)
|
||||
}()
|
||||
select {
|
||||
case <-writes:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("second request did not reach the transport")
|
||||
}
|
||||
cancel2()
|
||||
select {
|
||||
case err := <-second:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("second request() error = %v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("second request() blocked — client.mu was held across the hung read")
|
||||
t.Fatal("second request() blocked behind the hung read")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -539,6 +539,9 @@ func (client *remoteSSEClient) deliverEventMessage(value string) error {
|
||||
if err := decoder.Decode(&message); err != nil {
|
||||
return fmt.Errorf("decode MCP SSE stream message: %w", err)
|
||||
}
|
||||
if message.isRequestOrNotification() {
|
||||
return nil
|
||||
}
|
||||
key := rpcResponseKey(message.ID)
|
||||
if key == "" {
|
||||
return nil
|
||||
@@ -633,7 +636,7 @@ func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
|
||||
// those — the response has no method — and keep scanning. Previously the
|
||||
// first message event was returned unconditionally, so a leading
|
||||
// notification surfaced to the caller as an id mismatch and failed the call.
|
||||
if candidate.Method != "" {
|
||||
if candidate.isRequestOrNotification() {
|
||||
return true
|
||||
}
|
||||
decoded = candidate
|
||||
|
||||
@@ -339,3 +339,60 @@ func TestDecodeSSERPCMessageSkipsNotifications(t *testing.T) {
|
||||
t.Fatalf("expected a result payload, got %#v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSSERPCMessageSkipsEmptyAndNullMethod(t *testing.T) {
|
||||
stream := "event: message\n" +
|
||||
`data: {"jsonrpc":"2.0","id":7,"method":""}` + "\n\n" +
|
||||
"event: message\n" +
|
||||
`data: {"jsonrpc":"2.0","id":7,"method":null}` + "\n\n" +
|
||||
"event: message\n" +
|
||||
`data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}` + "\n\n"
|
||||
|
||||
msg, err := decodeSSERPCMessage(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeSSERPCMessage: %v", err)
|
||||
}
|
||||
if msg.isRequestOrNotification() {
|
||||
t.Fatalf("empty/null method must not be treated as the response, got method %q present=%v", msg.Method, msg.methodPresent)
|
||||
}
|
||||
if !rpcIDMatches(msg.ID, 7) {
|
||||
t.Fatalf("expected response id 7, got %#v", msg.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverEventMessageSkipsMethodPresence(t *testing.T) {
|
||||
client := &remoteSSEClient{pending: map[string]chan ssePendingResponse{}}
|
||||
key := rpcResponseKey(1)
|
||||
pending := make(chan ssePendingResponse, 1)
|
||||
client.pending[key] = pending
|
||||
|
||||
if err := client.deliverEventMessage(`{"jsonrpc":"2.0","id":1,"method":""}`); err != nil {
|
||||
t.Fatalf("deliverEventMessage empty method: %v", err)
|
||||
}
|
||||
if err := client.deliverEventMessage(`{"jsonrpc":"2.0","id":1,"method":null}`); err != nil {
|
||||
t.Fatalf("deliverEventMessage null method: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-pending:
|
||||
t.Fatalf("method presence must not complete pending, got %#v", got)
|
||||
default:
|
||||
}
|
||||
if _, ok := client.pending[key]; !ok {
|
||||
t.Fatal("deliverEventMessage must not delete pending for a request/notification")
|
||||
}
|
||||
|
||||
if err := client.deliverEventMessage(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`); err != nil {
|
||||
t.Fatalf("deliverEventMessage response: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-pending:
|
||||
if got.err != nil {
|
||||
t.Fatalf("true response: %v", got.err)
|
||||
}
|
||||
if got.message.isRequestOrNotification() {
|
||||
t.Fatal("true response must not carry a method")
|
||||
}
|
||||
default:
|
||||
t.Fatal("true response must complete pending")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,12 +18,49 @@ import (
|
||||
const maxMessageBytes = 64 * 1024 * 1024
|
||||
|
||||
type rpcMessage struct {
|
||||
JSONRPC string `json:"jsonrpc,omitempty"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
JSONRPC string `json:"jsonrpc,omitempty"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
methodPresent bool `json:"-"`
|
||||
}
|
||||
|
||||
func (m rpcMessage) isRequestOrNotification() bool {
|
||||
return m.methodPresent || m.Method != ""
|
||||
}
|
||||
|
||||
func (m *rpcMessage) UnmarshalJSON(data []byte) error {
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
var wire struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := decoder.Decode(&wire); err != nil {
|
||||
return err
|
||||
}
|
||||
m.JSONRPC = wire.JSONRPC
|
||||
m.ID = wire.ID
|
||||
m.Params = wire.Params
|
||||
m.Result = wire.Result
|
||||
m.Error = wire.Error
|
||||
if raw, ok := probe["method"]; ok {
|
||||
m.methodPresent = true
|
||||
var method string
|
||||
if err := json.Unmarshal(raw, &method); err == nil {
|
||||
m.Method = method
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRPCMessageMethodPresence(t *testing.T) {
|
||||
var req rpcMessage
|
||||
if err := json.Unmarshal([]byte(`{"jsonrpc":"2.0","id":1,"method":""}`), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !req.methodPresent {
|
||||
t.Fatal(`{"method":""} must set methodPresent so it is not routed as a response`)
|
||||
}
|
||||
var resp rpcMessage
|
||||
if err := json.Unmarshal([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.methodPresent {
|
||||
t.Fatal("response without method member must not set methodPresent")
|
||||
}
|
||||
if resp.Method != "" {
|
||||
t.Fatalf("response Method = %q", resp.Method)
|
||||
}
|
||||
var nullMethod rpcMessage
|
||||
if err := json.Unmarshal([]byte(`{"jsonrpc":"2.0","id":1,"method":null}`), &nullMethod); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !nullMethod.methodPresent {
|
||||
t.Fatal(`{"method":null} must set methodPresent so it is not routed as a response`)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ const defaultConnectTimeout = 8 * time.Second
|
||||
type RegisterOptions struct {
|
||||
PermissionStore *PermissionStore
|
||||
Autonomy PermissionAutonomy
|
||||
// AdvertiseInAuto exposes prompt-gated MCP tools to model surfaces whose
|
||||
// default mode is auto while preserving the execution-time prompt.
|
||||
AdvertiseInAuto bool
|
||||
ClientFactory func(context.Context, Server) (ToolClient, error)
|
||||
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
|
||||
// defaultConnectTimeout.
|
||||
@@ -271,9 +274,10 @@ func newRegistryTool(server Server, remote RemoteTool, client ToolClient, option
|
||||
client: client,
|
||||
parameters: SchemaFromMCP(remote.InputSchema),
|
||||
safety: tools.Safety{
|
||||
SideEffect: tools.SideEffectNetwork,
|
||||
Permission: permission,
|
||||
Reason: fmt.Sprintf("MCP tool %s/%s runs through the configured %s server.", server.Name, remote.Name, server.Type),
|
||||
SideEffect: tools.SideEffectNetwork,
|
||||
Permission: permission,
|
||||
Reason: fmt.Sprintf("MCP tool %s/%s runs through the configured %s server.", server.Name, remote.Name, server.Type),
|
||||
AdvertiseInAuto: options.AdvertiseInAuto,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +342,19 @@ func TestRegistryToolIsDeferredEligible(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryToolCanAdvertiseInAutoWithoutPreapproval(t *testing.T) {
|
||||
tool := newRegistryTool(
|
||||
Server{Name: "docs", Type: "stdio"},
|
||||
RemoteTool{Name: "search"},
|
||||
&fakeToolClient{},
|
||||
RegisterOptions{Autonomy: AutonomyLow, AdvertiseInAuto: true},
|
||||
)
|
||||
safety := tool.Safety()
|
||||
if safety.Permission != tools.PermissionPrompt || !safety.AdvertiseInAuto {
|
||||
t.Fatalf("MCP safety = %#v", safety)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryToolReportsMCPServerName verifies the registryTool reports its true
|
||||
// configured server name (not the sanitized tool-name token) so the deferred-tools
|
||||
// discovery label names a multi-token server correctly via tools.DeferredSource.
|
||||
|
||||
@@ -416,6 +416,14 @@ func (m *Manager) Logout(name string) (bool, error) {
|
||||
return m.store.Delete(ProviderKey(name))
|
||||
}
|
||||
|
||||
// Reset clears all persistent state in the underlying store.
|
||||
func (m *Manager) Reset() error {
|
||||
if m == nil || m.store == nil {
|
||||
return nil
|
||||
}
|
||||
return m.store.Reset()
|
||||
}
|
||||
|
||||
// StatusAll returns the status of every provider login.
|
||||
func (m *Manager) StatusAll() ([]Status, error) {
|
||||
return m.store.Status(KeyPrefixProvider)
|
||||
|
||||
+518
-17
@@ -1,15 +1,19 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -96,6 +100,10 @@ type StoreOptions struct {
|
||||
// Keyring is the client used when Storage=="keyring"; nil => keyring.New().
|
||||
// Injected by tests to avoid touching a real keychain.
|
||||
Keyring KeyringClient
|
||||
// KeyringLockPath overrides the cross-process lock file used by the keyring
|
||||
// backend. When empty, it is derived from the keyring storage identity
|
||||
// via ResolveKeyringLockPath(Env).
|
||||
KeyringLockPath string
|
||||
}
|
||||
|
||||
// KeyringClient is the minimal OS-keyring surface the store needs. *keyring.Keyring
|
||||
@@ -104,14 +112,43 @@ type KeyringClient interface {
|
||||
Get(service, account string) (string, bool, error)
|
||||
Set(service, account, secret string) error
|
||||
Delete(service, account string) (bool, error)
|
||||
// MaxSecretLen reports the largest secret, in bytes, Set accepts under
|
||||
// (service, account); ok is false when the backend has no practical limit.
|
||||
// keyringBlob needs it because macOS caps a keychain write well below the
|
||||
// size of a multi-provider token blob (see the keyringBlob doc).
|
||||
MaxSecretLen(service, account string) (int, bool)
|
||||
}
|
||||
|
||||
// Keyring storage stores the whole token blob under one fixed entry.
|
||||
// Keyring storage anchors the token blob at one fixed entry, which holds either
|
||||
// the whole blob or, once that no longer fits, the manifest naming the chunks
|
||||
// that do (see keyringBlob).
|
||||
const (
|
||||
keyringService = "zero"
|
||||
keyringAccount = "oauth-tokens"
|
||||
)
|
||||
|
||||
// Chunked keyring layout.
|
||||
//
|
||||
// keyringManifestPrefix marks the anchor entry as a manifest rather than a
|
||||
// whole blob. ":" is outside the base64 alphabet, so a stored blob can never
|
||||
// begin with this prefix and the two layouts are distinguishable without a
|
||||
// schema migration.
|
||||
//
|
||||
// keyringChunkFamilyA and B name the two generations a write alternates
|
||||
// between; keyringMaxChunks bounds both the accounts a read will probe and the
|
||||
// blob size the backend will accept.
|
||||
const (
|
||||
keyringManifestPrefix = "zc1:"
|
||||
keyringChunkFamilyA = "a"
|
||||
keyringChunkFamilyB = "b"
|
||||
keyringMaxChunks = 64
|
||||
// keyringMinChunkLen is the smallest per-entry budget worth splitting into.
|
||||
// Below it the service and account names have eaten the command line, so no
|
||||
// chunk count would fit the blob and the store says so instead of writing
|
||||
// hundreds of tiny entries.
|
||||
keyringMinChunkLen = 256
|
||||
)
|
||||
|
||||
// Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob,
|
||||
// written atomically through a pluggable backend (a 0600 file guarded by a
|
||||
// cross-process lock, or the OS keyring). When crypter is non-nil the file blob
|
||||
@@ -158,6 +195,28 @@ func ResolveStorePath(env map[string]string) (string, error) {
|
||||
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
|
||||
}
|
||||
|
||||
// ResolveKeyringLockPath determines the on-disk cross-process lock file for the
|
||||
// keyring backend. Unlike file-backed storage, keyring entries are anchored to
|
||||
// the user's OS keychain (zero/oauth-tokens) and are shared across all processes
|
||||
// for that OS user, regardless of file-store overrides such as
|
||||
// ZERO_OAUTH_TOKENS_PATH, FilePath, or XDG_CONFIG_HOME.
|
||||
func ResolveKeyringLockPath(env map[string]string) (string, error) {
|
||||
if override := strings.TrimSpace(envValue(env, "ZERO_OAUTH_KEYRING_LOCK_PATH")); override != "" {
|
||||
if filepath.IsAbs(override) {
|
||||
return filepath.Clean(override), nil
|
||||
}
|
||||
return filepath.Abs(override)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: resolve user home for keyring lock: %w", err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(home); err == nil {
|
||||
home = resolved
|
||||
}
|
||||
return filepath.Join(home, ".zero", "oauth-keyring.lockfile"), nil
|
||||
}
|
||||
|
||||
// NewStore builds a token store with the configured backend (file by default,
|
||||
// or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).
|
||||
func NewStore(options StoreOptions) (*Store, error) {
|
||||
@@ -197,11 +256,17 @@ func NewStore(options StoreOptions) (*Store, error) {
|
||||
kr = osKeyring
|
||||
}
|
||||
// Serialize the keyring's read-modify-write across processes with a lock
|
||||
// file beside where the file backend would live. Best-effort: if no config
|
||||
// location resolves, fall back to in-process serialization only.
|
||||
lockPath := ""
|
||||
if storePath, perr := ResolveStorePath(options.Env); perr == nil {
|
||||
lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile")
|
||||
// file derived from the keyring storage identity ("zero"/"oauth-tokens").
|
||||
// Unlike the file backend, the keyring does not vary with file configuration
|
||||
// (ZERO_OAUTH_TOKENS_PATH, FilePath, or XDG_CONFIG_HOME). Best-effort: if no
|
||||
// home location resolves, fall back to in-process serialization only.
|
||||
lockPath := strings.TrimSpace(options.KeyringLockPath)
|
||||
if lockPath == "" {
|
||||
lp, perr := ResolveKeyringLockPath(options.Env)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
lockPath = lp
|
||||
}
|
||||
return &Store{blob: keyringBlob{kr: kr, service: keyringService, account: keyringAccount, lockPath: lockPath}, now: now}, nil
|
||||
default:
|
||||
@@ -287,6 +352,15 @@ func (s *Store) Delete(key string) (bool, error) {
|
||||
return removed, err
|
||||
}
|
||||
|
||||
// Reset clears all persistent state in the store (removing files or deleting all keyring entries).
|
||||
func (s *Store) Reset() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.blob.withLock(s.now, func() error {
|
||||
return s.blob.reset()
|
||||
})
|
||||
}
|
||||
|
||||
// Status returns redaction-safe summaries of every stored token, sorted by key.
|
||||
// An optional prefix filters to one namespace (e.g. KeyPrefixProvider).
|
||||
func (s *Store) Status(prefix string) ([]Status, error) {
|
||||
@@ -382,6 +456,8 @@ type blobStore interface {
|
||||
read() (data []byte, ok bool, err error)
|
||||
// write replaces the stored blob.
|
||||
write(data []byte) error
|
||||
// reset removes all stored state and entries.
|
||||
reset() error
|
||||
// withLock runs fn under whatever cross-process exclusion the backend offers
|
||||
// (a lock file for the file backend; none for the keyring, which is the
|
||||
// authoritative store and is serialized within the process by Store.mu).
|
||||
@@ -394,6 +470,36 @@ type blobStore interface {
|
||||
// by a cross-process lock file. Behavior matches the original file store.
|
||||
type fileBlob struct{ path string }
|
||||
|
||||
func (b fileBlob) reset() error {
|
||||
var errs []error
|
||||
if err := os.Remove(b.path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if err := os.Remove(b.path + ".secret"); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
for _, dir := range []string{b.path + ".publish", b.path + ".secret.publish"} {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "publish-") {
|
||||
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.Remove(b.path + ".secret.lock"); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (b fileBlob) read() ([]byte, bool, error) {
|
||||
data, err := os.ReadFile(b.path)
|
||||
if err != nil {
|
||||
@@ -469,8 +575,29 @@ func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
|
||||
|
||||
func (b fileBlob) location() string { return b.path }
|
||||
|
||||
// keyringBlob persists the blob in the OS keyring as a single base64 entry
|
||||
// (base64 keeps the multi-line JSON a single, control-character-free value).
|
||||
// keyringBlob persists the blob in the OS keyring, base64-encoded (base64 keeps
|
||||
// the multi-line JSON a single, control-character-free value).
|
||||
//
|
||||
// macOS caps a keychain write at securityMaxLine bytes, because the secret
|
||||
// rides inside a `security -i` command line, and the blob holds every provider
|
||||
// and MCP login at once. That budget is 4039 bytes of base64 under the anchor
|
||||
// account, which is 3027 bytes of JSON: one OIDC login carrying an access
|
||||
// token, an ID token and a refresh token can fill it on its own, and two
|
||||
// reliably do. Storing the blob as one entry therefore turned a second login
|
||||
// into a hard "secret too large" failure with nothing saved.
|
||||
//
|
||||
// So a blob that does not fit is split across numbered entries and the anchor
|
||||
// account holds a manifest instead. Chunks live under two alternating
|
||||
// generations ("families"): a write fills the family that is NOT live, then
|
||||
// replaces the manifest. That single Set is the commit point, so until it lands
|
||||
// read() still returns the previous generation intact and a crash mid-write
|
||||
// loses nothing. Cleanup of the retired generation runs after the commit and
|
||||
// correctness never depends on it: the manifest states how many chunks each
|
||||
// family holds, and it is only ever written with a count it has already made
|
||||
// true, so a failed cleanup over-states and the next write deletes the excess.
|
||||
//
|
||||
// Backends with no size limit (Linux secret-tool reads the secret from stdin)
|
||||
// never reach the chunked layout, so their stored form is unchanged.
|
||||
type keyringBlob struct {
|
||||
kr KeyringClient
|
||||
service string
|
||||
@@ -480,25 +607,347 @@ type keyringBlob struct {
|
||||
lockPath string
|
||||
}
|
||||
|
||||
// keyringManifest describes which chunk generation is live and how many entries
|
||||
// each generation currently holds.
|
||||
type keyringManifest struct {
|
||||
live string
|
||||
counts map[string]int
|
||||
digest string
|
||||
}
|
||||
|
||||
func (b keyringBlob) corruptError(detail string) error {
|
||||
return fmt.Errorf("oauth: keyring token data at %s (account %q) %s; run `zero auth reset --confirm` (clears all OAuth logins and shared MCP tokens) or remove entries %q, %q.a.0..%d, and %q.b.0..%d to recover",
|
||||
b.location(), b.account, detail, b.account, b.account, keyringMaxChunks-1, b.account, keyringMaxChunks-1)
|
||||
}
|
||||
|
||||
func (b keyringBlob) read() ([]byte, bool, error) {
|
||||
enc, ok, err := b.kr.Get(b.service, b.account)
|
||||
if err != nil || !ok {
|
||||
return nil, ok, err
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
head, ok, err := b.kr.Get(b.service, b.account)
|
||||
if err != nil || !ok {
|
||||
return nil, ok, err
|
||||
}
|
||||
head = strings.TrimSpace(head)
|
||||
if !strings.HasPrefix(head, keyringManifestPrefix) {
|
||||
data, err := decodeKeyringBlob(head)
|
||||
if err != nil {
|
||||
lastErr = b.corruptError("contains invalid base64 data: " + err.Error())
|
||||
continue
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
manifest, err := parseKeyringManifest(head)
|
||||
if err != nil {
|
||||
lastErr = b.corruptError("has a malformed manifest (" + err.Error() + ")")
|
||||
continue
|
||||
}
|
||||
count := manifest.counts[manifest.live]
|
||||
var encoded strings.Builder
|
||||
var chunkErr error
|
||||
for index := range count {
|
||||
part, ok, err := b.kr.Get(b.service, b.chunkAccount(manifest.live, index))
|
||||
if err != nil {
|
||||
chunkErr = err
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
chunkErr = b.corruptError(fmt.Sprintf("is missing chunk %d of %d", index+1, count))
|
||||
break
|
||||
}
|
||||
encoded.WriteString(strings.TrimSpace(part))
|
||||
}
|
||||
if chunkErr != nil {
|
||||
lastErr = chunkErr
|
||||
continue
|
||||
}
|
||||
data, err := decodeKeyringBlob(encoded.String())
|
||||
if err != nil {
|
||||
lastErr = b.corruptError("contains invalid chunk data: " + err.Error())
|
||||
continue
|
||||
}
|
||||
// The corruption this guards against is the one that motivated chunking:
|
||||
// `security -i` splits an overlong line into two garbage commands rather
|
||||
// than refusing it, so a stored chunk can come back truncated.
|
||||
if sum := sha256.Sum256(data); hex.EncodeToString(sum[:]) != manifest.digest {
|
||||
lastErr = b.corruptError("failed its integrity check")
|
||||
continue
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("oauth: decode keyring token blob: %w", err)
|
||||
}
|
||||
return data, true, nil
|
||||
return nil, false, lastErr
|
||||
}
|
||||
|
||||
func (b keyringBlob) write(data []byte) error {
|
||||
return b.kr.Set(b.service, b.account, base64.StdEncoding.EncodeToString(data))
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
// Read the live manifest before overwriting anything: it names the
|
||||
// generation this write must avoid, and the counts cleanup needs afterwards.
|
||||
previous, err := b.readManifest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if budget, bounded := b.kr.MaxSecretLen(b.service, b.account); !bounded || len(encoded) <= budget {
|
||||
return b.writeWhole(encoded, previous)
|
||||
}
|
||||
return b.writeChunked(data, encoded, previous)
|
||||
}
|
||||
|
||||
// writeWhole stores the blob under the anchor account, the layout every backend
|
||||
// without a size limit uses and the one a shrinking store returns to. The Set
|
||||
// is the commit point; the chunks it retires are deleted after it lands.
|
||||
//
|
||||
// A delete that fails here leaves residue the manifest can no longer describe,
|
||||
// because the anchor now holds the blob rather than the counts. Nothing
|
||||
// reclaims it until the store next outgrows a single entry, where writeChunked
|
||||
// sweeps both generations; a store that shrinks once and never grows again
|
||||
// keeps it. Sweeping on every whole write would close that, but it costs a
|
||||
// keyringMaxChunks-wide probe per save — 128 `security` invocations on macOS —
|
||||
// for residue that only an already-failed delete can produce.
|
||||
func (b keyringBlob) writeWhole(encoded string, previous keyringManifest) error {
|
||||
if err := b.kr.Set(b.service, b.account, encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
b.sweepCleanupAccount()
|
||||
err := b.deleteChunkRange(keyringChunkFamilyA, 0, previous.counts[keyringChunkFamilyA], nil)
|
||||
if err = b.deleteChunkRange(keyringChunkFamilyB, 0, previous.counts[keyringChunkFamilyB], err); err != nil {
|
||||
return fmt.Errorf("oauth: tokens were saved, but a superseded keyring entry at %s could not be removed: %w", b.location(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b keyringBlob) writeChunked(data []byte, encoded string, previous keyringManifest) (retErr error) {
|
||||
b.sweepCleanupAccount()
|
||||
family := keyringChunkFamilyA
|
||||
if previous.live == keyringChunkFamilyA {
|
||||
family = keyringChunkFamilyB
|
||||
}
|
||||
// Size every chunk against the LONGEST account name the family can produce.
|
||||
// On macOS the account shares the command line with the secret, so a budget
|
||||
// derived from chunk 0 would overflow once the index grew a digit; pinning
|
||||
// it to the highest index keeps the size independent of the final count.
|
||||
budget, bounded := b.kr.MaxSecretLen(b.service, b.chunkAccount(family, keyringMaxChunks-1))
|
||||
if !bounded || budget < keyringMinChunkLen {
|
||||
return fmt.Errorf("oauth: keyring entries at %s hold at most %d bytes, too small to store the token blob; use file storage", b.location(), budget)
|
||||
}
|
||||
if len(encoded) > budget*keyringMaxChunks {
|
||||
return fmt.Errorf("oauth: token blob is %d encoded bytes, over the %d the keyring at %s can hold; use file storage", len(encoded), budget*keyringMaxChunks, b.location())
|
||||
}
|
||||
|
||||
count := (len(encoded) + budget - 1) / budget
|
||||
// Make the manifest cover the range this write is about to occupy BEFORE
|
||||
// occupying it. Without that, a write that dies partway through leaves
|
||||
// chunks above the count the manifest records, and no later cleanup would
|
||||
// know to delete them: a fragment of a token blob would sit in the keychain
|
||||
// for good. Raising the count of a generation that is not live changes
|
||||
// nothing a reader looks at, so the reservation is invisible until the
|
||||
// commit below.
|
||||
if previous.live == "" {
|
||||
// Nothing to reserve against: the anchor still holds the whole blob, so
|
||||
// writing a manifest here would destroy the only copy. Sweep both
|
||||
// generations instead. This runs once, when a store first outgrows a
|
||||
// single entry, and only has anything to find if an earlier shrink was
|
||||
// interrupted before its cleanup finished.
|
||||
//
|
||||
// Both, not just the target: writeWhole replaced the anchor with the
|
||||
// blob, so the counts that named the chunks a failed cleanup left
|
||||
// behind are gone. The manifest cannot state the range any more and no
|
||||
// later write derives it, so this is the only sweep that will ever
|
||||
// reach them. Sweeping just the target would leave the other
|
||||
// generation's chunks, and the token material in them, unreferenced
|
||||
// for good.
|
||||
other := keyringChunkFamilyB
|
||||
if family == keyringChunkFamilyB {
|
||||
other = keyringChunkFamilyA
|
||||
}
|
||||
err := b.deleteChunkRange(family, count, keyringMaxChunks, nil)
|
||||
if err = b.deleteChunkRange(other, 0, keyringMaxChunks, err); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if count > previous.counts[family] {
|
||||
reservation := keyringManifest{live: previous.live, counts: maps.Clone(previous.counts), digest: previous.digest}
|
||||
reservation.counts[family] = count
|
||||
if err := b.kr.Set(b.service, b.account, formatKeyringManifest(reservation)); err != nil {
|
||||
return err
|
||||
}
|
||||
previous.counts[family] = count
|
||||
}
|
||||
|
||||
writtenChunks := 0
|
||||
defer func() {
|
||||
if retErr != nil && previous.live == "" && writtenChunks > 0 {
|
||||
// During the first migration from whole-entry to chunked layout,
|
||||
// a failure before the manifest commit point must clean up any chunks
|
||||
// written so far to avoid leaving orphaned credential material in the keychain.
|
||||
if err := b.deleteChunkRange(family, 0, writtenChunks, nil); err != nil {
|
||||
_ = b.kr.Set(b.service, b.cleanupAccount(), fmt.Sprintf("%s:%d", family, writtenChunks))
|
||||
retErr = errors.Join(retErr, fmt.Errorf("cleanup orphaned migration chunks: %w", err))
|
||||
} else {
|
||||
_, _ = b.kr.Delete(b.service, b.cleanupAccount())
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for index := range count {
|
||||
offset := index * budget
|
||||
if err := b.kr.Set(b.service, b.chunkAccount(family, index), encoded[offset:min(offset+budget, len(encoded))]); err != nil {
|
||||
return err
|
||||
}
|
||||
writtenChunks++
|
||||
}
|
||||
// This generation is not live yet, so trimming a longer previous one of it
|
||||
// now is safe, and it makes the count the manifest is about to publish true
|
||||
// before the manifest claims it.
|
||||
if err := b.deleteChunkRange(family, count, previous.counts[family], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
next := keyringManifest{live: family, counts: maps.Clone(previous.counts), digest: hexDigest(data)}
|
||||
next.counts[family] = count
|
||||
if err := b.kr.Set(b.service, b.account, formatKeyringManifest(next)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Committed. The retired generation is now unreferenced, so deleting it is
|
||||
// secret hygiene rather than correctness. Its count deliberately stays in
|
||||
// the manifest afterwards: over-stating is the safe direction, it saves the
|
||||
// next write a reservation, and if a delete here failed the next cleanup
|
||||
// retries the same range.
|
||||
retired := previous.live
|
||||
if retired == "" || next.counts[retired] == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := b.deleteChunkRange(retired, 0, next.counts[retired], nil); err != nil {
|
||||
return fmt.Errorf("oauth: tokens were saved, but a superseded keyring entry at %s could not be removed: %w", b.location(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b keyringBlob) reset() error {
|
||||
b.sweepCleanupAccount()
|
||||
_, bounded := b.kr.MaxSecretLen(b.service, b.account)
|
||||
if !bounded {
|
||||
var errs []error
|
||||
if _, err := b.kr.Delete(b.service, b.account); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove %s: %w", b.account, err))
|
||||
}
|
||||
_, _ = b.kr.Delete(b.service, b.cleanupAccount())
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
manifest, err := b.readManifest()
|
||||
if err == nil && (manifest.live != "" || len(manifest.counts) > 0) {
|
||||
var delErr error
|
||||
delErr = b.deleteChunkRange(keyringChunkFamilyA, 0, manifest.counts[keyringChunkFamilyA], nil)
|
||||
delErr = b.deleteChunkRange(keyringChunkFamilyB, 0, manifest.counts[keyringChunkFamilyB], delErr)
|
||||
if _, kerr := b.kr.Delete(b.service, b.account); kerr != nil && delErr == nil {
|
||||
delErr = fmt.Errorf("remove %s: %w", b.account, kerr)
|
||||
}
|
||||
_, _ = b.kr.Delete(b.service, b.cleanupAccount())
|
||||
return delErr
|
||||
}
|
||||
|
||||
var delErr error
|
||||
delErr = b.deleteChunkRange(keyringChunkFamilyA, 0, keyringMaxChunks, nil)
|
||||
delErr = b.deleteChunkRange(keyringChunkFamilyB, 0, keyringMaxChunks, delErr)
|
||||
if _, kerr := b.kr.Delete(b.service, b.account); kerr != nil && delErr == nil {
|
||||
delErr = fmt.Errorf("remove %s: %w", b.account, kerr)
|
||||
}
|
||||
_, _ = b.kr.Delete(b.service, b.cleanupAccount())
|
||||
return delErr
|
||||
}
|
||||
|
||||
func (b keyringBlob) cleanupAccount() string {
|
||||
return b.account + ".cleanup"
|
||||
}
|
||||
|
||||
func (b keyringBlob) sweepCleanupAccount() {
|
||||
raw, ok, err := b.kr.Get(b.service, b.cleanupAccount())
|
||||
if err != nil || !ok {
|
||||
return
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" {
|
||||
parts := strings.Split(raw, ":")
|
||||
if len(parts) == 2 {
|
||||
family := parts[0]
|
||||
if family == keyringChunkFamilyA || family == keyringChunkFamilyB {
|
||||
if count, err := strconv.Atoi(parts[1]); err == nil && count > 0 {
|
||||
manifest, err := b.readManifest()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// A successful migration can adopt chunks whose cleanup failed.
|
||||
// The manifest owns them even if removing the stale marker fails.
|
||||
// Any surplus beyond its count is left orphaned rather than
|
||||
// risking deletion of a live generation during recovery.
|
||||
if manifest.live == family {
|
||||
_, _ = b.kr.Delete(b.service, b.cleanupAccount())
|
||||
return
|
||||
}
|
||||
if count > keyringMaxChunks {
|
||||
count = keyringMaxChunks
|
||||
}
|
||||
if err := b.deleteChunkRange(family, 0, count, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_, _ = b.kr.Delete(b.service, b.cleanupAccount())
|
||||
}
|
||||
|
||||
// readManifest returns the live manifest, or a zero manifest when the anchor
|
||||
// holds a whole blob or nothing at all. A malformed manifest is an error: the
|
||||
// chunks it names are unreachable without it, and overwriting it would strand
|
||||
// them in the keychain.
|
||||
func (b keyringBlob) readManifest() (keyringManifest, error) {
|
||||
head, ok, err := b.kr.Get(b.service, b.account)
|
||||
if err != nil || !ok {
|
||||
return keyringManifest{counts: map[string]int{}}, err
|
||||
}
|
||||
head = strings.TrimSpace(head)
|
||||
if !strings.HasPrefix(head, keyringManifestPrefix) {
|
||||
return keyringManifest{counts: map[string]int{}}, nil
|
||||
}
|
||||
m, err := parseKeyringManifest(head)
|
||||
if err != nil {
|
||||
return keyringManifest{counts: map[string]int{}}, b.corruptError("has a malformed manifest (" + err.Error() + ")")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// deleteChunkRange removes chunks [from, to) of family, joining onto prior. It
|
||||
// takes prior so the two-family cleanup in writeWhole reports the first failure
|
||||
// without either delete being skipped.
|
||||
func (b keyringBlob) deleteChunkRange(family string, from, to int, prior error) error {
|
||||
for index := from; index < to; index++ {
|
||||
account := b.chunkAccount(family, index)
|
||||
if _, err := b.kr.Delete(b.service, account); err != nil && prior == nil {
|
||||
prior = fmt.Errorf("remove %s: %w", account, err)
|
||||
}
|
||||
}
|
||||
return prior
|
||||
}
|
||||
|
||||
func (b keyringBlob) chunkAccount(family string, index int) string {
|
||||
return b.account + "." + family + "." + strconv.Itoa(index)
|
||||
}
|
||||
|
||||
// withLock serializes the keyring's read-modify-write. Store.mu covers the
|
||||
// in-process case; lockPath (when set) adds cross-process exclusion so two
|
||||
// processes can't both read the blob, modify, and write — dropping a token.
|
||||
// The chunked layout needs it for a second reason: it makes a write's chunk
|
||||
// fills and its manifest commit one indivisible sequence to other processes.
|
||||
//
|
||||
// Readers (Load, Status) take it as well, which is deliberate and not a
|
||||
// leftover: the generational layout already hands them a consistent view
|
||||
// without it, but holding it keeps a read from observing a torn manifest on a
|
||||
// backend whose Set is not atomic, and it bounds a reader behind at most one
|
||||
// writer. acquireFileLock reclaims after fileLockStaleAfter, so a crashed
|
||||
// holder cannot wedge the hot path.
|
||||
func (b keyringBlob) withLock(now func() time.Time, fn func() error) error {
|
||||
if b.lockPath == "" {
|
||||
return fn()
|
||||
@@ -513,6 +962,58 @@ func (b keyringBlob) withLock(now func() time.Time, fn func() error) error {
|
||||
|
||||
func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.account }
|
||||
|
||||
func decodeKeyringBlob(encoded string) ([]byte, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: decode keyring token blob: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func hexDigest(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// formatKeyringManifest renders "zc1:<live>:<countA>:<countB>:<sha256hex>".
|
||||
func formatKeyringManifest(m keyringManifest) string {
|
||||
return keyringManifestPrefix + m.live +
|
||||
":" + strconv.Itoa(m.counts[keyringChunkFamilyA]) +
|
||||
":" + strconv.Itoa(m.counts[keyringChunkFamilyB]) +
|
||||
":" + m.digest
|
||||
}
|
||||
|
||||
func parseKeyringManifest(head string) (keyringManifest, error) {
|
||||
malformed := func(detail string) (keyringManifest, error) {
|
||||
return keyringManifest{}, fmt.Errorf("oauth: keyring token manifest %s", detail)
|
||||
}
|
||||
fields := strings.Split(strings.TrimPrefix(head, keyringManifestPrefix), ":")
|
||||
if len(fields) != 4 {
|
||||
return malformed("is malformed")
|
||||
}
|
||||
m := keyringManifest{live: fields[0], counts: map[string]int{}, digest: fields[3]}
|
||||
if m.live != keyringChunkFamilyA && m.live != keyringChunkFamilyB {
|
||||
return malformed(fmt.Sprintf("names unknown generation %q", m.live))
|
||||
}
|
||||
for i, family := range []string{keyringChunkFamilyA, keyringChunkFamilyB} {
|
||||
count, err := strconv.Atoi(fields[i+1])
|
||||
if err != nil || count < 0 || count > keyringMaxChunks {
|
||||
return malformed(fmt.Sprintf("has invalid chunk count %q", fields[i+1]))
|
||||
}
|
||||
m.counts[family] = count
|
||||
}
|
||||
if m.counts[m.live] == 0 {
|
||||
return malformed("names a generation with no chunks")
|
||||
}
|
||||
if len(m.digest) != hex.EncodedLen(sha256.Size) {
|
||||
return malformed("has an invalid digest")
|
||||
}
|
||||
if _, err := hex.DecodeString(m.digest); err != nil {
|
||||
return malformed("has an invalid digest")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// FormatStatuses renders a human-readable status table without leaking token
|
||||
// material.
|
||||
func FormatStatuses(statuses []Status) string {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,34 +1,108 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeKR is an in-memory KeyringClient for exercising the keyring backend
|
||||
// without touching a real OS keychain.
|
||||
type fakeKR struct{ data map[string]string }
|
||||
type fakeKR struct {
|
||||
data map[string]string
|
||||
// budget mimics a backend that caps a single entry, as macOS does by
|
||||
// carrying the secret on the `security -i` command line. 0 leaves the
|
||||
// backend unbounded, which is Linux's secret-tool. The account is charged
|
||||
// against the budget for the same reason the real one charges it: both
|
||||
// share one command line, so a longer account name leaves less for the
|
||||
// secret.
|
||||
budget int
|
||||
// failSet, when non-nil, decides whether a write to account fails, standing
|
||||
// in for a locked or full keychain.
|
||||
failSet func(account string) error
|
||||
// sets counts successful writes per account, so a test can assert the order
|
||||
// a write publishes in.
|
||||
sets map[string]int
|
||||
// deletes counts delete calls per account.
|
||||
deletes map[string]int
|
||||
// failDelete, when non-nil, decides whether removing account fails, standing
|
||||
// in for a keychain that refuses a delete while it is busy or locked.
|
||||
failDelete func(account string) error
|
||||
}
|
||||
|
||||
func newFakeKR() *fakeKR { return &fakeKR{data: map[string]string{}} }
|
||||
func newFakeKR() *fakeKR {
|
||||
return &fakeKR{data: map[string]string{}, sets: map[string]int{}, deletes: map[string]int{}}
|
||||
}
|
||||
|
||||
// newCappedFakeKR returns a fake whose entries hold at most budget bytes once
|
||||
// the account name is charged, mimicking the macOS keychain.
|
||||
func newCappedFakeKR(budget int) *fakeKR {
|
||||
f := newFakeKR()
|
||||
f.budget = budget
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeKR) Get(service, account string) (string, bool, error) {
|
||||
v, ok := f.data[service+"/"+account]
|
||||
return v, ok, nil
|
||||
}
|
||||
func (f *fakeKR) Set(service, account, secret string) error {
|
||||
if f.failSet != nil {
|
||||
if err := f.failSet(account); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if limit, bounded := f.MaxSecretLen(service, account); bounded && len(secret) > limit {
|
||||
return fmt.Errorf("keyring: secret too large (%d > %d)", len(secret), limit)
|
||||
}
|
||||
f.data[service+"/"+account] = secret
|
||||
f.sets[account]++
|
||||
return nil
|
||||
}
|
||||
func (f *fakeKR) Delete(service, account string) (bool, error) {
|
||||
if f.deletes == nil {
|
||||
f.deletes = map[string]int{}
|
||||
}
|
||||
f.deletes[account]++
|
||||
if f.failDelete != nil {
|
||||
if err := f.failDelete(account); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
key := service + "/" + account
|
||||
_, ok := f.data[key]
|
||||
delete(f.data, key)
|
||||
return ok, nil
|
||||
}
|
||||
func (f *fakeKR) MaxSecretLen(_, account string) (int, bool) {
|
||||
if f.budget == 0 {
|
||||
return 0, false
|
||||
}
|
||||
limit := f.budget - len(account)
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
return limit, true
|
||||
}
|
||||
|
||||
// chunkAccounts returns the stored chunk accounts for family, in index order.
|
||||
func (f *fakeKR) chunkAccounts(family string) []string {
|
||||
var accounts []string
|
||||
for index := 0; index < keyringMaxChunks; index++ {
|
||||
account := keyringAccount + "." + family + "." + strconv.Itoa(index)
|
||||
if _, ok := f.data[keyringService+"/"+account]; ok {
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
func TestStoreKeyringBackendRoundTrip(t *testing.T) {
|
||||
// Keep the cross-process keyring lock file inside a temp config dir.
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
// Keep the cross-process keyring lock file inside a temp dir.
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
kr := newFakeKR()
|
||||
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
|
||||
if err != nil {
|
||||
@@ -94,7 +168,9 @@ func TestNewStoreStorageSelection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStoreKeyringStatus(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
kr := newFakeKR()
|
||||
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
|
||||
if err != nil {
|
||||
|
||||
@@ -197,3 +197,66 @@ func TestProviderKeyNormalizesCase(t *testing.T) {
|
||||
t.Fatalf("candidate \"xai\" must find the mixed-case login, ok=%v key=%q", ok, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreFileResetCleansPublicationResiduesAndSecretLock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tokens.json")
|
||||
s, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
|
||||
// 1. Perform initial save to establish encrypted store and .secret
|
||||
if err := s.Save(ProviderKey("svc"), Token{AccessToken: "initial"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
// 2. Simulate crash residues: publish-* in .publish and .secret.publish, and stale .secret.lock
|
||||
pubDir := path + ".publish"
|
||||
secPubDir := path + ".secret.publish"
|
||||
if err := os.MkdirAll(pubDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll pubDir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(secPubDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll secPubDir: %v", err)
|
||||
}
|
||||
strandedToken := filepath.Join(pubDir, "publish-stranded-token")
|
||||
strandedSecret := filepath.Join(secPubDir, "publish-stranded-secret")
|
||||
staleLock := path + ".secret.lock"
|
||||
if err := os.WriteFile(strandedToken, []byte("token-residue"), 0o600); err != nil {
|
||||
t.Fatalf("write strandedToken: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(strandedSecret, []byte("secret-residue"), 0o600); err != nil {
|
||||
t.Fatalf("write strandedSecret: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(staleLock, []byte("stale-lock"), 0o600); err != nil {
|
||||
t.Fatalf("write staleLock: %v", err)
|
||||
}
|
||||
|
||||
// 3. Reset the store
|
||||
if err := s.Reset(); err != nil {
|
||||
t.Fatalf("Reset: %v", err)
|
||||
}
|
||||
|
||||
// 4. Verify all secret-bearing artifacts and locks are removed
|
||||
for _, p := range []string{path, path + ".secret", strandedToken, strandedSecret, staleLock} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("file %s should be removed by Reset, stat err=%v", p, err)
|
||||
}
|
||||
}
|
||||
// Publication dirs themselves should be preserved
|
||||
for _, d := range []string{pubDir, secPubDir} {
|
||||
if _, err := os.Stat(d); err != nil {
|
||||
t.Errorf("publication directory %s should be preserved, stat err=%v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Verify fresh encrypted Save/Load succeeds
|
||||
if err := s.Save(ProviderKey("svc2"), Token{AccessToken: "fresh"}); err != nil {
|
||||
t.Fatalf("Save after Reset: %v", err)
|
||||
}
|
||||
tok, ok, err := s.Load(ProviderKey("svc2"))
|
||||
if err != nil || !ok || tok.AccessToken != "fresh" {
|
||||
t.Fatalf("Load after Reset = (%v, %v, %v), want fresh token", tok, ok, err)
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,12 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
|
||||
return InstallResult{}, err
|
||||
}
|
||||
defer unlock()
|
||||
// Put back anything an earlier run was killed mid-commit, and abort if a
|
||||
// transaction could not be resolved: installing over a tree that is still
|
||||
// owed a restore destroys the only copy of it. See installtxn.Recover.
|
||||
if err := installtxn.Recover(dir, reconcileInterrupted(dir)); err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
|
||||
// Re-read under the cross-process lock. Another install may have updated the
|
||||
// lockfile while this plugin was fetched and staged.
|
||||
@@ -205,6 +211,13 @@ func Remove(dir string, id string) error {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
// Put back anything an earlier run was killed mid-commit, and abort if a
|
||||
// transaction could not be resolved: reporting a removal while a backup this
|
||||
// run never saw stays on disk lets the next install publish it again. See
|
||||
// installtxn.Recover.
|
||||
if err := installtxn.Recover(dir, reconcileInterrupted(dir)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lock, err := ReadLock(dir)
|
||||
if err != nil {
|
||||
@@ -236,6 +249,70 @@ func Remove(dir string, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileInterrupted answers the one question installtxn.Recover cannot read
|
||||
// off the filesystem: of the two trees an interrupted commit left, which one is
|
||||
// the one the lockfile records. CommitDir swaps the trees and only then
|
||||
// publishes, so a kill between the two leaves exactly what a kill after both
|
||||
// leaves, and only the published metadata tells them apart. The recorded hash is
|
||||
// the same hashTree the install computed over the tree it copied, so matching it
|
||||
// against each tree is an exact answer rather than a guess at filesystem shape.
|
||||
func reconcileInterrupted(dir string) installtxn.Reconciler {
|
||||
return func(id string, target string, backup string) (installtxn.Phase, error) {
|
||||
lock, err := ReadLock(dir)
|
||||
if err != nil {
|
||||
// An unreadable lockfile is not an empty one. Reporting a phase from it
|
||||
// would be reading a fact out of a file we could not read.
|
||||
return installtxn.PhaseUnknown, err
|
||||
}
|
||||
entry, ok := lock[id]
|
||||
if !ok {
|
||||
// A missing entry is not proof that the publish never ran. The entry can
|
||||
// also be lost after a publish that did run: a truncated or deleted
|
||||
// lockfile reads as an empty one, and a directory with no entry is a
|
||||
// state this package supports (Load returns it, Remove handles it). The
|
||||
// live tree here may be nothing to do with this transaction at all, so
|
||||
// there is no phase to report and recovery must not pick between them.
|
||||
return installtxn.PhaseUnknown, nil
|
||||
}
|
||||
if entry.Hash == "" {
|
||||
// A hand edited entry records no tree at all, so there is nothing to
|
||||
// match either side against and no phase to report.
|
||||
return installtxn.PhaseUnknown, nil
|
||||
}
|
||||
matched, err := treeMatchesHash(target, entry.Hash)
|
||||
if err != nil {
|
||||
return installtxn.PhaseUnknown, err
|
||||
}
|
||||
if matched {
|
||||
return installtxn.PhaseCommitted, nil
|
||||
}
|
||||
matched, err = treeMatchesHash(backup, entry.Hash)
|
||||
if err != nil {
|
||||
return installtxn.PhaseUnknown, err
|
||||
}
|
||||
if matched {
|
||||
return installtxn.PhasePrePublish, nil
|
||||
}
|
||||
return installtxn.PhaseUnknown, nil
|
||||
}
|
||||
}
|
||||
|
||||
// treeMatchesHash reports whether the tree at path hashes to want. A path that
|
||||
// is not there is a non-match rather than an error: recovery asks about two
|
||||
// trees it has just seen on disk, so one going missing under it only means that
|
||||
// tree is not the one the lockfile records. Every other hashing failure is
|
||||
// returned, because a tree we could not read may well be the recorded one.
|
||||
func treeMatchesHash(path string, want string) (bool, error) {
|
||||
hash, err := hashTree(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hash == want, nil
|
||||
}
|
||||
|
||||
// ReadLock loads the plugins lockfile from dir. A missing lockfile yields an
|
||||
// empty map with no error.
|
||||
func ReadLock(dir string) (map[string]LockEntry, error) {
|
||||
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/installtxn"
|
||||
)
|
||||
|
||||
// initGitPluginRepo creates a real local git repo holding a plugin and returns a
|
||||
@@ -403,3 +406,778 @@ func TestInstallCopiesEntireTree(t *testing.T) {
|
||||
t.Fatalf("entry script not copied into install dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A process killed between installtxn.CommitDir's two renames leaves the
|
||||
// plugin's only copy in a workspace backup with nothing at the target: the
|
||||
// plugin disappears and no later run looks in the workspace. The next install
|
||||
// over the same directory already takes the same cross-process lock, so it is
|
||||
// where the interrupted one gets put back.
|
||||
func TestInstallRecoversAPluginLeftByAnInterruptedCommit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest())
|
||||
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: dir}); err != nil {
|
||||
t.Fatalf("seeding the install: %v", err)
|
||||
}
|
||||
|
||||
// The on-disk state a kill in that window leaves, built the way CommitDir
|
||||
// builds it: a staged workspace naming its target, with the live tree moved
|
||||
// into the backup beside it and the target gone.
|
||||
staged, _, err := installtxn.StageDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Dir(staged)
|
||||
writeWorkspaceMarker(t, workspace, "zero.demo")
|
||||
if err := os.Rename(filepath.Join(dir, "zero.demo"), filepath.Join(workspace, "previous")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
other := validManifest()
|
||||
other["id"] = "zero.other"
|
||||
src2 := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src2"), other)
|
||||
if _, err := Install(context.Background(), InstallOptions{Source: src2, Dir: dir}); err != nil {
|
||||
t.Fatalf("second install: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "zero.demo", manifestFileName)); err != nil {
|
||||
t.Fatalf("the interrupted install was not put back: %v", err)
|
||||
}
|
||||
loaded, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: dir}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []string{}
|
||||
for _, p := range loaded.Plugins {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Errorf("Load sees %v, want both plugins", ids)
|
||||
}
|
||||
if _, err := os.Stat(workspace); !os.IsNotExist(err) {
|
||||
t.Errorf("the recovered workspace should be cleared, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A removal has to stick. An install killed mid-commit leaves the tree in a
|
||||
// workspace backup with nothing at the target, and Remove then takes the
|
||||
// not-present branch: it drops the lockfile entry, reports success, and leaves
|
||||
// the backup behind for the next install's recovery to publish again. Since
|
||||
// Load enumerates directories rather than the lockfile, that republished tree
|
||||
// is a live plugin the user already deleted.
|
||||
func TestRemoveLeavesNothingARecoveryCanResurrect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest())
|
||||
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: dir}); err != nil {
|
||||
t.Fatalf("seeding the install: %v", err)
|
||||
}
|
||||
staged, _, err := installtxn.StageDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Dir(staged)
|
||||
writeWorkspaceMarker(t, workspace, "zero.demo")
|
||||
if err := os.Rename(filepath.Join(dir, "zero.demo"), filepath.Join(workspace, "previous")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Remove(dir, "zero.demo"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
|
||||
other := validManifest()
|
||||
other["id"] = "zero.other"
|
||||
src2 := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src2"), other)
|
||||
if _, err := Install(context.Background(), InstallOptions{Source: src2, Dir: dir}); err != nil {
|
||||
t.Fatalf("later install: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "zero.demo")); !os.IsNotExist(err) {
|
||||
t.Errorf("a removed plugin was put back on disk: %v", err)
|
||||
}
|
||||
loaded, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: dir}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range loaded.Plugins {
|
||||
if p.ID == "zero.demo" {
|
||||
t.Errorf("a removed plugin is loadable again")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A commit killed after its publish rename but before it cleared the workspace
|
||||
// leaves the tree the install replaced in a backup beside the live plugin.
|
||||
// Removing that plugin deletes the live tree and its lockfile entry, so a
|
||||
// backup that outlived it would be published again by the next install's
|
||||
// recovery, and Load enumerates directories rather than the lockfile: the
|
||||
// removed plugin would be live again with no entry naming it.
|
||||
func TestRemoveLeavesNoSupersededBackupARecoveryCanResurrect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest())
|
||||
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: dir}); err != nil {
|
||||
t.Fatalf("seeding the install: %v", err)
|
||||
}
|
||||
|
||||
// The state a kill in that window leaves: the plugin live at its target,
|
||||
// with the tree it replaced still retained in the workspace beside it.
|
||||
staged, _, err := installtxn.StageDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Dir(staged)
|
||||
writeWorkspaceMarker(t, workspace, "zero.demo")
|
||||
previous := filepath.Join(workspace, "previous")
|
||||
if err := os.MkdirAll(previous, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest, err := os.ReadFile(filepath.Join(dir, "zero.demo", manifestFileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(previous, manifestFileName), manifest, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Remove(dir, "zero.demo"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
|
||||
other := validManifest()
|
||||
other["id"] = "zero.other"
|
||||
src2 := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src2"), other)
|
||||
if _, err := Install(context.Background(), InstallOptions{Source: src2, Dir: dir}); err != nil {
|
||||
t.Fatalf("later install: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "zero.demo")); !os.IsNotExist(err) {
|
||||
t.Errorf("a removed plugin was put back on disk: %v", err)
|
||||
}
|
||||
loaded, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: dir}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range loaded.Plugins {
|
||||
if p.ID == "zero.demo" {
|
||||
t.Errorf("a removed plugin is loadable again")
|
||||
}
|
||||
}
|
||||
lock, err := ReadLock(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := lock["zero.demo"]; ok {
|
||||
t.Errorf("the lockfile still names a removed plugin")
|
||||
}
|
||||
}
|
||||
|
||||
// writeWorkspaceMarker plants the ownership marker installtxn.CommitDir writes
|
||||
// before it moves a live tree aside. Recovery acts only on a workspace carrying
|
||||
// that marker's magic first line, so a test standing in for a killed process has
|
||||
// to write the real thing: the workspace prefix and a previous directory beside
|
||||
// it are shapes ordinary user content can have too, and prove nothing.
|
||||
func writeWorkspaceMarker(t *testing.T, workspace string, id string) {
|
||||
t.Helper()
|
||||
marker := []byte("zero-install-txn v1\ntarget " + id + "\n")
|
||||
if err := os.WriteFile(filepath.Join(workspace, ".zero-install-txn"), marker, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// generationSource writes a plugin source whose content names which generation
|
||||
// it is, so a test can tell the recovered tree from the one it replaced by
|
||||
// reading the tree on disk instead of trusting the lockfile that is itself
|
||||
// under test.
|
||||
func generationSource(t *testing.T, generation string) string {
|
||||
t.Helper()
|
||||
versions := map[string]string{"old": "0.1.0", "new": "0.2.0"}
|
||||
manifest := validManifest()
|
||||
manifest["version"] = versions[generation]
|
||||
dir := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src-"+generation), manifest)
|
||||
if err := os.WriteFile(filepath.Join(dir, "generation.txt"), []byte(generation), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// interruptedUpdate is a plugins dir with the old generation installed, plus
|
||||
// everything the update over it would have used. An update is the only shape
|
||||
// that leaves a backup owed a restore, so every interrupted state below is one.
|
||||
type interruptedUpdate struct {
|
||||
dir string
|
||||
oldSource string
|
||||
oldHash string
|
||||
newSource string
|
||||
newHash string
|
||||
}
|
||||
|
||||
func seedInterruptedUpdate(t *testing.T) interruptedUpdate {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
seeded, err := Install(context.Background(), InstallOptions{Source: generationSource(t, "old"), Dir: dir})
|
||||
if err != nil {
|
||||
t.Fatalf("seeding the install: %v", err)
|
||||
}
|
||||
newSource := generationSource(t, "new")
|
||||
newHash, err := hashTree(newSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return interruptedUpdate{
|
||||
dir: dir,
|
||||
oldSource: seeded.Source,
|
||||
oldHash: seeded.Hash,
|
||||
newSource: canonicalSource(newSource),
|
||||
newHash: newHash,
|
||||
}
|
||||
}
|
||||
|
||||
// plantWorkspace builds what CommitDir has on disk before it touches the live
|
||||
// tree: a staged copy of the new generation and the ownership marker naming the
|
||||
// install it is about to replace.
|
||||
func (u interruptedUpdate) plantWorkspace(t *testing.T) string {
|
||||
t.Helper()
|
||||
staged, _, err := installtxn.StageDir(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copyTree(u.newSource, staged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Dir(staged)
|
||||
writeWorkspaceMarker(t, workspace, "zero.demo")
|
||||
return workspace
|
||||
}
|
||||
|
||||
func (u interruptedUpdate) target() string {
|
||||
return filepath.Join(u.dir, "zero.demo")
|
||||
}
|
||||
|
||||
func (u interruptedUpdate) mustRename(t *testing.T, from string, to string) {
|
||||
t.Helper()
|
||||
if err := os.Rename(from, to); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishNewEntry writes the lockfile the interrupted update was about to
|
||||
// publish, which is the only thing separating a commit killed before the
|
||||
// publish from one killed after it.
|
||||
func (u interruptedUpdate) publishNewEntry(t *testing.T) {
|
||||
t.Helper()
|
||||
lock, err := ReadLock(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock["zero.demo"] = LockEntry{Source: u.newSource, Hash: u.newHash}
|
||||
if err := writeLock(u.dir, lock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// recoveryWant is the state a resolved workspace has to leave behind.
|
||||
type recoveryWant struct {
|
||||
generation string
|
||||
version string
|
||||
source string
|
||||
hash string
|
||||
workspace bool
|
||||
}
|
||||
|
||||
// interruptedState is one point CommitDir can be killed at, with the state the
|
||||
// next run must arrive at.
|
||||
type interruptedState struct {
|
||||
name string
|
||||
plant func(t *testing.T, u interruptedUpdate) string
|
||||
want func(u interruptedUpdate) recoveryWant
|
||||
}
|
||||
|
||||
func interruptedStates() []interruptedState {
|
||||
oldTree := func(u interruptedUpdate, workspace bool) recoveryWant {
|
||||
return recoveryWant{generation: "old", version: "0.1.0", source: u.oldSource, hash: u.oldHash, workspace: workspace}
|
||||
}
|
||||
return []interruptedState{
|
||||
{
|
||||
// Killed after the marker, before anything moved. Nothing is owed a
|
||||
// restore, and the workspace belongs to whoever made it.
|
||||
name: "S1_after_marker",
|
||||
plant: func(t *testing.T, u interruptedUpdate) string {
|
||||
return u.plantWorkspace(t)
|
||||
},
|
||||
want: func(u interruptedUpdate) recoveryWant { return oldTree(u, true) },
|
||||
},
|
||||
{
|
||||
// Killed between the two renames: the backup is the only copy of the
|
||||
// plugin in existence and nothing is at the target.
|
||||
name: "S2_after_retaining_previous",
|
||||
plant: func(t *testing.T, u interruptedUpdate) string {
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
return workspace
|
||||
},
|
||||
want: func(u interruptedUpdate) recoveryWant { return oldTree(u, false) },
|
||||
},
|
||||
{
|
||||
// Killed after the swap and before the publish. Both trees are on disk
|
||||
// and only the lockfile says which one the user has: it still records
|
||||
// the backup, so the backup goes back and the new tree is dropped.
|
||||
name: "S3_after_swap_before_publish",
|
||||
plant: func(t *testing.T, u interruptedUpdate) string {
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
||||
return workspace
|
||||
},
|
||||
want: func(u interruptedUpdate) recoveryWant { return oldTree(u, false) },
|
||||
},
|
||||
{
|
||||
// The same shape on disk as S3, told apart only by the published entry:
|
||||
// the update committed, so the live tree stands and the backup beside
|
||||
// it is superseded.
|
||||
name: "S4_after_publish_before_cleanup",
|
||||
plant: func(t *testing.T, u interruptedUpdate) string {
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
||||
u.publishNewEntry(t)
|
||||
return workspace
|
||||
},
|
||||
want: func(u interruptedUpdate) recoveryWant {
|
||||
return recoveryWant{generation: "new", version: "0.2.0", source: u.newSource, hash: u.newHash, workspace: false}
|
||||
},
|
||||
},
|
||||
{
|
||||
// Killed inside a rollback: the failed tree is already set aside and the
|
||||
// backup is again the only copy, which is the state that ordering buys.
|
||||
name: "S5_interrupted_rollback",
|
||||
plant: func(t *testing.T, u interruptedUpdate) string {
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), filepath.Join(workspace, "failed"))
|
||||
return workspace
|
||||
},
|
||||
want: func(u interruptedUpdate) recoveryWant { return oldTree(u, false) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// installOther installs an unrelated plugin, which is how a recovery pass is
|
||||
// driven: the next install over the same dir takes the same lock.
|
||||
func installOther(t *testing.T, dir string, id string) error {
|
||||
t.Helper()
|
||||
manifest := validManifest()
|
||||
manifest["id"] = id
|
||||
source := writeSourcePlugin(t, filepath.Join(t.TempDir(), id), manifest)
|
||||
_, err := Install(context.Background(), InstallOptions{Source: source, Dir: dir})
|
||||
return err
|
||||
}
|
||||
|
||||
func assertRecovered(t *testing.T, u interruptedUpdate, workspace string, want recoveryWant) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(u.target(), "generation.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading the live tree: %v", err)
|
||||
}
|
||||
if string(data) != want.generation {
|
||||
t.Errorf("the live tree is the %q generation, want %q", data, want.generation)
|
||||
}
|
||||
lock, err := ReadLock(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := lock["zero.demo"]
|
||||
if entry.Source != want.source {
|
||||
t.Errorf("lock source = %q, want %q", entry.Source, want.source)
|
||||
}
|
||||
if entry.Hash != want.hash {
|
||||
t.Errorf("lock hash = %q, want %q", entry.Hash, want.hash)
|
||||
}
|
||||
// A tree that only stats is not a recovered install: it has to come back
|
||||
// through the same loader the rest of the program reads plugins with.
|
||||
loaded, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: u.dir}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, plugin := range loaded.Plugins {
|
||||
if plugin.ID != "zero.demo" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if plugin.Version != want.version {
|
||||
t.Errorf("Load sees version %q, want %q", plugin.Version, want.version)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Load does not see the recovered plugin")
|
||||
}
|
||||
_, err = os.Stat(workspace)
|
||||
if want.workspace && err != nil {
|
||||
t.Errorf("the workspace should be left alone, got %v", err)
|
||||
}
|
||||
if !want.workspace && !os.IsNotExist(err) {
|
||||
t.Errorf("the resolved workspace should be cleared, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRemovedForGood(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(filepath.Join(dir, "zero.demo")); !os.IsNotExist(err) {
|
||||
t.Errorf("a removed plugin is on disk again: %v", err)
|
||||
}
|
||||
lock, err := ReadLock(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := lock["zero.demo"]; ok {
|
||||
t.Errorf("the lockfile still names a removed plugin")
|
||||
}
|
||||
loaded, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: dir}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, plugin := range loaded.Plugins {
|
||||
if plugin.ID == "zero.demo" {
|
||||
t.Errorf("a removed plugin is loadable again")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every point an update can be killed at has one right resolution, and the next
|
||||
// install is one of the two places it happens.
|
||||
func TestInstallResolvesEveryInterruptedUpdateState(t *testing.T) {
|
||||
for _, state := range interruptedStates() {
|
||||
t.Run(state.name, func(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := state.plant(t, u)
|
||||
|
||||
if err := installOther(t, u.dir, "zero.other"); err != nil {
|
||||
t.Fatalf("install after the interrupted update: %v", err)
|
||||
}
|
||||
assertRecovered(t, u, workspace, state.want(u))
|
||||
|
||||
// A second pass must find nothing to do. Recovery that resolved the same
|
||||
// workspace twice would undo the state it just settled on.
|
||||
if err := installOther(t, u.dir, "zero.third"); err != nil {
|
||||
t.Fatalf("second recovery pass: %v", err)
|
||||
}
|
||||
assertRecovered(t, u, workspace, state.want(u))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The other place recovery runs is a removal, and it has to run there first:
|
||||
// removing without resolving leaves a backup the next install republishes,
|
||||
// reinstating a plugin the user deleted.
|
||||
func TestRemoveResolvesEveryInterruptedUpdateStateAndSticks(t *testing.T) {
|
||||
for _, state := range interruptedStates() {
|
||||
t.Run(state.name, func(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
state.plant(t, u)
|
||||
|
||||
if err := Remove(u.dir, "zero.demo"); err != nil {
|
||||
t.Fatalf("remove after the interrupted update: %v", err)
|
||||
}
|
||||
assertRemovedForGood(t, u.dir)
|
||||
|
||||
if err := installOther(t, u.dir, "zero.other"); err != nil {
|
||||
t.Fatalf("install after the removal: %v", err)
|
||||
}
|
||||
assertRemovedForGood(t, u.dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertUntouched pins the two things a caller must not have changed after
|
||||
// recovery reported a transaction it could not resolve.
|
||||
func assertUntouched(t *testing.T, u interruptedUpdate, generation string, want LockEntry) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(u.target(), "generation.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading the live tree: %v", err)
|
||||
}
|
||||
if string(data) != generation {
|
||||
t.Errorf("the live tree is the %q generation, want %q", data, generation)
|
||||
}
|
||||
lock, err := ReadLock(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lock["zero.demo"] != want {
|
||||
t.Errorf("lock entry = %+v, want %+v", lock["zero.demo"], want)
|
||||
}
|
||||
if _, ok := lock["zero.other"]; ok {
|
||||
t.Errorf("the aborted install still published its own entry")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(u.dir, "zero.other")); !os.IsNotExist(err) {
|
||||
t.Errorf("the aborted install still wrote its tree: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Neither tree matches what the lockfile records, so there is no answer to give
|
||||
// and either guess destroys the tree the user is actually owed. The failure has
|
||||
// to reach the caller, which must not install over or remove anything.
|
||||
func TestInterruptedUpdateMatchingNeitherTreeAbortsBothCallers(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
||||
unmatched := LockEntry{Source: u.oldSource, Hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
lock, err := ReadLock(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock["zero.demo"] = unmatched
|
||||
if err := writeLock(u.dir, lock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := installOther(t, u.dir, "zero.other"); err == nil {
|
||||
t.Errorf("Install did not report the unresolved transaction")
|
||||
}
|
||||
assertUntouched(t, u, "new", unmatched)
|
||||
if err := Remove(u.dir, "zero.demo"); err == nil {
|
||||
t.Errorf("Remove did not report the unresolved transaction")
|
||||
}
|
||||
assertUntouched(t, u, "new", unmatched)
|
||||
if _, err := os.Stat(filepath.Join(workspace, "previous")); err != nil {
|
||||
t.Errorf("the backup should still be there for a hand rescue, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A lockfile that cannot be read is not an empty one: the reconciler has no
|
||||
// answer, and a caller that went on would install over or report the removal of
|
||||
// a tree that may still be owed a restore.
|
||||
func TestInterruptedUpdateWithAnUnreadableLockAbortsBothCallers(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
||||
if err := os.WriteFile(filepath.Join(u.dir, LockFileName), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := installOther(t, u.dir, "zero.other"); err == nil {
|
||||
t.Errorf("Install did not report the unresolved transaction")
|
||||
}
|
||||
if err := Remove(u.dir, "zero.demo"); err == nil {
|
||||
t.Errorf("Remove did not report the unresolved transaction")
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(u.target(), "generation.txt"))
|
||||
if err != nil || string(data) != "new" {
|
||||
t.Errorf("the live tree changed under an unresolved transaction: %q, %v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workspace, "previous")); err != nil {
|
||||
t.Errorf("the backup should still be there for a hand rescue, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A restore that cannot be carried out is the case where silence is worst: the
|
||||
// plugin is missing from the target and its only copy is in a workspace nothing
|
||||
// else reads.
|
||||
// skipWithoutPermissionFaults skips a test that injects a failure by making a
|
||||
// directory unwritable. Windows does not block renames or deletes that way, and
|
||||
// root ignores the permission entirely, so on both the injected failure never
|
||||
// fires and the test would assert against a success it never meant to produce.
|
||||
func skipWithoutPermissionFaults(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("directory permissions do not block renames or removals on windows")
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root ignores the directory permissions this test relies on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedRestoreAbortsBothCallers(t *testing.T) {
|
||||
skipWithoutPermissionFaults(t)
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
// A workspace that cannot be written is the cheap stand-in for a rename that
|
||||
// fails: the backup cannot leave it.
|
||||
if err := os.Chmod(workspace, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(workspace, 0o755) })
|
||||
|
||||
if err := installOther(t, u.dir, "zero.other"); err == nil {
|
||||
t.Errorf("Install did not report the failed restore")
|
||||
}
|
||||
if err := Remove(u.dir, "zero.demo"); err == nil {
|
||||
t.Errorf("Remove did not report the failed restore")
|
||||
}
|
||||
if _, err := os.Stat(u.target()); !os.IsNotExist(err) {
|
||||
t.Errorf("nothing should have been written at the target: %v", err)
|
||||
}
|
||||
lock, err := ReadLock(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lock["zero.demo"] != (LockEntry{Source: u.oldSource, Hash: u.oldHash}) {
|
||||
t.Errorf("lock entry = %+v, want the untouched old entry", lock["zero.demo"])
|
||||
}
|
||||
if _, ok := lock["zero.other"]; ok {
|
||||
t.Errorf("the aborted install still published its own entry")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workspace, "previous")); err != nil {
|
||||
t.Errorf("the only copy of the plugin should still be there, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A publish always writes the lockfile entry, so an interrupted update with no
|
||||
// entry naming it proves the publish never ran and the backup is still the tree
|
||||
// the lockfile describes, even though there is no hash left to match it against.
|
||||
func TestInterruptedUpdateWithNoLockEntryAbortsTheCaller(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
||||
lock, err := ReadLock(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(lock, "zero.demo")
|
||||
if err := writeLock(u.dir, lock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = installOther(t, u.dir, "zero.other")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("a live tree the lockfile does not describe must stop the caller")
|
||||
}
|
||||
assertUntouched(t, u, "new", LockEntry{})
|
||||
}
|
||||
|
||||
// A directory at the target that the lockfile does not name is not proof that a
|
||||
// publish was interrupted. Anything can have created it: `zero tools make`, a
|
||||
// hand-written plugin, a restored file sync. Recovery used to read a missing
|
||||
// entry as proof the publish never ran and replace that tree with the retained
|
||||
// backup, which deleted whatever was there.
|
||||
func TestAnUnrelatedTreeAtTheTargetIsNeverReplaced(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
workspace, err := os.MkdirTemp(dir, ".zero-install-txn-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := []byte("zero-install-txn v1\ntarget zero.demo\n")
|
||||
if err := os.WriteFile(filepath.Join(workspace, ".zero-install-txn"), marker, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup := filepath.Join(workspace, "previous")
|
||||
writeSourcePlugin(t, backup, validManifest())
|
||||
// The user's own tree, which no lockfile entry names.
|
||||
mine := filepath.Join(dir, "zero.demo")
|
||||
writeSourcePlugin(t, mine, validManifest())
|
||||
if err := os.WriteFile(filepath.Join(mine, "mycode.txt"), []byte("my work"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = installOther(t, dir, "zero.other")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("recovery must not act on a tree the lockfile does not describe")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(mine, "mycode.txt")); statErr != nil {
|
||||
t.Fatalf("the user's own tree was destroyed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// An entry that records no hash describes no tree, so neither side can be
|
||||
// matched against it and there is no phase to report.
|
||||
func TestInterruptedUpdateWithAHashlessLockEntryAbortsTheCaller(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
||||
hashless := LockEntry{Source: u.oldSource}
|
||||
lock, err := ReadLock(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock["zero.demo"] = hashless
|
||||
if err := writeLock(u.dir, lock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := installOther(t, u.dir, "zero.other"); err == nil {
|
||||
t.Errorf("Install did not report the unresolved transaction")
|
||||
}
|
||||
assertUntouched(t, u, "new", hashless)
|
||||
}
|
||||
|
||||
// interruptedCaller is one of the two entry points that take the install lock.
|
||||
// Both must abort on a transaction recovery could not resolve.
|
||||
type interruptedCaller struct {
|
||||
name string
|
||||
run func(t *testing.T, dir string) error
|
||||
}
|
||||
|
||||
func interruptedCallers() []interruptedCaller {
|
||||
return []interruptedCaller{
|
||||
{name: "install", run: func(t *testing.T, dir string) error { return installOther(t, dir, "zero.other") }},
|
||||
{name: "remove", run: func(t *testing.T, dir string) error { return Remove(dir, "zero.demo") }},
|
||||
}
|
||||
}
|
||||
|
||||
// Retiring a superseded backup can fail too, and leaving it behind is not
|
||||
// harmless: a later removal deletes the live tree, and a backup that outlived it
|
||||
// would be read as an interrupted swap and published again. Each caller gets its
|
||||
// own planted state here, because a retirement that fails partway can take the
|
||||
// ownership marker with it and leave the next pass a workspace it cannot
|
||||
// attribute.
|
||||
func TestAFailedRetirementAbortsBothCallers(t *testing.T) {
|
||||
skipWithoutPermissionFaults(t)
|
||||
for _, caller := range interruptedCallers() {
|
||||
t.Run(caller.name, func(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
||||
u.publishNewEntry(t)
|
||||
// The backup's own contents cannot be unlinked, so removing the
|
||||
// workspace around it fails.
|
||||
if err := os.Chmod(filepath.Join(workspace, "previous"), 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(filepath.Join(workspace, "previous"), 0o755) })
|
||||
|
||||
if err := caller.run(t, u.dir); err == nil {
|
||||
t.Errorf("%s did not report the failed retirement", caller.name)
|
||||
}
|
||||
assertUntouched(t, u, "new", LockEntry{Source: u.newSource, Hash: u.newHash})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The workspace prefix is a public dot prefixed name and a previous directory
|
||||
// beside a target file is a shape ordinary content can have, so neither is
|
||||
// evidence. Without the magic marker this is somebody's content and recovery
|
||||
// must leave it exactly as it found it, and say nothing.
|
||||
func TestRecoveryLeavesAPrefixCollidingDirectoryAlone(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
collided := filepath.Join(dir, ".zero-install-txn-notes")
|
||||
previous := filepath.Join(collided, "previous")
|
||||
if err := os.MkdirAll(previous, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(previous, "SKILL.md"), []byte("notes\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(collided, "target"), []byte("zero.demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := installOther(t, dir, "zero.other"); err != nil {
|
||||
t.Fatalf("install alongside a prefix colliding directory: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(previous, "SKILL.md"))
|
||||
if err != nil || string(data) != "notes\n" {
|
||||
t.Errorf("user content inside the colliding directory was disturbed: %q, %v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(collided, "target")); err != nil {
|
||||
t.Errorf("the colliding directory lost a file: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "zero.demo")); !os.IsNotExist(err) {
|
||||
t.Errorf("recovery published content it does not own: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ var descriptors = []Descriptor{
|
||||
openAICompat("ollama-cloud", "Ollama Cloud", "https://ollama.com/v1", "qwen3-coder:480b", []string{"OLLAMA_API_KEY"}, "ollama.com", "ollama cloud"),
|
||||
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
|
||||
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
|
||||
localOpenAI("atomic-chat-local", "Atomic Chat Local", "http://127.0.0.1:1337/v1", "local-model", "atomic chat local"),
|
||||
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
|
||||
// Hugging Face Inference Providers — OpenAI-compatible router at
|
||||
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
|
||||
|
||||
@@ -16,6 +16,7 @@ var expectedCatalogIDs = []string{
|
||||
"ollama-cloud",
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"atomic-chat-local",
|
||||
"openrouter",
|
||||
"huggingface",
|
||||
"chatgpt",
|
||||
@@ -259,7 +260,7 @@ func TestRemoteProvidersDeclareAuthOrExplicitPublicAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLocalProvidersDoNotRequireAuth(t *testing.T) {
|
||||
for _, id := range []string{"ollama", "lmstudio"} {
|
||||
for _, id := range []string{"ollama", "lmstudio", "atomic-chat-local"} {
|
||||
descriptor, err := Require(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Require(%q) error = %v", id, err)
|
||||
@@ -316,6 +317,7 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
|
||||
"ollama cloud": "ollama-cloud",
|
||||
"ollama local": "ollama",
|
||||
"lm-studio": "lmstudio",
|
||||
"atomic chat local": "atomic-chat-local",
|
||||
"mini_max": "minimax",
|
||||
"Moonshot": "moonshot",
|
||||
"Atlas Cloud": "atlascloud",
|
||||
@@ -369,7 +371,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/proxydial"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
@@ -518,9 +519,15 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
|
||||
// WRAPPED, SO THE DIALER LEARNS THE ROUTE THIS TRANSPORT CHOSE. The wrapper
|
||||
// records the proxy selected for each request on that request, and the
|
||||
// dialer reads it back on the dial that request causes; installing the bare
|
||||
// transport instead would leave a proxied dial looking like a loopback
|
||||
// target and fail the probe (#569). The cloned default transport already
|
||||
// reads HTTPS_PROXY and friends; nothing new is enabled.
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
Transport: proxydial.Wrap(transport),
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxConnectivityRedirects {
|
||||
return fmt.Errorf("provider connectivity exceeded %d redirects", maxConnectivityRedirects)
|
||||
@@ -583,6 +590,17 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
|
||||
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
// THE PROXY IS NOT THE TARGET. With HTTPS_PROXY set to a local forward
|
||||
// proxy, the transport dials the proxy first and tunnels the request
|
||||
// through it, and this dialer used to refuse that dial as a loopback
|
||||
// address: "proxyconnect tcp: ... loopback hosts are blocked" for a
|
||||
// request whose real target had already been validated (#569). The
|
||||
// target validation is unchanged; only the dial the transport opens to
|
||||
// the proxy it picked for this very request is let past the guard, and a
|
||||
// direct dial is checked as it always was.
|
||||
if proxydial.IsProxyDial(ctx) {
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
}
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package providerhealth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/proxydial"
|
||||
)
|
||||
|
||||
// connectivityClientWithProxy builds the real connectivity client and swaps in
|
||||
// a proxy function, which is how the transport learns its route. The
|
||||
// environment is left alone deliberately: http.ProxyFromEnvironment reads it
|
||||
// once per process, so an environment-driven test would depend on test order.
|
||||
func connectivityClientWithProxy(t *testing.T, resolver Resolver, proxyFor func(*http.Request) (*url.URL, error)) *http.Client {
|
||||
t.Helper()
|
||||
client := newConnectivityClient(3*time.Second, resolver, nil, false)
|
||||
wrapped, ok := client.Transport.(*proxydial.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport = %T, want the route-recording wrapper; without it no dial can be recognised as the proxy dial", client.Transport)
|
||||
}
|
||||
wrapped.Proxy = proxyFor
|
||||
return client
|
||||
}
|
||||
|
||||
// recordingProxy is a listener standing in for a local forward proxy. It
|
||||
// records the first line it receives and hangs up.
|
||||
func recordingProxy(t *testing.T) (*url.URL, <-chan string) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
arrived := make(chan string, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
buffer := make([]byte, 256)
|
||||
n, _ := conn.Read(buffer)
|
||||
arrived <- string(buffer[:n])
|
||||
}()
|
||||
proxyURL, err := url.Parse("http://" + listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return proxyURL, arrived
|
||||
}
|
||||
|
||||
// THE PROXY IS NOT THE TARGET, AND THE GUARD HAS TO KNOW THE DIFFERENCE.
|
||||
//
|
||||
// With HTTPS_PROXY set to a local forward proxy, the transport dials the proxy
|
||||
// first and tunnels the request through it. This dialer refused that dial as a
|
||||
// loopback address, so `zero doctor --connectivity` failed with "proxyconnect
|
||||
// tcp: ... loopback hosts are blocked" for a target that had already been
|
||||
// validated and was public (#569). Driven through the real client, so the route
|
||||
// is the transport's own decision.
|
||||
func TestConnectivityClientTunnelsThroughTheConfiguredProxy(t *testing.T) {
|
||||
proxyURL, arrived := recordingProxy(t)
|
||||
client := connectivityClientWithProxy(t,
|
||||
staticResolver{err: errors.New("resolver must not be called for a proxied request")},
|
||||
func(*http.Request) (*url.URL, error) { return proxyURL, nil })
|
||||
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.example.com/v1/models", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.Do(request)
|
||||
select {
|
||||
case first := <-arrived:
|
||||
if line := strings.SplitN(first, "\r\n", 2)[0]; line != "CONNECT api.example.com:443 HTTP/1.1" {
|
||||
t.Fatalf("proxy received %q, want a CONNECT for the validated target", line)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("the proxy never received a connection; request error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AND A DIRECT REQUEST STAYS GUARDED, EVEN WHEN ITS ORIGIN IS THE PROXY'S OWN
|
||||
// ADDRESS.
|
||||
//
|
||||
// This is the NO_PROXY shape: the proxy function sends one host direct. An
|
||||
// exemption derived from asking that function about a request of our own
|
||||
// invention would answer "proxied" for the excluded host too, and this dial,
|
||||
// whose address equals the configured proxy endpoint, would skip the resolver
|
||||
// and reach whatever the name resolves to at connect time.
|
||||
func TestDirectRequestToTheProxyEndpointIsStillGuarded(t *testing.T) {
|
||||
proxyURL, err := url.Parse("http://rebind.example:80")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lookups := 0
|
||||
resolver := countingResolver{
|
||||
onLookup: func() { lookups++ },
|
||||
addr: netip.MustParseAddr("10.0.0.5"),
|
||||
}
|
||||
client := connectivityClientWithProxy(t, resolver, func(request *http.Request) (*url.URL, error) {
|
||||
if request.URL.Hostname() == "rebind.example" {
|
||||
return nil, nil
|
||||
}
|
||||
return proxyURL, nil
|
||||
})
|
||||
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://rebind.example/data", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.Do(request)
|
||||
|
||||
if lookups == 0 {
|
||||
t.Fatal("the direct dial skipped the safety resolver because its address matched the configured proxy endpoint")
|
||||
}
|
||||
var safety endpointSafetyError
|
||||
if !errors.As(err, &safety) {
|
||||
t.Fatalf("err = %v, want the private answer to be refused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A proxy configured with a Unicode hostname is dialed by its IDNA-encoded
|
||||
// name, and it is still the proxy: the request must reach it rather than be
|
||||
// refused as an ordinary private address.
|
||||
func TestUnicodeProxyHostnameIsStillTheProxy(t *testing.T) {
|
||||
dialed := ""
|
||||
client := connectivityClientWithProxy(t,
|
||||
staticResolver{err: errors.New("resolver must not be called for a proxied request")},
|
||||
func(*http.Request) (*url.URL, error) { return url.Parse("http://büro.example:3067") })
|
||||
wrapped := client.Transport.(*proxydial.Transport)
|
||||
guarded := wrapped.DialContext
|
||||
wrapped.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = address
|
||||
return guarded(ctx, network, address)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.example.com/v1/models", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.Do(request)
|
||||
|
||||
if dialed != "xn--bro-hoa.example:3067" {
|
||||
t.Fatalf("SETUP INVALID: dialed %q, want the IDNA-encoded proxy endpoint", dialed)
|
||||
}
|
||||
var safety endpointSafetyError
|
||||
if errors.As(err, &safety) {
|
||||
t.Fatalf("the configured proxy was refused by the guard: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a proxy the guard is exactly what it was: loopback is refused.
|
||||
func TestSafeDialContextStillRefusesLoopbackWithoutAProxy(t *testing.T) {
|
||||
dial := safeDialContext(staticResolver{err: errors.New("resolver must not be called")}, false)
|
||||
conn, err := dial(context.Background(), "tcp", "127.0.0.1:3067")
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
t.Fatal("loopback was dialed with no proxy configured")
|
||||
}
|
||||
var safety endpointSafetyError
|
||||
if !errors.As(err, &safety) {
|
||||
t.Fatalf("err = %v, want endpointSafetyError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A sibling loopback port is not the proxy, and the guard is unchanged for it.
|
||||
func TestSafeDialContextRefusesALoopbackPortThatIsNotTheProxy(t *testing.T) {
|
||||
proxyURL, arrived := recordingProxy(t)
|
||||
client := connectivityClientWithProxy(t,
|
||||
staticResolver{err: errors.New("resolver must not be called")},
|
||||
func(request *http.Request) (*url.URL, error) {
|
||||
// Only the https probe is proxied; the http one below is direct and
|
||||
// happens to point at the neighbouring port.
|
||||
if request.URL.Scheme == "https" {
|
||||
return proxyURL, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, port, _ := net.SplitHostPort(proxyURL.Host)
|
||||
number, _ := strconv.Atoi(port)
|
||||
sibling := "http://" + net.JoinHostPort("127.0.0.1", strconv.Itoa(number+1)) + "/"
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, sibling, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.Do(request)
|
||||
var safety endpointSafetyError
|
||||
if !errors.As(err, &safety) {
|
||||
t.Fatalf("err = %v, want a loopback port beside the proxy to stay blocked", err)
|
||||
}
|
||||
select {
|
||||
case first := <-arrived:
|
||||
t.Fatalf("the proxy received a connection it should not have: %q", first)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// countingResolver answers with one address and reports each lookup, so a test
|
||||
// can tell a skipped guard from a guard that ran and allowed the address.
|
||||
type countingResolver struct {
|
||||
onLookup func()
|
||||
addr netip.Addr
|
||||
}
|
||||
|
||||
func (resolver countingResolver) LookupNetIP(context.Context, string, string) ([]netip.Addr, error) {
|
||||
if resolver.onLookup != nil {
|
||||
resolver.onLookup()
|
||||
}
|
||||
return []netip.Addr{resolver.addr}, nil
|
||||
}
|
||||
@@ -214,6 +214,8 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "gpt-4.1", Description: "catalog default"},
|
||||
{ID: "gpt-4o-mini", Description: "fast model"},
|
||||
},
|
||||
// atomic-chat-local has no curated list: discover the user-loaded model
|
||||
// from the runtime's /v1/models endpoint.
|
||||
"opencode-go-anthropic-compatible": {
|
||||
{ID: "minimax-m3", Description: "MiniMax M3: default"},
|
||||
{ID: "minimax-m2.7", Description: "MiniMax M2.7: coding model"},
|
||||
|
||||
@@ -25,10 +25,32 @@ func (state ProviderState) Actions() []Action {
|
||||
}
|
||||
|
||||
func SetupCommand(descriptor providercatalog.Descriptor, name string, setActive bool) string {
|
||||
return setupCommand(descriptor, name, "", setActive)
|
||||
}
|
||||
|
||||
// SetupCommandWithModel is SetupCommand with an explicit --model. A local
|
||||
// runtime serves whichever model the user loaded, so its catalog DefaultModel is
|
||||
// only a placeholder: an adopt command that omits --model persists that
|
||||
// placeholder and the first completion fails with an unknown-model response.
|
||||
// An empty model falls back to SetupCommand's behaviour. Commands that cannot
|
||||
// be represented safely across supported shells are omitted.
|
||||
func SetupCommandWithModel(descriptor providercatalog.Descriptor, name string, model string, setActive bool) string {
|
||||
return setupCommand(descriptor, name, model, setActive)
|
||||
}
|
||||
|
||||
func setupCommand(descriptor providercatalog.Descriptor, name string, model string, setActive bool) string {
|
||||
parts := []string{"zero", "providers", "add", strings.TrimSpace(descriptor.ID)}
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
parts = append(parts, "--name", name)
|
||||
}
|
||||
if model = strings.TrimSpace(model); model != "" {
|
||||
// A separate operand beginning with '-' is rejected as an option.
|
||||
if strings.HasPrefix(model, "-") {
|
||||
parts = append(parts, "--model="+model)
|
||||
} else {
|
||||
parts = append(parts, "--model", model)
|
||||
}
|
||||
}
|
||||
if descriptor.RequiresAuth && len(descriptor.AuthEnvVars) > 0 {
|
||||
if env := strings.TrimSpace(descriptor.AuthEnvVars[0]); env != "" {
|
||||
parts = append(parts, "--api-key-env", env)
|
||||
@@ -37,7 +59,7 @@ func SetupCommand(descriptor providercatalog.Descriptor, name string, setActive
|
||||
if setActive {
|
||||
parts = append(parts, "--set-active")
|
||||
}
|
||||
return joinCommand(parts)
|
||||
return joinSetupCommand(parts)
|
||||
}
|
||||
|
||||
func UseCommand(name string) string {
|
||||
@@ -161,6 +183,54 @@ func firstNonEmpty(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinSetupCommand only emits arguments supported literally by POSIX shells,
|
||||
// cmd.exe, and PowerShell. Shell-specific quoting cannot safely cover all three.
|
||||
// Return no command when a value requires it; callers can offer interactive setup.
|
||||
func joinSetupCommand(parts []string) string {
|
||||
quoted := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part == "" {
|
||||
continue
|
||||
}
|
||||
arg := setupCommandArg(part)
|
||||
if value, inlineModel := strings.CutPrefix(part, "--model="); inlineModel {
|
||||
// Validate the untrusted model separately from the fixed separator;
|
||||
// '=' must not become an allowed character in model IDs.
|
||||
arg = setupCommandArg(value)
|
||||
if arg != "" {
|
||||
arg = "--model=" + arg
|
||||
}
|
||||
}
|
||||
if arg == "" {
|
||||
return ""
|
||||
}
|
||||
quoted = append(quoted, arg)
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
|
||||
func setupCommandArg(value string) string {
|
||||
for i, r := range value {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '-', '_', '.', '/', ':', ' ':
|
||||
continue
|
||||
case '@':
|
||||
// A leading @ starts splatting in PowerShell.
|
||||
if i > 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if value == "" || strings.Contains(value, " ") {
|
||||
return `"` + value + `"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func joinCommand(parts []string) string {
|
||||
quoted := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package provideronboarding
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -229,3 +231,33 @@ func assertNoSecretLeak(t *testing.T, actions []Action, secrets ...string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCommandWithModelShellRoundTrip(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX shell round-trip; portable command boundary is tested on every OS")
|
||||
}
|
||||
for _, id := range []string{"my loaded model", "unsloth/Qwen3-GGUF", "model:latest", "user/model@revision"} {
|
||||
command := SetupCommandWithModel(providercatalog.Descriptor{ID: "atomic-chat-local"}, "Atomic Chat Local", id, true)
|
||||
out, err := exec.Command("sh", "-c", `zero() { printf '%s\n' "$@"; }; `+command).CombinedOutput()
|
||||
want := "providers\nadd\natomic-chat-local\n--name\nAtomic Chat Local\n--model\n" + id + "\n--set-active\n"
|
||||
if err != nil || string(out) != want {
|
||||
t.Fatalf("argument round-trip: command=%q output=%q err=%v", command, out, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCommandWithModelOmitsUnsafeArguments(t *testing.T) {
|
||||
for _, value := range []string{"x&calc", `a"&calc&"b`, "$(id)", "`id`", "%PATH%", "!PATH!", "a;b", "a|b", "a^b", "a>b", "a\nb", "@args", "a\u201db", "-x&calc", `-a"&calc&"b`, "-$(id)", "-%PATH%", "-model=value"} {
|
||||
for _, field := range []string{"model", "name"} {
|
||||
name, model := "local", "loaded/model"
|
||||
if field == "model" {
|
||||
model = value
|
||||
} else {
|
||||
name = value
|
||||
}
|
||||
if got := SetupCommandWithModel(providercatalog.Descriptor{ID: "atomic-chat-local"}, name, model, true); got != "" {
|
||||
t.Fatalf("unsafe %s %q emitted as a shell command: %q", field, value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
)
|
||||
|
||||
// LocalRuntime describes a local, OpenAI-compatible model server that ZERO can
|
||||
@@ -102,18 +103,67 @@ func DetectLocalRuntimes(ctx context.Context, options LocalDetectOptions) []Dete
|
||||
// SetupAction returns the no-key onboarding action for a detected local runtime.
|
||||
func (runtime DetectedLocalRuntime) SetupAction() Action {
|
||||
descriptor := providercatalog.Descriptor{ID: runtime.CatalogID, RequiresAuth: false}
|
||||
command := SetupCommand(descriptor, runtime.Name, true)
|
||||
model := runtime.AdoptModel()
|
||||
name := strings.TrimSpace(runtime.Name)
|
||||
if name == "" {
|
||||
name = runtime.CatalogID
|
||||
}
|
||||
// Do not fall back to an unserved/default model when all advertised IDs
|
||||
// were rejected. Preserve other runtimes' existing empty-response behavior.
|
||||
if model == "" && (runtime.CatalogID == "atomic-chat-local" || len(runtime.Models) > 0) {
|
||||
return Action{
|
||||
Label: "Load a chat model",
|
||||
Detail: "Detected " + name + " on " + runtime.BaseURL + " but no usable chat model ID was discovered. Load a chat model in " + name + ", then run zero providers detect again.",
|
||||
}
|
||||
}
|
||||
command := SetupCommandWithModel(descriptor, runtime.Name, model, true)
|
||||
if command == "" {
|
||||
return Action{
|
||||
Label: "Use interactive setup",
|
||||
Detail: "A setup value cannot be safely included in a command for all supported shells. Run zero setup and select or enter the model there.",
|
||||
}
|
||||
}
|
||||
detail := "Detected " + name + " on " + runtime.BaseURL + " — no API key required."
|
||||
if model != "" {
|
||||
detail = "Detected " + name + " on " + runtime.BaseURL + " serving " + model + " — no API key required."
|
||||
}
|
||||
return Action{
|
||||
Label: "Use local runtime",
|
||||
Command: command,
|
||||
Detail: "Detected " + name + " on " + runtime.BaseURL + " — no API key required.",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// AdoptModel returns an advertised model eligible for automatic chat adoption,
|
||||
// preferring the catalog default when served. Shell syntax is handled when
|
||||
// rendering the command, so eligible IDs are not silently replaced for quoting.
|
||||
func (runtime DetectedLocalRuntime) AdoptModel() string {
|
||||
want := strings.TrimSpace(runtime.DefaultModel)
|
||||
first := ""
|
||||
defaultAlias := ""
|
||||
for _, raw := range runtime.Models {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" || providermodelcatalog.IsKnownNonCodingModelID(id) || runtime.CatalogID == "atomic-chat-local" && id == "local-model" {
|
||||
continue
|
||||
}
|
||||
if id == want {
|
||||
return id
|
||||
}
|
||||
// Ollama's untagged default is equivalent to its :latest spelling.
|
||||
// Retain the advertised ID and prefer an exact match if one follows.
|
||||
if runtime.CatalogID == "ollama" && want != "" && !strings.Contains(want, ":") && id == want+":latest" {
|
||||
defaultAlias = id
|
||||
}
|
||||
if first == "" {
|
||||
first = id
|
||||
}
|
||||
}
|
||||
if defaultAlias != "" {
|
||||
return defaultAlias
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func probeLocalRuntime(ctx context.Context, client *http.Client, timeout time.Duration, candidate LocalRuntime) ([]string, bool) {
|
||||
endpoint := localModelsEndpoint(candidate.BaseURL)
|
||||
if endpoint == "" {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalRuntimeCandidatesCoverOllamaAndLMStudio(t *testing.T) {
|
||||
func TestLocalRuntimeCandidatesCoverOllamaLMStudioAndAtomicChat(t *testing.T) {
|
||||
candidates := LocalRuntimeCandidates()
|
||||
if len(candidates) == 0 {
|
||||
t.Fatalf("LocalRuntimeCandidates() returned no candidates")
|
||||
@@ -39,6 +39,24 @@ func TestLocalRuntimeCandidatesCoverOllamaAndLMStudio(t *testing.T) {
|
||||
if lmstudio.RequiresKey {
|
||||
t.Fatalf("lmstudio candidate must not require an API key: %#v", lmstudio)
|
||||
}
|
||||
atomicChat, ok := byCatalog["atomic-chat-local"]
|
||||
if !ok {
|
||||
t.Fatalf("expected an atomic-chat-local candidate, got %#v", candidates)
|
||||
}
|
||||
if atomicChat.BaseURL != "http://127.0.0.1:1337/v1" {
|
||||
t.Fatalf("atomic-chat-local candidate BaseURL = %q, want http://127.0.0.1:1337/v1", atomicChat.BaseURL)
|
||||
}
|
||||
if atomicChat.DefaultModel != "local-model" {
|
||||
t.Fatalf("atomic-chat-local candidate DefaultModel = %q, want local-model", atomicChat.DefaultModel)
|
||||
}
|
||||
if atomicChat.RequiresKey {
|
||||
t.Fatalf("atomic-chat-local candidate must not require an API key: %#v", atomicChat)
|
||||
}
|
||||
// The hosted atomic-chat preset stays remote and key-gated, so it must never
|
||||
// be probed as a local runtime.
|
||||
if _, ok := byCatalog["atomic-chat"]; ok {
|
||||
t.Fatalf("hosted atomic-chat must not be a local-runtime candidate, got %#v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) {
|
||||
@@ -67,6 +85,145 @@ func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A local runtime serves whichever model the user loaded, so the adopt command
|
||||
// must pin the id the probe saw. Atomic Chat pulls its catalog from Hugging
|
||||
// Face, so the served id is an arbitrary repo id and never the "local-model"
|
||||
// placeholder the catalog carries as DefaultModel.
|
||||
func TestSetupActionPinsProbedModel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
detected := DetectLocalRuntimes(context.Background(), LocalDetectOptions{
|
||||
HTTPClient: server.Client(),
|
||||
Candidates: []LocalRuntime{{
|
||||
CatalogID: "atomic-chat-local",
|
||||
Name: "Atomic Chat Local",
|
||||
BaseURL: server.URL + "/v1",
|
||||
DefaultModel: "local-model",
|
||||
}},
|
||||
})
|
||||
if len(detected) != 1 {
|
||||
t.Fatalf("DetectLocalRuntimes() = %#v, want one reachable runtime", detected)
|
||||
}
|
||||
if got := detected[0].AdoptModel(); got != "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF" {
|
||||
t.Fatalf("AdoptModel() = %q, want the probed id, not the catalog placeholder", got)
|
||||
}
|
||||
action := detected[0].SetupAction()
|
||||
if !strings.Contains(action.Command, "--model unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF") {
|
||||
t.Fatalf("SetupAction command must pin the probed model, got %q", action.Command)
|
||||
}
|
||||
if strings.Contains(action.Command, "local-model") {
|
||||
t.Fatalf("SetupAction command must not persist the catalog placeholder, got %q", action.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// With no model IDs, Atomic Chat needs guidance instead of a failing command.
|
||||
func TestSetupActionOmitsModelWhenProbeFoundNone(t *testing.T) {
|
||||
runtime := DetectedLocalRuntime{
|
||||
LocalRuntime: LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", BaseURL: "http://127.0.0.1:1337/v1", DefaultModel: "local-model"},
|
||||
Reachable: true,
|
||||
}
|
||||
if got := runtime.AdoptModel(); got != "" {
|
||||
t.Fatalf("AdoptModel() = %q, want empty", got)
|
||||
}
|
||||
action := runtime.SetupAction()
|
||||
if strings.Contains(action.Command, "providers add") {
|
||||
t.Fatalf("atomic-chat-local with no served model must not advertise a bare add command, got %q", action.Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupActionPreservesModelsWithSpaces(t *testing.T) {
|
||||
runtime := DetectedLocalRuntime{
|
||||
LocalRuntime: LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local"},
|
||||
Models: []string{"my loaded model"},
|
||||
}
|
||||
if got := runtime.AdoptModel(); got != "my loaded model" {
|
||||
t.Fatalf("AdoptModel() = %q", got)
|
||||
}
|
||||
if got := runtime.SetupAction().Command; !strings.Contains(got, `--model "my loaded model"`) {
|
||||
t.Fatalf("model with spaces not preserved: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupActionOmitsUnsafeCommands(t *testing.T) {
|
||||
for _, id := range []string{"$(touch pwned)", "`id`", "x&calc", "a \" & calc & \"b", "a;b", "a|b", "a'b", "a>b", "%PATH%", "!PATH!", "@args", "line\ncommand", "a\u201db"} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
runtime := DetectedLocalRuntime{LocalRuntime: LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local"}, Models: []string{id, "safe-model"}}
|
||||
if runtime.AdoptModel() != id {
|
||||
t.Fatal("discovered model must not be silently changed")
|
||||
}
|
||||
action := runtime.SetupAction()
|
||||
if action.Command != "" || !strings.Contains(action.Detail, "zero setup") {
|
||||
t.Fatalf("unsafe command must be replaced with interactive guidance: %#v", action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupActionRejectsAtomicPlaceholder(t *testing.T) {
|
||||
runtime := DetectedLocalRuntime{LocalRuntime: LocalRuntime{CatalogID: "atomic-chat-local", DefaultModel: "local-model"}, Models: []string{"local-model"}}
|
||||
if runtime.AdoptModel() != "" || runtime.SetupAction().Command != "" {
|
||||
t.Fatal("placeholder must not be advertised as adoptable")
|
||||
}
|
||||
runtime.Models = append(runtime.Models, "loaded/model")
|
||||
if runtime.AdoptModel() != "loaded/model" {
|
||||
t.Fatal("real model must be selected instead of placeholder")
|
||||
}
|
||||
}
|
||||
|
||||
// A server that advertises the catalog default alongside other ids keeps the
|
||||
// default, so an existing Ollama setup does not silently switch models.
|
||||
func TestAdoptModelPrefersCatalogDefaultWhenServed(t *testing.T) {
|
||||
runtime := DetectedLocalRuntime{
|
||||
LocalRuntime: LocalRuntime{CatalogID: "ollama", Name: "Ollama Local", DefaultModel: "llama3.1"},
|
||||
Reachable: true,
|
||||
Models: []string{"qwen3:8b", "llama3.1"},
|
||||
}
|
||||
if got := runtime.AdoptModel(); got != "llama3.1" {
|
||||
t.Fatalf("AdoptModel() = %q, want the catalog default when the server serves it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptModelChatEligibilityAndOllamaDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, provider, defaultModel string
|
||||
models []string
|
||||
want string
|
||||
}{
|
||||
{"skip embedding", "atomic-chat-local", "local-model", []string{"text-embedding-local", "qwen3-coder-30b"}, "qwen3-coder-30b"},
|
||||
{"reject nonchat default", "lmstudio", "text-embedding-local", []string{"text-embedding-local", "chat-model"}, "chat-model"},
|
||||
{"latest alias", "ollama", "llama3.1", []string{"qwen3:8b", "llama3.1:latest"}, "llama3.1:latest"},
|
||||
{"exact before alias", "ollama", "llama3.1", []string{"llama3.1:latest", "llama3.1"}, "llama3.1"},
|
||||
{"absent default", "ollama", "llama3.1", []string{"text-embedding-local", "qwen3:8b", "other-chat"}, "qwen3:8b"},
|
||||
{"different tag", "ollama", "llama3.1", []string{"qwen3:8b", "llama3.1:custom"}, "qwen3:8b"},
|
||||
{"explicit default tag", "ollama", "llama3.1:custom", []string{"qwen3:8b", "llama3.1:latest"}, "qwen3:8b"},
|
||||
{"other runtime", "lmstudio", "llama3.1", []string{"qwen3:8b", "llama3.1:latest"}, "qwen3:8b"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
detected := DetectedLocalRuntime{LocalRuntime: LocalRuntime{CatalogID: tt.provider, DefaultModel: tt.defaultModel}, Models: tt.models}
|
||||
if got := detected.AdoptModel(); got != tt.want {
|
||||
t.Fatalf("AdoptModel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupActionOmitsKnownNonChatOnlyModels(t *testing.T) {
|
||||
for _, provider := range []string{"atomic-chat-local", "lmstudio", "ollama"} {
|
||||
for _, defaultModel := range []string{"local-model", "text-embedding-local"} {
|
||||
detected := DetectedLocalRuntime{LocalRuntime: LocalRuntime{CatalogID: provider, DefaultModel: defaultModel}, Models: []string{"text-embedding-local", "whisper-1"}}
|
||||
action := detected.SetupAction()
|
||||
if detected.AdoptModel() != "" || action.Command != "" || !strings.Contains(action.Detail, "zero providers detect") {
|
||||
t.Fatalf("%s must offer guidance without adopting non-chat models: %+v", provider, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLocalRuntimesSkipsUnreachableRuntime(t *testing.T) {
|
||||
// A client whose transport always fails simulates a closed local port.
|
||||
failing := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
@@ -152,3 +309,14 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestAtomicDetectionWithoutUsableModelsOmitsAdoption(t *testing.T) {
|
||||
for _, body := range []string{`{"data":[]}`, `not json`, strings.Repeat(" ", 256*1024) + `{"data":[{"id":"real"}]}`, `{"data":[{"id":"local-model"}]}`} {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }))
|
||||
detected := DetectLocalRuntimes(context.Background(), LocalDetectOptions{HTTPClient: server.Client(), Candidates: []LocalRuntime{{CatalogID: "atomic-chat-local", BaseURL: server.URL + "/v1", DefaultModel: "local-model"}}})
|
||||
server.Close()
|
||||
if len(detected) != 1 || detected[0].SetupAction().Command != "" || detected[0].SetupAction().Detail == "" {
|
||||
t.Fatalf("model-less detection advertised a failing action: %+v", detected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
|
||||
// these two).
|
||||
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
@@ -73,6 +74,7 @@ func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
|
||||
// the breakpoint, and thinking blocks must never carry cache_control (the API
|
||||
// rejects them) — the marker goes on the last cacheable block instead.
|
||||
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
messages := []anthropicMessage{
|
||||
{Role: "user", Content: "plain string"},
|
||||
{Role: "assistant", Content: []map[string]any{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
// can ask the model to retry, mirroring the OpenAI provider's behavior, instead
|
||||
// of silently dropping it.
|
||||
func TestStreamCompletionEmitsDroppedOnNamelessToolUseBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":""}}`)
|
||||
writeSSEEvent(w, "content_block_stop", `{"type":"content_block_stop","index":0}`)
|
||||
@@ -45,6 +46,7 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolUseBlock(t *testing.T) {
|
||||
|
||||
// A well-formed tool_use block must NOT emit a dropped signal.
|
||||
func TestStreamCompletionDoesNotDropValidToolUseBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"read_file"}}`)
|
||||
writeSSEEvent(w, "content_block_stop", `{"type":"content_block_stop","index":0}`)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// truncated at the output cap. The provider must surface it on the done event so
|
||||
// the agent does not treat a clipped answer as complete.
|
||||
func TestStreamCompletionSurfacesMaxTokensStopReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "message_start", `{"type":"message_start","message":{"usage":{"input_tokens":5,"output_tokens":0}}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"cut"}}`)
|
||||
@@ -37,6 +38,7 @@ func TestStreamCompletionSurfacesMaxTokensStopReason(t *testing.T) {
|
||||
|
||||
// A normal end_turn stop_reason must leave the done event's FinishReason empty.
|
||||
func TestStreamCompletionNormalStopReasonHasNoFinishReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`)
|
||||
writeSSEEvent(w, "message_delta", `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// message_stop or closing) must abort on the idle timeout instead of blocking
|
||||
// the agent forever.
|
||||
func TestStreamCompletionIdleTimeoutAbortsStalledStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
released := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`)
|
||||
|
||||
@@ -60,6 +60,7 @@ func firstUserContentBlocks(t *testing.T, body map[string]any) []any {
|
||||
// TestUserTextOnlyTurnUnchanged pins the text-only wire shape: a single
|
||||
// user message whose content is a one-element text-block array.
|
||||
func TestUserTextOnlyTurnUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := captureRequestBody(t, zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Describe this."},
|
||||
@@ -78,6 +79,7 @@ func TestUserTextOnlyTurnUnchanged(t *testing.T) {
|
||||
// TestUserImagePlusTextTurn asserts a text block followed by one image
|
||||
// source block carrying base64 of the RAW bytes.
|
||||
func TestUserImagePlusTextTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := []byte{0x89, 0x50, 0x4e, 0x47, 0x01, 0x02}
|
||||
body := captureRequestBody(t, zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
@@ -112,6 +114,7 @@ func TestUserImagePlusTextTurn(t *testing.T) {
|
||||
// TestUserImageOnlyTurnEmits asserts an image-only user turn (empty Content)
|
||||
// still produces a user message with a single image block.
|
||||
func TestUserImageOnlyTurnEmits(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := []byte{0xff, 0xd8, 0xff, 0xe0}
|
||||
body := captureRequestBody(t, zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
|
||||
@@ -9,10 +9,16 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
providerio.ShrinkBackoffForTest()
|
||||
}
|
||||
|
||||
func TestStreamCompletionPostsMessagesRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotPath string
|
||||
var gotAPIKey string
|
||||
var gotVersion string
|
||||
@@ -123,6 +129,7 @@ func TestStreamCompletionPostsMessagesRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionAppliesCustomAuthAndHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotDefaultAuth string
|
||||
var gotCustomAuth string
|
||||
var gotTenant string
|
||||
@@ -165,6 +172,7 @@ func TestStreamCompletionAppliesCustomAuthAndHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "message_start", `{"type":"message_start","message":{"usage":{"input_tokens":25}}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`)
|
||||
@@ -186,6 +194,7 @@ func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionReportsCacheTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "message_start", `{"type":"message_start","message":{"usage":{"input_tokens":10,"cache_read_input_tokens":200,"cache_creation_input_tokens":40}}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`)
|
||||
@@ -217,6 +226,7 @@ func TestStreamCompletionReportsCacheTokens(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionEnablesThinkingWhenEffortRequested(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
@@ -257,6 +267,7 @@ func TestStreamCompletionEnablesThinkingWhenEffortRequested(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionOmitsThinkingWithoutEffort(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
@@ -287,6 +298,7 @@ func TestStreamCompletionOmitsThinkingWithoutEffort(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionCapturesThinkingBlocksForReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think"}}`)
|
||||
@@ -312,6 +324,7 @@ func TestStreamCompletionCapturesThinkingBlocksForReplay(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionPreservesUnclosedThinkingBlockAtStreamEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The SSE ends after thinking_delta/signature_delta but BEFORE the thinking
|
||||
// block's content_block_stop. The open buffer must still be finalized into the
|
||||
// done event's ReasoningBlocks (via closeOpen), or the next Anthropic replay
|
||||
@@ -339,6 +352,7 @@ func TestStreamCompletionPreservesUnclosedThinkingBlockAtStreamEnd(t *testing.T)
|
||||
}
|
||||
|
||||
func TestAnthropicRequestReplaysThinkingBlocksFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
@@ -392,6 +406,7 @@ func TestAnthropicRequestReplaysThinkingBlocksFirst(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsToolUseBlocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"read_file","input":{}}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}`)
|
||||
@@ -414,6 +429,7 @@ func TestStreamCompletionEmitsToolUseBlocks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionClosesOpenToolCallOnEOF(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"grep","input":{}}}`)
|
||||
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"pattern\":\"Zero\"}"}}`)
|
||||
@@ -429,6 +445,7 @@ func TestStreamCompletionClosesOpenToolCallOnEOF(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
@@ -464,6 +481,7 @@ func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProviderWithKey(t, "sk-ant", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSEEvent(w, "error", `{"type":"error","error":{"message":"stream failed sk-ant","type":"overloaded_error"}}`)
|
||||
})
|
||||
@@ -481,6 +499,7 @@ func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamCompletionRejectsMalformedHistoryBeforeDispatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("provider should not dispatch malformed history")
|
||||
})
|
||||
@@ -507,6 +526,7 @@ func TestStreamCompletionRejectsMalformedHistoryBeforeDispatch(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewRequiresModelAndPositiveMaxTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := New(Options{}); err == nil {
|
||||
t.Fatal("New without model returned nil error")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMapStopReasonRefusal(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := mapStopReason("refusal"); got != zeroruntime.FinishReasonContentFilter {
|
||||
t.Errorf("refusal → %q, want content_filter (M4)", got)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// EscalationSwitchers builds the two mid-run model switchers a run installs when
|
||||
// it has opted into escalation.
|
||||
//
|
||||
// ONE IMPLEMENTATION, TWO SURFACES. exec grew this first, and the interactive TUI
|
||||
// needs the same behaviour rather than a similar one. The part that is easy to get
|
||||
// subtly wrong is not the switch, it is the nil handling around it, and a second
|
||||
// copy of that would only ever be exercised on one surface.
|
||||
//
|
||||
// THE NIL CONTRACTS ARE THE CONTRACT. The agent loop reassigns the provider only
|
||||
// when a switcher returns a non-nil one, so a (nil, nil) return means "no swap"
|
||||
// and has to leave everything as it was, including whatever the caller tracks
|
||||
// through onSwitch. onSwitch therefore fires only on a real swap, and may be nil.
|
||||
// An error is reported to the loop, which records a note and continues on the
|
||||
// current model.
|
||||
//
|
||||
// The session switcher comes back nil unless the run STARTED optimized. A run
|
||||
// that began on the default adapter stays on it, so escalation cannot quietly
|
||||
// change the transport underneath a session.
|
||||
func EscalationSwitchers(
|
||||
profile config.ProviderProfile,
|
||||
provider zeroruntime.Provider,
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error),
|
||||
onSwitch func(modelID string),
|
||||
) (
|
||||
func(context.Context, string) (zeroruntime.Provider, error),
|
||||
func(context.Context, string) (zeroruntime.TurnSessionProvider, error),
|
||||
) {
|
||||
if newProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// The escalated profile is the run's profile with the model replaced, so the
|
||||
// credential, base URL and headers travel with it. Callers pass a newProvider
|
||||
// that already applies the stored key, which is why there is no per-site key
|
||||
// handling here.
|
||||
switchTo := func(modelID string) (config.ProviderProfile, zeroruntime.Provider, error) {
|
||||
switched := profile
|
||||
switched.Model = modelID
|
||||
provider, err := newProvider(switched)
|
||||
return switched, provider, err
|
||||
}
|
||||
|
||||
modelSwitcher := func(_ context.Context, modelID string) (zeroruntime.Provider, error) {
|
||||
_, switchedProvider, err := switchTo(modelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if switchedProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if onSwitch != nil {
|
||||
onSwitch(modelID)
|
||||
}
|
||||
return switchedProvider, nil
|
||||
}
|
||||
|
||||
turnSessions, _ := OptimizedTurnSessions(profile, provider, Options{})
|
||||
if turnSessions == nil {
|
||||
return modelSwitcher, nil
|
||||
}
|
||||
|
||||
sessionSwitcher := func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
|
||||
switchedProfile, switchedProvider, err := switchTo(modelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if switchedProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if onSwitch != nil {
|
||||
onSwitch(modelID)
|
||||
}
|
||||
if optimized, ok := OptimizedTurnSessions(switchedProfile, switchedProvider, Options{}); ok {
|
||||
return optimized, nil
|
||||
}
|
||||
// Ineligible target: the default adapter, but carrying the switched
|
||||
// model's own capability projection rather than the original's.
|
||||
return DefaultTurnSessions(switchedProfile, switchedProvider, Options{}), nil
|
||||
}
|
||||
return modelSwitcher, sessionSwitcher
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type escalationStubProvider struct{ model string }
|
||||
|
||||
func (escalationStubProvider) Name() string { return "stub" }
|
||||
|
||||
func (escalationStubProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// THE ESCALATED PROFILE IS THE RUN'S PROFILE WITH THE MODEL REPLACED.
|
||||
//
|
||||
// Everything else has to travel: the base URL, the credential and the headers.
|
||||
// Building a bare profile with only the model set would escalate onto an
|
||||
// unauthenticated endpoint, and the failure would look like a provider outage.
|
||||
func TestEscalationSwitchersKeepTheProfileAndReplaceOnlyTheModel(t *testing.T) {
|
||||
profile := config.ProviderProfile{Name: "acme", Model: "small", BaseURL: "https://acme.test", APIKey: "sk-live"}
|
||||
var asked config.ProviderProfile
|
||||
switcher, _ := EscalationSwitchers(profile, escalationStubProvider{}, func(p config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
asked = p
|
||||
return escalationStubProvider{model: p.Model}, nil
|
||||
}, nil)
|
||||
if switcher == nil {
|
||||
t.Fatal("no model switcher was built for a run that opted into escalation")
|
||||
}
|
||||
if _, err := switcher(context.Background(), "large"); err != nil {
|
||||
t.Fatalf("switch: %v", err)
|
||||
}
|
||||
if asked.Model != "large" {
|
||||
t.Errorf("escalated to model %q, want large", asked.Model)
|
||||
}
|
||||
if asked.BaseURL != profile.BaseURL || asked.APIKey != profile.APIKey || asked.Name != profile.Name {
|
||||
t.Errorf("the escalated profile lost the run's provider identity: %+v", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A (nil, nil) RETURN MEANS NO SWAP, AND MUST CHANGE NOTHING.
|
||||
//
|
||||
// The agent loop reassigns the provider only when it gets a non-nil one, so a nil
|
||||
// provider leaves the run on its current model. Anything the caller tracks
|
||||
// through onSwitch has to stay in step with that, or usage gets attributed to a
|
||||
// model the run never moved to.
|
||||
func TestEscalationSwitchersDoNotReportASwitchThatDidNotHappen(t *testing.T) {
|
||||
switched := []string{}
|
||||
switcher, _ := EscalationSwitchers(
|
||||
config.ProviderProfile{Model: "small"}, escalationStubProvider{},
|
||||
func(config.ProviderProfile) (zeroruntime.Provider, error) { return nil, nil },
|
||||
func(modelID string) { switched = append(switched, modelID) },
|
||||
)
|
||||
provider, err := switcher(context.Background(), "large")
|
||||
if err != nil {
|
||||
t.Fatalf("a nil provider is not an error: %v", err)
|
||||
}
|
||||
if provider != nil {
|
||||
t.Errorf("provider = %v, want nil", provider)
|
||||
}
|
||||
if len(switched) != 0 {
|
||||
t.Errorf("onSwitch fired for a swap that never happened: %v", switched)
|
||||
}
|
||||
}
|
||||
|
||||
// An error reaches the loop, which records it and stays on the current model.
|
||||
func TestEscalationSwitchersReportProviderErrors(t *testing.T) {
|
||||
want := errors.New("no credential for that model")
|
||||
switched := []string{}
|
||||
switcher, _ := EscalationSwitchers(
|
||||
config.ProviderProfile{Model: "small"}, escalationStubProvider{},
|
||||
func(config.ProviderProfile) (zeroruntime.Provider, error) { return nil, want },
|
||||
func(modelID string) { switched = append(switched, modelID) },
|
||||
)
|
||||
if _, err := switcher(context.Background(), "large"); !errors.Is(err, want) {
|
||||
t.Errorf("err = %v, want %v", err, want)
|
||||
}
|
||||
if len(switched) != 0 {
|
||||
t.Errorf("onSwitch fired for a failed switch: %v", switched)
|
||||
}
|
||||
}
|
||||
|
||||
// And a real swap does report itself, or the caller's attribution never moves.
|
||||
func TestEscalationSwitchersReportARealSwitch(t *testing.T) {
|
||||
switched := []string{}
|
||||
switcher, _ := EscalationSwitchers(
|
||||
config.ProviderProfile{Model: "small"}, escalationStubProvider{},
|
||||
func(p config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return escalationStubProvider{model: p.Model}, nil
|
||||
},
|
||||
func(modelID string) { switched = append(switched, modelID) },
|
||||
)
|
||||
if _, err := switcher(context.Background(), "large"); err != nil {
|
||||
t.Fatalf("switch: %v", err)
|
||||
}
|
||||
if len(switched) != 1 || switched[0] != "large" {
|
||||
t.Errorf("onSwitch recorded %v, want one switch to large", switched)
|
||||
}
|
||||
}
|
||||
|
||||
// A RUN THAT STARTED ON THE DEFAULT ADAPTER STAYS ON IT.
|
||||
//
|
||||
// The session switcher is installed only when the run START is optimized, so
|
||||
// escalation cannot quietly change the transport underneath a session that never
|
||||
// had one.
|
||||
func TestEscalationSwitchersOmitTheSessionSwitcherForAnUnoptimizedStart(t *testing.T) {
|
||||
_, sessionSwitcher := EscalationSwitchers(
|
||||
config.ProviderProfile{Name: "acme", Model: "small"}, escalationStubProvider{},
|
||||
func(p config.ProviderProfile) (zeroruntime.Provider, error) { return escalationStubProvider{}, nil },
|
||||
nil,
|
||||
)
|
||||
if sessionSwitcher != nil {
|
||||
t.Error("a run that did not start with optimized turn sessions was given a session switcher")
|
||||
}
|
||||
}
|
||||
|
||||
// No provider factory means no escalation rather than a switcher that panics.
|
||||
func TestEscalationSwitchersRequireAProviderFactory(t *testing.T) {
|
||||
switcher, sessionSwitcher := EscalationSwitchers(config.ProviderProfile{}, escalationStubProvider{}, nil, nil)
|
||||
if switcher != nil || sessionSwitcher != nil {
|
||||
t.Error("switchers were built without a provider factory to build providers with")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
// wraps into the default TurnSessionProvider: construction succeeds, a session
|
||||
// opens, and Compact reports unsupported (the default adapter contract).
|
||||
func TestNewTurnSessionProviderForEveryKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
kinds := []config.ProviderKind{
|
||||
config.ProviderKindOpenAI,
|
||||
config.ProviderKindOpenAICompatible,
|
||||
@@ -65,6 +66,7 @@ func TestNewTurnSessionProviderForEveryKind(t *testing.T) {
|
||||
// projects the model-registry entry (context limits, capability flags,
|
||||
// reasoning efforts) into the flat ProviderCapabilities.
|
||||
func TestNewTurnSessionProviderProjectsRegistryCapabilities(t *testing.T) {
|
||||
t.Parallel()
|
||||
registry, err := modelregistry.NewRegistry([]modelregistry.ModelEntry{{
|
||||
ID: "pr7-caps-model",
|
||||
DisplayName: "PR7 Capability Probe",
|
||||
@@ -137,6 +139,7 @@ func TestNewTurnSessionProviderProjectsRegistryCapabilities(t *testing.T) {
|
||||
// entry that enumerates no efforts of its own still reports the name-inferred
|
||||
// effective tiers the /effort picker and run-time resolver advertise.
|
||||
func TestNewTurnSessionProviderUsesEffectiveReasoningEfforts(t *testing.T) {
|
||||
t.Parallel()
|
||||
registry, err := modelregistry.NewRegistry([]modelregistry.ModelEntry{{
|
||||
// A gpt-5-family id with NO ReasoningEfforts listed: the effective
|
||||
// efforts come from name inference, differing from the raw entry.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
// emitDone must mark the shared state done so callers observe it through the
|
||||
// pointer (a by-value receiver would make state.done a dead store).
|
||||
func TestEmitDoneMarksStateDoneThroughPointer(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider, err := New(Options{Model: "gemini-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user