mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b3ca3599c | ||
|
|
6e08e9c259 | ||
|
|
67d1e37580 | ||
|
|
b239788334 | ||
|
|
230a129630 | ||
|
|
024ba0ee89 | ||
|
|
8ff542bb8f | ||
|
|
3b81dc0f1d | ||
|
|
8400f9086e | ||
|
|
48610b4aa5 | ||
|
|
e0168d4a72 | ||
|
|
7758ec5934 | ||
|
|
8c23620b88 | ||
|
|
4df239b32d | ||
|
|
ab02818d93 | ||
|
|
d5c6f85c56 | ||
|
|
548765ef4f | ||
|
|
ada465979d | ||
|
|
6937a309cf | ||
|
|
da244bcbfc | ||
|
|
e34c8d2252 | ||
|
|
91d921f4c5 | ||
|
|
6ae8396f02 | ||
|
|
2837f5efb3 | ||
|
|
08fbea7a32 | ||
|
|
82ae3a8809 | ||
|
|
488def68dc | ||
|
|
b04fe96e9e | ||
|
|
79fa1422ef | ||
|
|
1aacf38b72 | ||
|
|
3063593129 | ||
|
|
d6b25fc303 | ||
|
|
7c50a9fb98 | ||
|
|
275e248669 | ||
|
|
0207f2b269 | ||
|
|
8b3d1b1597 | ||
|
|
a3852bac59 | ||
|
|
a3e7446681 | ||
|
|
0021475d4c | ||
|
|
deba3adfca | ||
|
|
501f32160f | ||
|
|
1e4db7c664 | ||
|
|
f9092668a8 | ||
|
|
7f5e5af7bb | ||
|
|
f30f550e60 |
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")
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -147,11 +146,7 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+31
-2
@@ -30,15 +30,35 @@ var processAlive = osProcessAlive
|
||||
// only to enrich a contention error with the current holder's PID; the kernel
|
||||
// lock, not PID metadata, is authoritative.
|
||||
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
|
||||
return lockutil.TryAcquireFileLock(path)
|
||||
}, func() (int, error) {
|
||||
return readPidFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
func acquireLockRoot(root *os.Root, name, displayPath string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
|
||||
return lockutil.TryAcquireFileLockRoot(root, name, displayPath)
|
||||
}, func() (int, error) {
|
||||
return readPidFileRoot(root, name)
|
||||
})
|
||||
}
|
||||
|
||||
func acquireLockWith(
|
||||
isAlive func(pid int) bool,
|
||||
acquire func() (*lockutil.FileLock, error),
|
||||
readPID func() (int, error),
|
||||
) (*fileLock, error) {
|
||||
if isAlive == nil {
|
||||
isAlive = processAlive
|
||||
}
|
||||
lock, err := lockutil.TryAcquireFileLock(path)
|
||||
lock, err := acquire()
|
||||
if err != nil {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, err
|
||||
}
|
||||
pid, perr := readPidFile(path)
|
||||
pid, perr := readPID()
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
@@ -62,6 +82,15 @@ func (l *fileLock) release() error {
|
||||
// readPidFile reads and parses the PID recorded in a lock file.
|
||||
func readPidFile(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
return parsePidFile(data, err)
|
||||
}
|
||||
|
||||
func readPidFileRoot(root *os.Root, name string) (int, error) {
|
||||
data, err := root.ReadFile(name)
|
||||
return parsePidFile(data, err)
|
||||
}
|
||||
|
||||
func parsePidFile(data []byte, err error) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
+162
-7
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -23,6 +24,23 @@ type Server struct {
|
||||
listener net.Listener
|
||||
conns map[net.Conn]struct{} // open connections, closed on Shutdown so blocked reads return
|
||||
lock *fileLock
|
||||
// runtimeRoot is retained for the full default-daemon lifecycle. Every file
|
||||
// child is addressed relative to this capability; runtimeDir is used only to
|
||||
// verify the unavoidable AF_UNIX pathname bind still names the same object.
|
||||
runtimeRoot *os.Root
|
||||
runtimeDir string
|
||||
// socketRoot captures the directory that actually received the pathname
|
||||
// bind. It can differ from runtimeRoot only when the configured directory is
|
||||
// exchanged in the final pre-bind window; retaining it makes rollback target
|
||||
// the socket that was really created rather than re-resolving the pathname.
|
||||
socketRoot *os.Root
|
||||
socketName string
|
||||
// statusRoot binds status publication and shutdown cleanup to the same
|
||||
// directory object. statusCommitted is set only after this server publishes
|
||||
// its document, so a failed startup never removes a previous daemon's status.
|
||||
statusRoot *os.Root
|
||||
statusName string
|
||||
statusCommitted bool
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -41,6 +59,14 @@ type ServerOptions struct {
|
||||
Now func() time.Time
|
||||
Log func(string)
|
||||
isAlive func(int) bool // test hook for the single-instance lock
|
||||
// beforeStatusReplace, replaceStatusFile, and syncStatusParent are test hooks
|
||||
// for the status-file commit boundary. nil selects production behavior.
|
||||
beforeStatusReplace func()
|
||||
replaceStatusFile func(root *os.Root, src, dst string) error
|
||||
syncStatusParent func(root *os.Root) error
|
||||
afterRuntimeRootOpen func() // test hook at the default-root trust boundary
|
||||
beforeSocketBind func() // test hook before the final root verification
|
||||
afterSocketPreflight func() // test hook in the unavoidable pathname-bind window
|
||||
}
|
||||
|
||||
// NewServer validates options and builds a Server.
|
||||
@@ -80,11 +106,41 @@ func (s *Server) Serve() error {
|
||||
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
|
||||
defaultRoot, isDefault, err := openDefaultRuntimeRoot(s.opts.Paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
if isDefault {
|
||||
s.runtimeRoot = defaultRoot
|
||||
s.runtimeDir = filepath.Dir(s.opts.Paths.Socket)
|
||||
s.statusRoot = defaultRoot
|
||||
s.statusName = filepath.Base(s.opts.Paths.Status)
|
||||
if s.opts.afterRuntimeRootOpen != nil {
|
||||
s.opts.afterRuntimeRootOpen()
|
||||
}
|
||||
if err := runtimeRootStillNamesPath(defaultRoot, s.runtimeDir); err != nil {
|
||||
s.closeRuntimeRoots()
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := secureCustomRuntimeParents(s.opts.Paths); err != nil {
|
||||
return err
|
||||
}
|
||||
statusRoot, err := openStatusRoot(s.opts.Paths.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: open status directory: %w", err)
|
||||
}
|
||||
s.statusRoot = statusRoot
|
||||
s.statusName = filepath.Base(s.opts.Paths.Status)
|
||||
}
|
||||
var lock *fileLock
|
||||
if s.runtimeRoot != nil {
|
||||
lock, err = acquireLockRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Lock), s.opts.Paths.Lock, s.opts.isAlive)
|
||||
} else {
|
||||
lock, err = acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
}
|
||||
if err != nil {
|
||||
s.closeRuntimeRoots()
|
||||
return err
|
||||
}
|
||||
s.lock = lock
|
||||
@@ -92,7 +148,22 @@ func (s *Server) Serve() error {
|
||||
|
||||
// A leftover socket file from an unclean exit would make Listen fail with
|
||||
// "address already in use"; we hold the lock, so any socket here is stale.
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("daemon: remove stale control socket: %w", err)
|
||||
}
|
||||
if s.opts.beforeSocketBind != nil {
|
||||
s.opts.beforeSocketBind()
|
||||
}
|
||||
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.opts.afterSocketPreflight != nil {
|
||||
s.opts.afterSocketPreflight()
|
||||
}
|
||||
} else {
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
@@ -101,6 +172,22 @@ func (s *Server) Serve() error {
|
||||
s.mu.Lock()
|
||||
s.listener = listener
|
||||
s.mu.Unlock()
|
||||
if s.runtimeRoot != nil {
|
||||
boundRoot, boundName, err := captureBoundSocketRoot(s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
return fmt.Errorf("daemon: capture bound control socket: %w", err)
|
||||
}
|
||||
s.socketRoot = boundRoot
|
||||
s.socketName = boundName
|
||||
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := s.runtimeRoot.Lstat(filepath.Base(s.opts.Paths.Socket))
|
||||
if err != nil || info.Mode()&os.ModeSocket == 0 {
|
||||
return fmt.Errorf("daemon: bound control socket is outside the secured runtime directory")
|
||||
}
|
||||
}
|
||||
// If Shutdown already fired during the bind window, close now and bail so a
|
||||
// shutdown requested at startup is never lost (the accept loop would otherwise
|
||||
// block forever waiting for a connection that never comes) (D4).
|
||||
@@ -111,7 +198,7 @@ func (s *Server) Serve() error {
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
|
||||
if err := s.hardenSocket(); err != nil {
|
||||
return fmt.Errorf("daemon: harden control socket: %w", err)
|
||||
}
|
||||
s.startedAt = s.opts.Now()
|
||||
@@ -190,11 +277,56 @@ func (s *Server) cleanup() {
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
_ = os.Remove(s.opts.Paths.Status)
|
||||
if s.socketRoot != nil {
|
||||
if err := s.socketRoot.Remove(s.socketName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove control socket: %v", err)
|
||||
}
|
||||
} else if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove control socket: %v", err)
|
||||
}
|
||||
} else {
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
}
|
||||
if s.statusRoot != nil {
|
||||
if s.statusCommitted {
|
||||
if err := s.statusRoot.Remove(s.statusName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove status file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.lock != nil {
|
||||
_ = s.lock.release()
|
||||
}
|
||||
s.closeRuntimeRoots()
|
||||
}
|
||||
|
||||
func (s *Server) hardenSocket() error {
|
||||
if s.runtimeRoot != nil {
|
||||
return hardenSocketFileRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Socket))
|
||||
}
|
||||
return hardenSocketFile(s.opts.Paths.Socket)
|
||||
}
|
||||
|
||||
func (s *Server) closeRuntimeRoots() {
|
||||
if s.socketRoot != nil {
|
||||
if err := s.socketRoot.Close(); err != nil {
|
||||
s.logf("daemon: close bound socket directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.socketRoot = nil
|
||||
if s.statusRoot != nil && s.statusRoot != s.runtimeRoot {
|
||||
if err := s.statusRoot.Close(); err != nil {
|
||||
s.logf("daemon: close status directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.statusRoot = nil
|
||||
if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Close(); err != nil {
|
||||
s.logf("daemon: close runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.runtimeRoot = nil
|
||||
}
|
||||
|
||||
func (s *Server) writeStatusFile() error {
|
||||
@@ -208,7 +340,30 @@ func (s *Server) writeStatusFile() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
|
||||
root := s.statusRoot
|
||||
ownedRoot := false
|
||||
if root == nil {
|
||||
var err error
|
||||
root, err = openStatusRoot(s.opts.Paths.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
ownedRoot = true
|
||||
}
|
||||
committed, err := writeStatusFileAtomicallyRoot(root, filepath.Base(s.opts.Paths.Status), data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent)
|
||||
if ownedRoot {
|
||||
if closeErr := root.Close(); closeErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("close status directory: %w", closeErr))
|
||||
}
|
||||
}
|
||||
if committed {
|
||||
s.statusCommitted = true
|
||||
}
|
||||
if err != nil {
|
||||
if committed {
|
||||
s.logf("daemon: status file publication committed with warning: %v", err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -2,20 +2,201 @@ package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
)
|
||||
|
||||
func TestServeSupportsReadOnlyCustomRuntimeDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix permission compatibility regression")
|
||||
}
|
||||
dir, err := os.MkdirTemp("/tmp", "zero-serve-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "daemon.sock"),
|
||||
Lock: filepath.Join(dir, "daemon.lock"),
|
||||
Status: filepath.Join(dir, "daemon.status"),
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
srv.Shutdown()
|
||||
if err := <-serveErr; err != nil {
|
||||
t.Fatalf("Serve with 0755 custom runtime directory: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o755 {
|
||||
t.Fatalf("custom runtime directory permissions = %04o, want unchanged 0755", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeKeepsDefaultRootBoundAcrossCoordination(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
|
||||
}
|
||||
home, err := os.MkdirTemp("/tmp", "zero-root-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
moved := filepath.Join(home, "bound-runtime")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
srv.opts.beforeSocketBind = func() {
|
||||
if err := os.Rename(dir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = srv.Serve()
|
||||
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
|
||||
t.Fatalf("Serve error = %v, want replaced-runtime rejection", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
|
||||
t.Fatalf("rooted lock was not created in the bound directory: %v", err)
|
||||
}
|
||||
for _, path := range []string{paths.Lock, paths.Socket, paths.Status} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("substitute runtime entry %s was touched: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemovesSocketBoundAfterFinalRuntimeSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
|
||||
}
|
||||
home, err := os.MkdirTemp("/tmp", "zero-bind-swap-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
moved := filepath.Join(home, "trusted-runtime")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
srv.opts.afterSocketPreflight = func() {
|
||||
if err := os.Rename(dir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = srv.Serve()
|
||||
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
|
||||
t.Fatalf("Serve error = %v, want post-bind runtime replacement rejection", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
|
||||
t.Fatalf("rooted lock was not retained in the trusted directory: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(paths.Socket); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("substitute runtime retained stale socket: %v", err)
|
||||
}
|
||||
listener, err := net.Listen("unix", paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("substitute runtime remained unbindable after rollback: %v", err)
|
||||
}
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRuntimeLogHardensDefaultRootBeforeOpen(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows DACL hardening has platform-specific coverage")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
if err := os.MkdirAll(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, logPath, err := OpenRuntimeLog(paths)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenRuntimeLog: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if logPath != filepath.Join(dir, "daemon.log") {
|
||||
t.Fatalf("log path = %q", logPath)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got&0o077 != 0 {
|
||||
t.Fatalf("default runtime directory permissions = %04o, want owner-only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
return newTestServerWithPaths(t, launcher, paths), paths
|
||||
}
|
||||
|
||||
func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Server {
|
||||
t.Helper()
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
@@ -28,7 +209,7 @@ func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return srv, paths
|
||||
return srv
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
@@ -136,6 +317,153 @@ func TestServerEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServePreservesPreviousStatusWhenReplacementFails(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
|
||||
dir, err := os.MkdirTemp("", "zero-daemon-status-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{Socket: filepath.Join(dir, "d.sock"), Lock: filepath.Join(dir, "d.lock"), Status: filepath.Join(dir, "d.status")}
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
previous := []byte(`{"pid":7,"socket":"previous","version":1}`)
|
||||
if err := os.WriteFile(paths.Status, previous, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.opts.replaceStatusFile = func(*os.Root, string, string) error {
|
||||
return errors.New("injected replacement failure")
|
||||
}
|
||||
|
||||
if err := srv.Serve(); err == nil || !strings.Contains(err.Error(), "injected replacement failure") {
|
||||
t.Fatal("Serve succeeded despite injected status replacement failure")
|
||||
}
|
||||
got, err := os.ReadFile(paths.Status)
|
||||
if err != nil {
|
||||
t.Fatalf("previous status was removed after failed publication: %v", err)
|
||||
}
|
||||
if string(got) != string(previous) {
|
||||
t.Fatalf("previous status changed after failed publication: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeCleansStatusThroughBoundDirectoryAfterSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not permit renaming the directory containing the live socket")
|
||||
}
|
||||
parent, err := os.MkdirTemp("", "zero-daemon-swap-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(parent) })
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
|
||||
srv.opts.beforeStatusReplace = func() {
|
||||
if err := os.Rename(dir, movedDir); err != nil {
|
||||
t.Fatalf("move status directory: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(paths.Status, substitute, 0o600); err != nil {
|
||||
t.Fatalf("write substitute status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, filepath.Join(movedDir, "d.status"))
|
||||
srv.Shutdown()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(movedDir, "d.status")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("bound status survived cleanup: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(paths.Status)
|
||||
if err != nil {
|
||||
t.Fatalf("substitute status was removed by pathname cleanup: %v", err)
|
||||
}
|
||||
if string(got) != string(substitute) {
|
||||
t.Fatalf("substitute status changed during cleanup: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPublishesDefaultStatusAfterCrashReportCreatesRuntimeDirectory(t *testing.T) {
|
||||
home, err := os.MkdirTemp("", "zero-home-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
|
||||
if _, err := observability.WriteCrashReport(
|
||||
observability.DefaultCrashDir(),
|
||||
"cli",
|
||||
"boom",
|
||||
[]byte("stack"),
|
||||
time.Now(),
|
||||
); err != nil {
|
||||
t.Fatalf("WriteCrashReport: %v", err)
|
||||
}
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
if paths.Status != filepath.Join(home, ".zero", "daemon.status") {
|
||||
t.Fatalf("default status path = %q, want path beneath temporary home", paths.Status)
|
||||
}
|
||||
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
|
||||
deadline := time.NewTimer(3 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
if _, err := os.Stat(paths.Status); err == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
t.Fatalf("Serve returned before publishing status: %v", err)
|
||||
case <-deadline.C:
|
||||
t.Fatal("daemon did not publish its default status file")
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
srv.Shutdown()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSecondInstanceFails(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
|
||||
+179
-4
@@ -1,9 +1,12 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/privatedir"
|
||||
)
|
||||
|
||||
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
|
||||
@@ -12,10 +15,182 @@ import (
|
||||
// safe cross-platform ceiling.
|
||||
const maxUnixSocketPath = 103
|
||||
|
||||
// secureSocketParent creates the socket's parent directory owner-only (0700 on
|
||||
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
|
||||
func secureSocketParent(socketPath string) error {
|
||||
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
|
||||
func secureCustomRuntimeParents(paths Paths) error {
|
||||
parents := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "socket", path: filepath.Dir(paths.Socket)},
|
||||
{name: "lock", path: filepath.Dir(paths.Lock)},
|
||||
{name: "status", path: filepath.Dir(paths.Status)},
|
||||
}
|
||||
seen := make(map[string]struct{}, len(parents))
|
||||
for _, parent := range parents {
|
||||
absolute, err := filepath.Abs(parent.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: resolve %s directory: %w", parent.name, err)
|
||||
}
|
||||
if _, ok := seen[absolute]; ok {
|
||||
continue
|
||||
}
|
||||
seen[absolute] = struct{}{}
|
||||
if err := os.MkdirAll(absolute, 0o700); err != nil {
|
||||
return fmt.Errorf("daemon: create %s directory: %w", parent.name, err)
|
||||
}
|
||||
root, err := os.OpenRoot(absolute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: open custom %s directory: %w", parent.name, err)
|
||||
}
|
||||
validateErr := validateStatusRoot(root)
|
||||
closeErr := root.Close()
|
||||
if validateErr != nil {
|
||||
return fmt.Errorf("daemon: validate custom %s directory: %w", parent.name, validateErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("daemon: close custom %s directory: %w", parent.name, closeErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenRuntimeLog secures the default runtime root before acquiring the
|
||||
// detached daemon's log descriptor. Custom endpoint layouts remain supported,
|
||||
// but their existing parent permissions are caller-owned and left untouched.
|
||||
func OpenRuntimeLog(paths Paths) (*os.File, string, error) {
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
root, isDefault, err := openDefaultRuntimeRoot(paths)
|
||||
if err != nil {
|
||||
return nil, logPath, err
|
||||
}
|
||||
if isDefault {
|
||||
defer root.Close()
|
||||
file, err := openRootAppendRegular(root, filepath.Base(logPath))
|
||||
if err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: open runtime log: %w", err)
|
||||
}
|
||||
return file, logPath, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(logPath)
|
||||
if err := os.MkdirAll(parent, 0o700); err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: create custom log directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(logPath)
|
||||
if err == nil && !info.Mode().IsRegular() {
|
||||
return nil, logPath, fmt.Errorf("daemon: custom runtime log is not a regular file")
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, logPath, fmt.Errorf("daemon: inspect custom runtime log: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: open custom runtime log: %w", err)
|
||||
}
|
||||
return file, logPath, nil
|
||||
}
|
||||
|
||||
func openDefaultRuntimeRoot(paths Paths) (*os.Root, bool, error) {
|
||||
defaults, err := DefaultPaths()
|
||||
if err != nil {
|
||||
// A caller-supplied layout remains usable even when the process has no
|
||||
// resolvable home directory. Production default paths can only reach this
|
||||
// helper after DefaultPaths has already succeeded.
|
||||
return nil, false, nil
|
||||
}
|
||||
matches, err := samePaths(paths, defaults)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !matches {
|
||||
return nil, false, nil
|
||||
}
|
||||
root, err := privatedir.Open(filepath.Dir(defaults.Socket))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("daemon: secure default runtime directory: %w", err)
|
||||
}
|
||||
return root, true, nil
|
||||
}
|
||||
|
||||
func samePaths(left, right Paths) (bool, error) {
|
||||
pairs := [][2]string{
|
||||
{left.Socket, right.Socket},
|
||||
{left.Lock, right.Lock},
|
||||
{left.Status, right.Status},
|
||||
}
|
||||
for _, pair := range pairs {
|
||||
leftAbs, err := filepath.Abs(pair[0])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("daemon: resolve runtime path: %w", err)
|
||||
}
|
||||
rightAbs, err := filepath.Abs(pair[1])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("daemon: resolve default runtime path: %w", err)
|
||||
}
|
||||
if filepath.Clean(leftAbs) != filepath.Clean(rightAbs) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func runtimeRootStillNamesPath(root *os.Root, path string) error {
|
||||
opened, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect bound runtime directory: %w", err)
|
||||
}
|
||||
current, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect runtime directory entry: %w", err)
|
||||
}
|
||||
if current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
|
||||
return fmt.Errorf("daemon: default runtime directory changed after it was secured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureBoundSocketRoot(path string) (*os.Root, string, error) {
|
||||
root, err := os.OpenRoot(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open bound socket directory: %w", err)
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
info, err := root.Lstat(name)
|
||||
if err != nil {
|
||||
_ = root.Close()
|
||||
return nil, "", fmt.Errorf("inspect bound socket: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
_ = root.Close()
|
||||
return nil, "", fmt.Errorf("bound socket path is not a socket")
|
||||
}
|
||||
return root, name, nil
|
||||
}
|
||||
|
||||
func openRootAppendRegular(root *os.Root, name string) (*os.File, error) {
|
||||
info, err := root.Lstat(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("runtime log is not a regular file")
|
||||
}
|
||||
file, err := root.OpenFile(name, os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !opened.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("runtime log changed while opening")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// checkSocketPathLength rejects an over-long unix socket path before bind.
|
||||
|
||||
@@ -10,3 +10,7 @@ import "os"
|
||||
func hardenSocketFile(path string) error {
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
|
||||
func hardenSocketFileRoot(root *os.Root, name string) error {
|
||||
return root.Chmod(name, 0o600)
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
|
||||
// mode bits, and the daemon places the socket under the per-user profile
|
||||
// directory, which is already ACL-restricted to the owner. A dedicated restricted
|
||||
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
|
||||
func hardenSocketFile(string) error { return nil }
|
||||
|
||||
func hardenSocketFileRoot(*os.Root, string) error { return nil }
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("status directory ownership metadata is unavailable")
|
||||
}
|
||||
if int(stat.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("status directory is owned by uid %d, not the current user", stat.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func secureStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsMissingMetadata(t *testing.T) {
|
||||
err := checkStatusDirOwner(nil, statusDirOwnerTestInfo{})
|
||||
if err == nil || !strings.Contains(err.Error(), "metadata is unavailable") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want unavailable metadata rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsDifferentUser(t *testing.T) {
|
||||
info := statusDirOwnerTestInfo{sys: &syscall.Stat_t{Uid: uint32(os.Geteuid() + 1)}}
|
||||
err := checkStatusDirOwner(nil, info)
|
||||
if err == nil || !strings.Contains(err.Error(), "not the current user") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want owner mismatch rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
type statusDirOwnerTestInfo struct {
|
||||
sys any
|
||||
}
|
||||
|
||||
func (statusDirOwnerTestInfo) Name() string { return "." }
|
||||
func (statusDirOwnerTestInfo) Size() int64 { return 0 }
|
||||
func (statusDirOwnerTestInfo) Mode() os.FileMode { return os.ModeDir | 0o700 }
|
||||
func (statusDirOwnerTestInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (statusDirOwnerTestInfo) IsDir() bool { return true }
|
||||
func (info statusDirOwnerTestInfo) Sys() any { return info.sys }
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const statusDirectoryWriteAccess = windows.ACCESS_MASK(
|
||||
windows.GENERIC_ALL |
|
||||
windows.GENERIC_WRITE |
|
||||
windows.DELETE |
|
||||
windows.WRITE_DAC |
|
||||
windows.WRITE_OWNER |
|
||||
windows.FILE_WRITE_DATA |
|
||||
windows.FILE_APPEND_DATA |
|
||||
windows.FILE_WRITE_ATTRIBUTES |
|
||||
windows.FILE_WRITE_EA |
|
||||
0x40, // FILE_DELETE_CHILD
|
||||
)
|
||||
|
||||
// checkStatusDirOwner validates ownership and write access through a handle
|
||||
// opened beneath root. Path-based ACL inspection would recreate the ancestor
|
||||
// swap race that Root is intended to close.
|
||||
func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open status directory for access validation: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := directory.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close status directory access handle: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
raw, err := directory.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("access status directory handle: %w", err)
|
||||
}
|
||||
var descriptor *windows.SECURITY_DESCRIPTOR
|
||||
var queryErr error
|
||||
if err := raw.Control(func(handle uintptr) {
|
||||
descriptor, queryErr = windows.GetSecurityInfo(
|
||||
windows.Handle(handle),
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("inspect status directory access: %w", err)
|
||||
}
|
||||
if queryErr != nil {
|
||||
return fmt.Errorf("inspect status directory owner and DACL: %w", queryErr)
|
||||
}
|
||||
if descriptor == nil {
|
||||
return fmt.Errorf("status directory security descriptor is unavailable")
|
||||
}
|
||||
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current Windows user: %w", err)
|
||||
}
|
||||
tokenOwner, err := currentWindowsTokenOwner(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current Windows token owner: %w", err)
|
||||
}
|
||||
owner, _, err := descriptor.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read status directory owner: %w", err)
|
||||
}
|
||||
if owner == nil || (!owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner)) {
|
||||
return fmt.Errorf("status directory is not owned by the current Windows token")
|
||||
}
|
||||
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read status directory DACL: %w", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return fmt.Errorf("status directory has an unrestricted Windows DACL")
|
||||
}
|
||||
for index := uint16(0); index < dacl.AceCount; index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, uint32(index), &ace); err != nil {
|
||||
return fmt.Errorf("read status directory DACL entry %d: %w", index, err)
|
||||
}
|
||||
switch ace.Header.AceType {
|
||||
case windows.ACCESS_DENIED_ACE_TYPE:
|
||||
continue
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE:
|
||||
default:
|
||||
return fmt.Errorf("status directory DACL entry %d has unsupported type %d", index, ace.Header.AceType)
|
||||
}
|
||||
if ace.Mask&statusDirectoryWriteAccess == 0 {
|
||||
continue
|
||||
}
|
||||
trustee := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
if !allowedStatusDirectoryTrustee(trustee, user.User.Sid) {
|
||||
return fmt.Errorf("status directory DACL grants write access to unexpected trustee %s", trustee.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type statusDirectoryTokenOwner struct {
|
||||
owner *windows.SID
|
||||
}
|
||||
|
||||
func currentWindowsTokenOwner(token windows.Token) (*windows.SID, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
|
||||
if err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, err
|
||||
}
|
||||
buffer := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner := (*statusDirectoryTokenOwner)(unsafe.Pointer(&buffer[0])).owner
|
||||
if owner == nil {
|
||||
return nil, errors.New("windows access token has no default owner")
|
||||
}
|
||||
return owner.Copy()
|
||||
}
|
||||
|
||||
func allowedStatusDirectoryTrustee(trustee, user *windows.SID) bool {
|
||||
return trustee != nil && (trustee.Equals(user) ||
|
||||
trustee.IsWellKnown(windows.WinLocalSystemSid) ||
|
||||
trustee.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
|
||||
trustee.IsWellKnown(windows.WinCreatorOwnerSid) ||
|
||||
trustee.IsWellKnown(windows.WinCreatorOwnerRightsSid))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func secureStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
descriptor, err := windows.SecurityDescriptorFromString(
|
||||
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
dir,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
dacl,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pinner runtime.Pinner
|
||||
pinner.Pin(worldSID)
|
||||
defer pinner.Unpin()
|
||||
broadDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(worldSID),
|
||||
},
|
||||
}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
dir,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
broadDACL,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDirPlatform(t, dir)
|
||||
broadenStatusTestDirPlatform(t, dir)
|
||||
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
info, err := root.Stat(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = checkStatusDirOwner(root, info)
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected trustee") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want broad DACL rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
)
|
||||
|
||||
const (
|
||||
statusTempPrefix = ".daemon-status-"
|
||||
statusTempPattern = statusTempPrefix + "*"
|
||||
)
|
||||
|
||||
func openStatusRoot(path string) (*os.Root, error) {
|
||||
root, err := os.OpenRoot(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open status directory: %w", err)
|
||||
}
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
_ = root.Close()
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// writeStatusFileAtomicallyRoot publishes through a caller-owned, already
|
||||
// bound directory root. The returned boolean crosses the commit boundary: once
|
||||
// true, err is a durability/cleanup warning and the complete document is live.
|
||||
func writeStatusFileAtomicallyRoot(
|
||||
root *os.Root,
|
||||
statusName string,
|
||||
data []byte,
|
||||
perm os.FileMode,
|
||||
beforeReplace func(),
|
||||
replace func(root *os.Root, src, dst string) error,
|
||||
syncParent func(root *os.Root) error,
|
||||
) (committed bool, returnErr error) {
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
temp, tempName, err := createStatusTemp(root, perm)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
if err := temp.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close temporary status file during cleanup: %w", err))
|
||||
}
|
||||
closed = true
|
||||
}
|
||||
if err := root.Remove(tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary status file: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
return false, fmt.Errorf("set temporary status file permissions: %w", err)
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
return false, fmt.Errorf("write temporary status file: %w", err)
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
return false, fmt.Errorf("sync temporary status file: %w", err)
|
||||
}
|
||||
closeErr := temp.Close()
|
||||
closed = true
|
||||
if closeErr != nil {
|
||||
return false, fmt.Errorf("close temporary status file: %w", closeErr)
|
||||
}
|
||||
if beforeReplace != nil {
|
||||
beforeReplace()
|
||||
}
|
||||
if err := prepareStatusReplacement(root, tempName, statusName); err != nil {
|
||||
return false, fmt.Errorf("prepare status replacement: %w", err)
|
||||
}
|
||||
|
||||
rename := root.Rename
|
||||
if replace != nil {
|
||||
rename = func(src, dst string) error { return replace(root, src, dst) }
|
||||
}
|
||||
// On Windows an existing reader that omitted FILE_SHARE_DELETE can block
|
||||
// this replacement even though an in-place write would succeed. Keep the
|
||||
// atomic publication boundary: RenameWithRetry bounds transient sharing
|
||||
// failures to ten short attempts, and a persistent failure leaves the old
|
||||
// status document intact instead of exposing a partially written one.
|
||||
var committedWarning error
|
||||
if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil {
|
||||
var committedReplacement *fsutil.CommittedReplacementCleanupError
|
||||
if !errors.As(err, &committedReplacement) {
|
||||
return false, fmt.Errorf("replace status file: %w", err)
|
||||
}
|
||||
committedWarning = fmt.Errorf("clean up replaced status file: %w", err)
|
||||
}
|
||||
committed = true
|
||||
if syncParent == nil {
|
||||
syncParent = syncStatusRoot
|
||||
}
|
||||
if err := syncParent(root); err != nil {
|
||||
committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err))
|
||||
}
|
||||
if committedWarning != nil {
|
||||
return committed, committedWarning
|
||||
}
|
||||
return committed, nil
|
||||
}
|
||||
|
||||
func validateStatusRoot(root *os.Root) error {
|
||||
info, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect status directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("status directory is not a directory")
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0o022 != 0 {
|
||||
return fmt.Errorf("status directory permissions are %04o, want no group/other write access", info.Mode().Perm())
|
||||
}
|
||||
if err := checkStatusDirOwner(root, info); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createStatusTemp(root *os.Root, perm os.FileMode) (*os.File, string, error) {
|
||||
for range 100 {
|
||||
var suffix [16]byte
|
||||
if _, err := rand.Read(suffix[:]); err != nil {
|
||||
return nil, "", fmt.Errorf("generate temporary status file name: %w", err)
|
||||
}
|
||||
name := statusTempPrefix + hex.EncodeToString(suffix[:])
|
||||
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
|
||||
if err == nil {
|
||||
return file, name, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary status file: %w", err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary status file: exhausted unique names")
|
||||
}
|
||||
|
||||
// syncStatusRoot makes the replacement directory entry durable through the
|
||||
// same bound root used for creation and replacement. Windows directory sync is
|
||||
// best-effort because Go cannot fsync a directory there.
|
||||
func syncStatusRoot(root *os.Root) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
syncErr := directory.Sync()
|
||||
closeErr := directory.Close()
|
||||
if syncErr != nil {
|
||||
return syncErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
"github.com/Gitlawb/zero/internal/privatedir"
|
||||
)
|
||||
|
||||
func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
previous := []byte(`{"pid":7,"socket":"old.sock","version":1,"startedAt":"2026-08-01T00:00:00Z"}`)
|
||||
if err := os.WriteFile(path, previous, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
replaceErr := errors.New("injected replacement failure")
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Status: path},
|
||||
Version: 2,
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
staged, err := root.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read staged status: %v", err)
|
||||
}
|
||||
var decoded StatusFile
|
||||
if err := json.Unmarshal(staged, &decoded); err != nil {
|
||||
t.Fatalf("staged status is incomplete JSON: %v", err)
|
||||
}
|
||||
current, err := root.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("read previous status at commit boundary: %v", err)
|
||||
}
|
||||
if string(current) != string(previous) {
|
||||
t.Fatalf("previous status changed before commit: %q", current)
|
||||
}
|
||||
return replaceErr
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := server.writeStatusFile()
|
||||
if !errors.Is(err, replaceErr) {
|
||||
t.Fatalf("writeStatusFile error = %v, want injected replacement failure", err)
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read preserved status: %v", err)
|
||||
}
|
||||
if string(current) != string(previous) {
|
||||
t.Fatalf("status after failed publication = %q, want previous document", current)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFilePublishesCompleteRestrictedDocument(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
startedAt := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
parentSynced := false
|
||||
server := &Server{
|
||||
startedAt: startedAt,
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 3,
|
||||
syncStatusParent: func(root *os.Root) error {
|
||||
if _, err := root.Stat("."); err != nil {
|
||||
t.Fatalf("stat bound status parent: %v", err)
|
||||
}
|
||||
parentSynced = true
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status StatusFile
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
t.Fatalf("published status is invalid JSON: %v", err)
|
||||
}
|
||||
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 3 || !status.StartedAt.Equal(startedAt) {
|
||||
t.Fatalf("published status = %+v", status)
|
||||
}
|
||||
if !parentSynced {
|
||||
t.Fatal("status parent directory was not synced")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("status mode = %04o, want 0600", got)
|
||||
}
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileReaderSeesCompleteDocumentsDuringPublication(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
previous := StatusFile{
|
||||
PID: 7,
|
||||
Socket: filepath.Join(dir, "old.sock"),
|
||||
Version: 1,
|
||||
StartedAt: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
previousData, err := json.Marshal(previous)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, previousData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
replacementReady := make(chan struct{})
|
||||
publish := make(chan struct{})
|
||||
writeDone := make(chan error, 1)
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "new.sock"), Status: path},
|
||||
Version: 2,
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
close(replacementReady)
|
||||
<-publish
|
||||
return fsutil.RenameWithRetry(src, dst, root.Rename)
|
||||
},
|
||||
},
|
||||
}
|
||||
go func() {
|
||||
writeDone <- server.writeStatusFile()
|
||||
}()
|
||||
|
||||
<-replacementReady
|
||||
for range 100 {
|
||||
status := readStatusDocument(t, path)
|
||||
if status != previous {
|
||||
t.Fatalf("status before commit = %+v, want previous document %+v", status, previous)
|
||||
}
|
||||
}
|
||||
close(publish)
|
||||
if err := <-writeDone; err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
|
||||
status := readStatusDocument(t, path)
|
||||
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 2 || !status.StartedAt.Equal(server.startedAt) {
|
||||
t.Fatalf("status after commit = %+v", status)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterDirectorySyncWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
syncErr := errors.New("injected directory sync failure")
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 4,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
syncStatusParent: func(*os.Root) error { return syncErr },
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 4 {
|
||||
t.Fatalf("published status = %+v, want version 4", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], syncErr.Error()) {
|
||||
t.Fatalf("logs = %q, want directory sync warning", logs)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterCommittedReplacementWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cleanupErr := errors.New("injected backup cleanup failure")
|
||||
parentSynced := false
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 5,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
|
||||
return err
|
||||
}
|
||||
return &fsutil.CommittedReplacementCleanupError{
|
||||
BackupPath: filepath.Join(dir, ".zero-replace-backup"),
|
||||
Cause: cleanupErr,
|
||||
}
|
||||
},
|
||||
syncStatusParent: func(*os.Root) error {
|
||||
parentSynced = true
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
if !parentSynced {
|
||||
t.Fatal("status directory was not synced after committed replacement warning")
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 5 {
|
||||
t.Fatalf("published status = %+v, want version 5", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], cleanupErr.Error()) {
|
||||
t.Fatalf("logs = %q, want committed cleanup warning", logs)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterCommittedTempCleanupWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var tempName string
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 6,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
|
||||
return err
|
||||
}
|
||||
tempName = src
|
||||
if err := root.Mkdir(src, 0o700); err != nil {
|
||||
t.Fatalf("recreate staged path as directory: %v", err)
|
||||
}
|
||||
keep, err := root.OpenFile(filepath.Join(src, "keep"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("make staged directory non-empty: %v", err)
|
||||
}
|
||||
if err := keep.Close(); err != nil {
|
||||
t.Fatalf("close staged directory marker: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("published status = %+v, want version 6", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], "remove temporary status file") {
|
||||
t.Fatalf("logs = %q, want temporary cleanup warning", logs)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, tempName, "keep")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, tempName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
|
||||
var swapErr error
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 6,
|
||||
beforeStatusReplace: func() {
|
||||
swapErr = os.Rename(dir, movedDir)
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
t.Fatalf("move bound status directory: %v", swapErr)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute status directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, substitute, 0o600); err != nil {
|
||||
t.Fatalf("write substitute status: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatalf("unexpected directory-swap error: %v", swapErr)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("status after blocked swap = %+v, want version 6", status)
|
||||
}
|
||||
if _, err := os.Lstat(movedDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("moved directory exists after blocked Windows swap: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
return
|
||||
}
|
||||
if got, err := os.ReadFile(path); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != string(substitute) {
|
||||
t.Fatalf("substitute destination changed: %q", got)
|
||||
}
|
||||
status := readStatusDocument(t, filepath.Join(movedDir, "daemon.status"))
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("bound status = %+v, want version 6", status)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
assertNoStatusTemps(t, movedDir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileAllowsReadOnlyCustomStatusDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Status: path},
|
||||
Version: 6,
|
||||
},
|
||||
}
|
||||
err := server.writeStatusFile()
|
||||
if err != nil {
|
||||
t.Fatalf("writeStatusFile in 0755 directory: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
t.Fatalf("status file not created in read-only custom directory: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileRejectsWritableStatusDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{Paths: Paths{Status: path}, Version: 6},
|
||||
}
|
||||
err := server.writeStatusFile()
|
||||
if err == nil || !strings.Contains(err.Error(), "no group/other write access") {
|
||||
t.Fatalf("writeStatusFile error = %v, want writable-directory rejection", err)
|
||||
}
|
||||
if _, err := os.Lstat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("status file created in writable directory: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestPrivateDirHardensBroadCurrentUserStatusDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
broadenStatusTestDirPlatform(t, dir)
|
||||
if err := privatedir.Ensure(dir); err != nil {
|
||||
t.Fatalf("privatedir.Ensure: %v", err)
|
||||
}
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
t.Fatalf("validate hardened status root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readStatusDocument(t *testing.T, path string) StatusFile {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
var status StatusFile
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
t.Fatalf("status is incomplete JSON: %v (content %q)", err, data)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func assertNoStatusTemps(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(dir, statusTempPattern))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary status files remain: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func secureStatusTestDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
secureStatusTestDirPlatform(t, dir)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestWriteStatusFilePreservesExistingProtectedDACL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
|
||||
if err != nil {
|
||||
t.Skipf("cannot build restrictive DACL: %v", err)
|
||||
}
|
||||
dacl, _, err := restricted.DACL()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read restrictive DACL: %v", err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil, nil, dacl, nil); err != nil {
|
||||
t.Skipf("cannot apply restrictive DACL: %v", err)
|
||||
}
|
||||
want := statusDACL(t, path)
|
||||
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 2,
|
||||
},
|
||||
}
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
if got := statusDACL(t, path); got != want {
|
||||
t.Fatalf("status DACL after replacement = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func statusDACL(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Skipf("cannot read status DACL: %v", err)
|
||||
}
|
||||
text := descriptor.String()
|
||||
if index := strings.Index(text, "D:"); index >= 0 {
|
||||
return text[index:]
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
func prepareStatusReplacement(*os.Root, string, string) error { return nil }
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// prepareStatusReplacement copies the existing status document's DACL to the
|
||||
// complete staging file before Root.Rename replaces it. Both files are opened
|
||||
// relative to the already-bound Root directory handle, so preserving Windows
|
||||
// security metadata does not reintroduce a pathname/ancestor-swap boundary.
|
||||
func prepareStatusReplacement(root *os.Root, src, dst string) (returnErr error) {
|
||||
if filepath.Base(src) != src || filepath.Base(dst) != dst {
|
||||
return fmt.Errorf("status replacement names must be root-relative base names")
|
||||
}
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open bound status directory for DACL preservation: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := directory.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close bound status directory for DACL preservation: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
raw, err := directory.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("access bound status directory handle: %w", err)
|
||||
}
|
||||
var destination, source windows.Handle
|
||||
var openErr error
|
||||
if err := raw.Control(func(rootHandle uintptr) {
|
||||
destination, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), dst, windows.READ_CONTROL|windows.FILE_READ_ATTRIBUTES)
|
||||
if isMissingStatusObject(openErr) {
|
||||
openErr = nil // no destination descriptor exists to preserve
|
||||
return
|
||||
}
|
||||
if openErr != nil {
|
||||
return
|
||||
}
|
||||
source, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), src, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_READ_ATTRIBUTES)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("open status files through bound directory: %w", err)
|
||||
}
|
||||
if destination != 0 {
|
||||
defer windows.CloseHandle(destination)
|
||||
}
|
||||
if source != 0 {
|
||||
defer windows.CloseHandle(source)
|
||||
}
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("open status files for DACL preservation: %w", openErr)
|
||||
}
|
||||
if destination == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
descriptor, err := windows.GetSecurityInfo(destination, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing status DACL: %w", err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode existing status DACL: %w", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return fmt.Errorf("existing status file has an unrestricted Windows DACL")
|
||||
}
|
||||
control, _, err := descriptor.Control()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing status DACL control: %w", err)
|
||||
}
|
||||
securityInfo := windows.SECURITY_INFORMATION(windows.DACL_SECURITY_INFORMATION)
|
||||
if control&windows.SE_DACL_PROTECTED != 0 {
|
||||
securityInfo |= windows.SECURITY_INFORMATION(windows.PROTECTED_DACL_SECURITY_INFORMATION)
|
||||
} else {
|
||||
securityInfo |= windows.SECURITY_INFORMATION(windows.UNPROTECTED_DACL_SECURITY_INFORMATION)
|
||||
}
|
||||
if err := windows.SetSecurityInfo(source, windows.SE_FILE_OBJECT, securityInfo, nil, nil, dacl, nil); err != nil {
|
||||
return fmt.Errorf("preserve existing status DACL on replacement: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openStatusSecurityHandle(root windows.Handle, name string, access uint32) (windows.Handle, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
attributes := &windows.OBJECT_ATTRIBUTES{
|
||||
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
|
||||
RootDirectory: root,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
|
||||
}
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
access,
|
||||
attributes,
|
||||
&status,
|
||||
nil,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("refusing status DACL operation on reparse point %q", name)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func isMissingStatusObject(err error) bool {
|
||||
return errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) ||
|
||||
errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,28 @@ func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
|
||||
}
|
||||
return acquireOpenedFileLock(file)
|
||||
}
|
||||
|
||||
// TryAcquireFileLockRoot attempts to lock name relative to an already-bound
|
||||
// directory root. It is intended for lifecycle owners that must not resolve the
|
||||
// root pathname again between validation and lock acquisition.
|
||||
func TryAcquireFileLockRoot(root *os.Root, name, displayPath string) (*FileLock, error) {
|
||||
if root == nil {
|
||||
return nil, errors.New("lockutil: lock root is nil")
|
||||
}
|
||||
name = filepath.Clean(name)
|
||||
if name == "." || name == ".." || filepath.IsAbs(name) || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
|
||||
return nil, fmt.Errorf("lockutil: lock name %q escapes root", name)
|
||||
}
|
||||
file, err := openLockFileRoot(root, name, displayPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lockutil: open rooted lock file: %w", err)
|
||||
}
|
||||
return acquireOpenedFileLock(file)
|
||||
}
|
||||
|
||||
func acquireOpenedFileLock(file *os.File) (*FileLock, error) {
|
||||
state, contended, err := tryLockFile(file)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
@@ -82,6 +104,41 @@ func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func openLockFileRoot(root *os.Root, name, displayPath string) (*os.File, error) {
|
||||
info, err := root.Lstat(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
file, createErr := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
if err := validateRootLockFile(file); err != nil {
|
||||
return nil, errors.Join(err, file.Close())
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, errors.New("refusing non-regular rooted lock file")
|
||||
}
|
||||
file, err := root.OpenFile(name, os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, errors.Join(err, file.Close())
|
||||
}
|
||||
if !os.SameFile(info, opened) {
|
||||
return nil, errors.Join(errors.New("rooted lock file changed while opening"), file.Close())
|
||||
}
|
||||
if err := validateRootLockFile(file); err != nil {
|
||||
return nil, errors.Join(err, file.Close())
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// WriteMetadata replaces the diagnostic contents of the lock file while the
|
||||
// caller holds it. Metadata does not establish ownership; the kernel lock does.
|
||||
func (lock *FileLock) WriteMetadata(data []byte) error {
|
||||
|
||||
@@ -76,6 +76,20 @@ func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr e
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func validateRootLockFile(file *os.File) error {
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(int(file.Fd()), &stat); err != nil {
|
||||
return fmt.Errorf("inspect rooted lock file: %w", err)
|
||||
}
|
||||
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return errors.New("refusing non-regular rooted lock file")
|
||||
}
|
||||
if stat.Nlink != 1 {
|
||||
return errors.New("refusing multiply-linked rooted lock file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryLockFile(file *os.File) (platformLockState, bool, error) {
|
||||
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
|
||||
if err == nil {
|
||||
|
||||
@@ -73,6 +73,10 @@ func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr e
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func validateRootLockFile(file *os.File) error {
|
||||
return validateWindowsLockHandle(windows.Handle(file.Fd()), false)
|
||||
}
|
||||
|
||||
func openLockDirectoryAt(parent windows.Handle, name string) (windows.Handle, error) {
|
||||
handle, err := openLockPathAt(
|
||||
parent,
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,50 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/privatedir"
|
||||
)
|
||||
|
||||
// crashExitCode is returned when a top-level panic is recovered.
|
||||
const crashExitCode = 1
|
||||
|
||||
const crashTempPrefix = ".crash-report-"
|
||||
|
||||
// ErrCrashReportCommitted marks an error that happened after a complete crash
|
||||
// report was published. Callers may still use the returned path and should
|
||||
// surface the error as a cleanup warning rather than as a failed publication.
|
||||
var ErrCrashReportCommitted = errors.New("crash report publication committed")
|
||||
|
||||
type crashReportCommittedError struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *crashReportCommittedError) Error() string {
|
||||
return fmt.Sprintf("%v with warning: %v", ErrCrashReportCommitted, err.cause)
|
||||
}
|
||||
|
||||
func (err *crashReportCommittedError) Unwrap() []error {
|
||||
return []error{ErrCrashReportCommitted, err.cause}
|
||||
}
|
||||
|
||||
type crashReportHooks struct {
|
||||
beforePublish func()
|
||||
write func(*os.File, []byte) (int, error)
|
||||
link func(*os.Root, string, string) error
|
||||
renameNoReplace func(*os.Root, string, string) error
|
||||
remove func(*os.Root, string) error
|
||||
}
|
||||
|
||||
// FormatCrashReport renders a human-readable crash report.
|
||||
func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time) string {
|
||||
return fmt.Sprintf("zero crash report\ntime: %s\nlabel: %s\npanic: %v\n\nstack:\n%s\n",
|
||||
@@ -26,16 +59,189 @@ func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time)
|
||||
|
||||
// WriteCrashReport writes a crash report file into dir and returns its path.
|
||||
func WriteCrashReport(dir, label string, recovered any, stack []byte, ts time.Time) (string, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return writeCrashReport(dir, label, recovered, stack, ts, crashReportHooks{})
|
||||
}
|
||||
|
||||
func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Time, hooks crashReportHooks) (path string, returnErr error) {
|
||||
absoluteDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve crash report directory: %w", err)
|
||||
}
|
||||
root, err := openCrashDirectory(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, "crash-"+ts.UTC().Format("20060102-150405")+".log")
|
||||
if err := os.WriteFile(path, []byte(FormatCrashReport(label, recovered, stack, ts)), 0o600); err != nil {
|
||||
committed := false
|
||||
defer func() {
|
||||
if err := root.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close crash report directory: %w", err))
|
||||
}
|
||||
if committed && returnErr != nil && !errors.Is(returnErr, ErrCrashReportCommitted) {
|
||||
returnErr = &crashReportCommittedError{cause: returnErr}
|
||||
}
|
||||
if !committed && returnErr != nil {
|
||||
path = ""
|
||||
}
|
||||
}()
|
||||
|
||||
name := "crash-" + ts.UTC().Format("20060102-150405") + ".log"
|
||||
path = filepath.Join(dir, name)
|
||||
report, tempName, err := createCrashTemp(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
if tempName == "" {
|
||||
return
|
||||
}
|
||||
if err := removeCrashTemp(hooks, root, tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary crash report: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
data := []byte(FormatCrashReport(label, recovered, stack, ts))
|
||||
writeReport := hooks.write
|
||||
if writeReport == nil {
|
||||
writeReport = func(file *os.File, data []byte) (int, error) { return file.Write(data) }
|
||||
}
|
||||
written, err := writeReport(report, data)
|
||||
if err == nil && written != len(data) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
closeErr := report.Close()
|
||||
if closeErr != nil {
|
||||
return "", errors.Join(fmt.Errorf("write crash report: %w", err), fmt.Errorf("close crash report: %w", closeErr))
|
||||
}
|
||||
return "", fmt.Errorf("write crash report: %w", err)
|
||||
}
|
||||
if err := report.Sync(); err != nil {
|
||||
closeErr := report.Close()
|
||||
if closeErr != nil {
|
||||
return "", errors.Join(fmt.Errorf("sync crash report: %w", err), fmt.Errorf("close crash report: %w", closeErr))
|
||||
}
|
||||
return "", fmt.Errorf("sync crash report: %w", err)
|
||||
}
|
||||
if err := report.Close(); err != nil {
|
||||
return "", fmt.Errorf("close crash report: %w", err)
|
||||
}
|
||||
if hooks.beforePublish != nil {
|
||||
hooks.beforePublish()
|
||||
}
|
||||
publishName := name
|
||||
link := root.Link
|
||||
if hooks.link != nil {
|
||||
link = func(oldname, newname string) error { return hooks.link(root, oldname, newname) }
|
||||
}
|
||||
if linkErr := link(tempName, name); linkErr != nil {
|
||||
publishName, err = publishCrashFallback(root, name, tempName, hooks.renameNoReplace)
|
||||
if err != nil {
|
||||
return "", errors.Join(fmt.Errorf("publish crash report with hard link: %w", linkErr), err)
|
||||
}
|
||||
committed = true
|
||||
tempName = ""
|
||||
} else {
|
||||
committed = true
|
||||
}
|
||||
path = filepath.Join(dir, publishName)
|
||||
if !crashPathUsesRoot(root, absoluteDir) {
|
||||
path = ""
|
||||
}
|
||||
if tempName != "" {
|
||||
if err := removeCrashTemp(hooks, root, tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
tempName = ""
|
||||
return path, fmt.Errorf("remove temporary crash report: %w", err)
|
||||
}
|
||||
tempName = ""
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// publishCrashFallback atomically renames the complete staging file to an
|
||||
// unpredictable unused name. The no-replace operation is the collision check;
|
||||
// there is no check-then-rename window in which another report can be replaced.
|
||||
func publishCrashFallback(root *os.Root, timestampName, tempName string, rename func(*os.Root, string, string) error) (string, error) {
|
||||
if rename == nil {
|
||||
rename = renameNoReplace
|
||||
}
|
||||
base := strings.TrimSuffix(timestampName, filepath.Ext(timestampName))
|
||||
if suffix := strings.TrimPrefix(tempName, crashTempPrefix); suffix != "" && suffix != tempName {
|
||||
candidate := base + "-" + suffix + filepath.Ext(timestampName)
|
||||
if err := rename(root, tempName, candidate); err == nil {
|
||||
return candidate, nil
|
||||
} else if !errors.Is(err, os.ErrExist) {
|
||||
return "", fmt.Errorf("publish fallback crash report: %w", err)
|
||||
}
|
||||
}
|
||||
for range 100 {
|
||||
var suffix [16]byte
|
||||
if _, err := rand.Read(suffix[:]); err != nil {
|
||||
return "", fmt.Errorf("generate fallback crash report name: %w", err)
|
||||
}
|
||||
candidate := base + "-" + hex.EncodeToString(suffix[:]) + filepath.Ext(timestampName)
|
||||
if err := rename(root, tempName, candidate); err == nil {
|
||||
return candidate, nil
|
||||
} else if !errors.Is(err, os.ErrExist) {
|
||||
return "", fmt.Errorf("publish fallback crash report: %w", err)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("generate fallback crash report name: exhausted unique names")
|
||||
}
|
||||
|
||||
func removeCrashTemp(hooks crashReportHooks, root *os.Root, name string) error {
|
||||
if hooks.remove != nil {
|
||||
return hooks.remove(root, name)
|
||||
}
|
||||
return root.Remove(name)
|
||||
}
|
||||
|
||||
func createCrashTemp(root *os.Root) (*os.File, string, error) {
|
||||
for range 100 {
|
||||
var suffix [16]byte
|
||||
if _, err := rand.Read(suffix[:]); err != nil {
|
||||
return nil, "", fmt.Errorf("generate temporary crash report name: %w", err)
|
||||
}
|
||||
name := crashTempPrefix + hex.EncodeToString(suffix[:])
|
||||
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err == nil {
|
||||
return file, name, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary crash report: %w", err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary crash report: exhausted unique names")
|
||||
}
|
||||
|
||||
func crashPathUsesRoot(root *os.Root, dir string) bool {
|
||||
bound, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
current, err := os.Stat(filepath.Clean(dir))
|
||||
return err == nil && os.SameFile(bound, current)
|
||||
}
|
||||
|
||||
func openCrashDirectory(dir string) (*os.Root, error) {
|
||||
clean := filepath.Clean(dir)
|
||||
defaultDir := filepath.Clean(DefaultCrashDir())
|
||||
parent := filepath.Dir(clean)
|
||||
// The default layout shares ~/.zero with the daemon runtime fallback. Keep
|
||||
// both the shared parent and the crash-report child private. Custom crash
|
||||
// destinations are hardened at the caller-supplied boundary only.
|
||||
if clean == defaultDir && filepath.Base(clean) == "crashes" && filepath.Base(parent) == ".zero" {
|
||||
if err := privatedir.Ensure(parent); err != nil {
|
||||
return nil, fmt.Errorf("secure crash report parent: %w", err)
|
||||
}
|
||||
}
|
||||
root, err := privatedir.Open(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secure crash report directory: %w", err)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// DefaultCrashDir is where crash reports are written by default.
|
||||
func DefaultCrashDir() string {
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
@@ -54,8 +260,22 @@ func Recover(dir, label string, stderr io.Writer, code *int) {
|
||||
return
|
||||
}
|
||||
stack := debug.Stack()
|
||||
if path, err := WriteCrashReport(dir, label, recovered, stack, time.Now()); err == nil {
|
||||
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
|
||||
reportRecoveredCrash(stderr, code, recovered, stack, func() (string, error) {
|
||||
return WriteCrashReport(dir, label, recovered, stack, time.Now())
|
||||
})
|
||||
}
|
||||
|
||||
func reportRecoveredCrash(stderr io.Writer, code *int, recovered any, stack []byte, write func() (string, error)) {
|
||||
path, err := write()
|
||||
if err == nil || errors.Is(err, ErrCrashReportCommitted) {
|
||||
if path != "" {
|
||||
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved, but its current path could not be determined\n", recovered)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Warning: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "zero crashed: %v\n%s\n", recovered, stack)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package observability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -27,6 +30,400 @@ func TestWriteAndFormatCrashReport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportKeepsTwoReportsFromTheSameSecond(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC)
|
||||
first, err := WriteCrashReport(dir, "cli", "first panic", []byte("first stack"), ts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := WriteCrashReport(dir, "cli", "second panic", []byte("second stack"), ts)
|
||||
if err != nil {
|
||||
t.Fatalf("second report in the same second: %v", err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatalf("same-second reports shared path %q", first)
|
||||
}
|
||||
firstData, err := os.ReadFile(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondData, err := os.ReadFile(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(firstData), "first panic") || strings.Contains(string(firstData), "second panic") {
|
||||
t.Fatalf("first report was replaced: %q", firstData)
|
||||
}
|
||||
if !strings.Contains(string(secondData), "second panic") {
|
||||
t.Fatalf("second report was not persisted: %q", secondData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportCreatesPrivateDefaultDirectories(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
dir := DefaultCrashDir()
|
||||
if dir != filepath.Join(home, ".zero", "crashes") {
|
||||
t.Fatalf("DefaultCrashDir = %q, want path beneath temporary home", dir)
|
||||
}
|
||||
if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), time.Now()); err != nil {
|
||||
t.Fatalf("WriteCrashReport: %v", err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got&0o077 != 0 {
|
||||
t.Fatalf("directory %s permissions = %04o, want owner-only", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportHardensPreexistingDefaultDirectories(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows DACL migration is covered by the daemon integration test")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
dir := DefaultCrashDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), time.Now()); err != nil {
|
||||
t.Fatalf("WriteCrashReport: %v", err)
|
||||
}
|
||||
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got&0o077 != 0 {
|
||||
t.Fatalf("directory %s permissions = %04o after migration, want owner-only", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportBindsDirectoryDuringSwap(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
var swapErr error
|
||||
path, err := writeCrashReport(dir, "cli", "secret panic", []byte("secret stack"), ts, crashReportHooks{
|
||||
beforePublish: func() {
|
||||
swapErr = os.Rename(dir, movedDir)
|
||||
if swapErr != nil {
|
||||
return
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute crash directory: %v", err)
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeCrashReport: %v", err)
|
||||
}
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatalf("swap crash directory: %v", swapErr)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("report missing after Windows blocked directory swap: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
t.Fatalf("writeCrashReport returned stale path %q after directory swap", path)
|
||||
}
|
||||
reportName := "crash-" + ts.UTC().Format("20060102-150405") + ".log"
|
||||
data, err := os.ReadFile(filepath.Join(movedDir, reportName))
|
||||
if err != nil {
|
||||
t.Fatalf("read report through originally bound directory: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "secret panic") {
|
||||
t.Fatalf("report in bound directory missing panic: %q", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, reportName)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("substitute directory received crash report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportPublishesOnlyCompleteContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
finalPath := filepath.Join(dir, "crash-20260824-120000.log")
|
||||
staged := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
result := make(chan crashWriteResult, 1)
|
||||
go func() {
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
beforePublish: func() {
|
||||
close(staged)
|
||||
<-release
|
||||
},
|
||||
})
|
||||
result <- crashWriteResult{path: path, err: err}
|
||||
}()
|
||||
select {
|
||||
case <-staged:
|
||||
case written := <-result:
|
||||
t.Fatalf("writeCrashReport returned before publication hook: %v", written.err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("writeCrashReport did not reach publication hook")
|
||||
}
|
||||
defer func() {
|
||||
select {
|
||||
case <-release:
|
||||
default:
|
||||
close(release)
|
||||
}
|
||||
}()
|
||||
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final report visible before complete publication: %v", err)
|
||||
}
|
||||
close(release)
|
||||
written := <-result
|
||||
if written.err != nil {
|
||||
t.Fatalf("writeCrashReport: %v", written.err)
|
||||
}
|
||||
data, err := os.ReadFile(written.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "complete stack") {
|
||||
t.Fatalf("published report is incomplete: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportRemovesTempAfterWriteFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
injected := errors.New("injected write failure")
|
||||
_, err := writeCrashReport(dir, "cli", "boom", []byte("stack"), time.Now(), crashReportHooks{
|
||||
write: func(*os.File, []byte) (int, error) { return 0, injected },
|
||||
})
|
||||
if !errors.Is(err, injected) {
|
||||
t.Fatalf("writeCrashReport error = %v, want injected failure", err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("crash directory contains partial files after failure: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportDoesNotOverwriteExistingReport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
path := filepath.Join(dir, "crash-20260824-120000.log")
|
||||
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), ts)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteCrashReport with existing timestamp: %v", err)
|
||||
}
|
||||
if published == path {
|
||||
t.Fatal("new report reused the occupied timestamp path")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "existing" {
|
||||
t.Fatalf("existing report overwritten: %q", data)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("crash directory should contain existing and uniquely published reports: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportPreservesPathAfterCommittedCleanupWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
injected := errors.New("injected cleanup failure")
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
remove: func(*os.Root, string) error { return injected },
|
||||
})
|
||||
if !errors.Is(err, ErrCrashReportCommitted) || !errors.Is(err, injected) {
|
||||
t.Fatalf("writeCrashReport error = %v, want committed cleanup warning", err)
|
||||
}
|
||||
wantPath := filepath.Join(dir, "crash-20260824-120000.log")
|
||||
if path != wantPath {
|
||||
t.Fatalf("writeCrashReport path = %q, want %q", path, wantPath)
|
||||
}
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read committed crash report: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(data), "complete stack") {
|
||||
t.Fatalf("committed crash report is incomplete: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportDoesNotReturnStalePathWithCleanupWarning(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
injected := errors.New("injected cleanup failure")
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
var swapErr error
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
beforePublish: func() {
|
||||
swapErr = os.Rename(dir, movedDir)
|
||||
if swapErr == nil {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute crash directory: %v", err)
|
||||
}
|
||||
}
|
||||
},
|
||||
remove: func(*os.Root, string) error { return injected },
|
||||
})
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("Windows kept the open crash directory from being renamed: %v", swapErr)
|
||||
}
|
||||
t.Fatalf("swap crash directory: %v", swapErr)
|
||||
}
|
||||
if !errors.Is(err, ErrCrashReportCommitted) || !errors.Is(err, injected) {
|
||||
t.Fatalf("writeCrashReport error = %v, want committed cleanup warning", err)
|
||||
}
|
||||
if path != "" {
|
||||
t.Fatalf("writeCrashReport returned stale path %q after directory swap", path)
|
||||
}
|
||||
report := filepath.Join(movedDir, "crash-20260824-120000.log")
|
||||
data, readErr := os.ReadFile(report)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read committed report through moved directory: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(data), "complete stack") {
|
||||
t.Fatalf("committed report is incomplete: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportFallsBackWhenHardLinksAreUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
unsupported := errors.New("hard links unavailable")
|
||||
var collisionName string
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
link: func(root *os.Root, oldname, _ string) error {
|
||||
suffix := strings.TrimPrefix(oldname, crashTempPrefix)
|
||||
collisionName = "crash-20260824-120000-" + suffix + ".log"
|
||||
if err := root.WriteFile(collisionName, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatalf("create fallback-name collision: %v", err)
|
||||
}
|
||||
return unsupported
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeCrashReport fallback: %v", err)
|
||||
}
|
||||
if path == filepath.Join(dir, collisionName) {
|
||||
t.Fatalf("fallback replaced the existing report %q", collisionName)
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Base(path), "crash-20260824-120000-") {
|
||||
t.Fatalf("fallback path = %q, want timestamp and random suffix", path)
|
||||
}
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read fallback crash report: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(data), "complete stack") {
|
||||
t.Fatalf("fallback crash report is incomplete: %q", data)
|
||||
}
|
||||
existing, readErr := os.ReadFile(filepath.Join(dir, collisionName))
|
||||
if readErr != nil {
|
||||
t.Fatalf("read existing collision: %v", readErr)
|
||||
}
|
||||
if string(existing) != "existing" {
|
||||
t.Fatalf("existing fallback collision was overwritten: %q", existing)
|
||||
}
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("crash directory contains partial files after fallback: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportFallbackDoesNotReplaceCommitTimeCollision(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
unsupported := errors.New("hard links unavailable")
|
||||
var collisionName string
|
||||
attempts := 0
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
link: func(*os.Root, string, string) error { return unsupported },
|
||||
renameNoReplace: func(root *os.Root, oldname, newname string) error {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
collisionName = newname
|
||||
if err := root.WriteFile(newname, []byte("concurrent report"), 0o600); err != nil {
|
||||
t.Fatalf("create commit-time collision: %v", err)
|
||||
}
|
||||
}
|
||||
return renameNoReplace(root, oldname, newname)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeCrashReport fallback: %v", err)
|
||||
}
|
||||
if attempts < 2 {
|
||||
t.Fatalf("fallback rename attempts = %d, want collision retry", attempts)
|
||||
}
|
||||
if filepath.Base(path) == collisionName {
|
||||
t.Fatalf("fallback replaced the concurrent report %q", collisionName)
|
||||
}
|
||||
existing, err := os.ReadFile(filepath.Join(dir, collisionName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(existing) != "concurrent report" {
|
||||
t.Fatalf("concurrent report overwritten: %q", existing)
|
||||
}
|
||||
published, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(published), "complete stack") {
|
||||
t.Fatalf("published fallback is incomplete: %q", published)
|
||||
}
|
||||
}
|
||||
|
||||
type crashWriteResult struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
func TestRecoverCapturesPanic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var stderr bytes.Buffer
|
||||
@@ -63,3 +460,25 @@ func TestRecoverNoPanicIsNoop(t *testing.T) {
|
||||
t.Fatalf("unexpected output without a panic: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportRecoveredCrashReportsCommittedCleanupWarning(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
code := 0
|
||||
injected := errors.New("injected cleanup failure")
|
||||
reportRecoveredCrash(&stderr, &code, "kaboom", []byte("secret stack"), func() (string, error) {
|
||||
return "/tmp/crash.log", &crashReportCommittedError{cause: injected}
|
||||
})
|
||||
|
||||
if code != crashExitCode {
|
||||
t.Fatalf("exit code = %d, want %d", code, crashExitCode)
|
||||
}
|
||||
output := stderr.String()
|
||||
for _, want := range []string{"kaboom", "saved to /tmp/crash.log", "Warning:", injected.Error()} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("crash notice missing %q: %q", want, output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "secret stack") {
|
||||
t.Fatalf("committed report warning fell back to inline stack: %q", output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build darwin
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func renameNoReplace(root *os.Root, oldname, newname string) error {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open crash directory for publication: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
return unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL)
|
||||
}
|
||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user