mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f98c66d8 | ||
|
|
c3d2fce6eb | ||
|
|
0bb14894ec | ||
|
|
4c91718d7e | ||
|
|
c1937dfac7 | ||
|
|
90014210d2 | ||
|
|
6937a309cf | ||
|
|
da244bcbfc | ||
|
|
e34c8d2252 | ||
|
|
91d921f4c5 | ||
|
|
6ae8396f02 | ||
|
|
2837f5efb3 | ||
|
|
08fbea7a32 | ||
|
|
82ae3a8809 | ||
|
|
488def68dc | ||
|
|
b04fe96e9e | ||
|
|
79fa1422ef | ||
|
|
1aacf38b72 | ||
|
|
3063593129 | ||
|
|
d6b25fc303 | ||
|
|
7c50a9fb98 | ||
|
|
275e248669 | ||
|
|
0207f2b269 | ||
|
|
8b3d1b1597 | ||
|
|
a3852bac59 | ||
|
|
a3e7446681 | ||
|
|
0021475d4c | ||
|
|
deba3adfca | ||
|
|
501f32160f | ||
|
|
1e4db7c664 | ||
|
|
f9092668a8 | ||
|
|
7f5e5af7bb |
No files matched your search
@@ -29,6 +29,9 @@ reviews:
|
||||
- path: "tests/**/*.ts"
|
||||
instructions: |
|
||||
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
|
||||
- path: "**/*.go"
|
||||
instructions: |
|
||||
Do not request Plan 9 or other unsupported GOOS/GOARCH shims. The five native prebuilt release targets are linux-x64, linux-arm64, macos-arm64, macos-x64, and windows-x64. Android/Termux is also a supported runtime through the documented android-arm64 source build and Linux artifact fallback. Other GOOS/GOARCH combinations are not a review criterion.
|
||||
tools:
|
||||
github-checks:
|
||||
timeout_ms: 300000
|
||||
|
||||
+153
-75
@@ -6,10 +6,109 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Code Quality & Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Check formatting
|
||||
run: make fmt-check
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: make deadcode
|
||||
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: make lint-static
|
||||
|
||||
test:
|
||||
name: Unit Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Test with coverage
|
||||
run: |
|
||||
go test -count=1 -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: Publish coverage summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f coverage.out ]; then
|
||||
go tool cover -func=coverage.out | awk '
|
||||
BEGIN {
|
||||
total = "unknown"
|
||||
rows = ""
|
||||
}
|
||||
$1 == "total:" {
|
||||
total = $3
|
||||
next
|
||||
}
|
||||
{
|
||||
rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3)
|
||||
}
|
||||
END {
|
||||
print "### 📊 Test Coverage Summary: **" total "**"
|
||||
print "<details><summary>Coverage per function</summary>\n"
|
||||
print "| File | Function | Coverage |"
|
||||
print "| :--- | :--- | :--- |"
|
||||
printf "%s", rows
|
||||
print "</details>"
|
||||
}' >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: zero-coverage-report
|
||||
path: coverage.out
|
||||
if-no-files-found: warn
|
||||
|
||||
smoke:
|
||||
name: Smoke (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -18,64 +117,22 @@ jobs:
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install Make on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: choco install make --no-progress -y
|
||||
|
||||
- name: Validate quality targets with stock macOS Make
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 1
|
||||
GIT_CONFIG_KEY_0: grep.patternType
|
||||
GIT_CONFIG_VALUE_0: fixed
|
||||
run: make -n vulncheck
|
||||
|
||||
- name: Check vulnerabilities through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: cmd
|
||||
run: make vulncheck
|
||||
|
||||
- name: Check dead code through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make deadcode
|
||||
|
||||
- name: Run static lint through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make lint-static
|
||||
|
||||
- name: Check formatting
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
unformatted="$(gofmt -l .)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:" >&2
|
||||
echo "$unformatted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
- name: Test (Fast Non-Race)
|
||||
run: go test ./...
|
||||
|
||||
- name: Build binary
|
||||
@@ -84,9 +141,15 @@ jobs:
|
||||
- name: Smoke binary
|
||||
run: go run ./cmd/zero-release smoke
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
race:
|
||||
name: Race Detector
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# A race the detector newly reports can present as a deadlock rather than a
|
||||
# failure, and the default job timeout is six hours. This job is expected to
|
||||
# be the longest in the workflow, so the ceiling is generous but finite.
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -100,55 +163,70 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# Invoke the Makefile target rather than repeating its flags. `make test`
|
||||
# is already `go test ./... -race -count=1`, so running it here is what
|
||||
# stops the project's declared test command and what CI actually runs
|
||||
# from drifting apart again. The race detector needs cgo, which the
|
||||
# hosted ubuntu runner supplies without an extra setup step.
|
||||
- name: Test with the race detector
|
||||
run: make test
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build binary
|
||||
run: go run ./cmd/zero-release build
|
||||
|
||||
- name: Performance smoke
|
||||
run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||
run: |
|
||||
mkdir -p dist/perf
|
||||
go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||
|
||||
- name: Upload performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: zero-performance-smoke
|
||||
path: dist/perf/perf-bench.json
|
||||
if-no-files-found: warn
|
||||
|
||||
security:
|
||||
name: Security & code health
|
||||
name: Security & Code Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
||||
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
||||
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
||||
- name: govulncheck
|
||||
- name: govulncheck (linux)
|
||||
run: make vulncheck
|
||||
|
||||
# Advisory: reports functions unreachable from any cmd/* main so dormant
|
||||
# code is visible in CI. Non-blocking while the dormant subsystems are
|
||||
# still being wired or removed.
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: make deadcode
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
# package but never actually called, plus staticcheck-style correctness
|
||||
# and readability issues. Scoped to a few linters rather than the full
|
||||
# default battery, and non-blocking, while the existing findings across
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: make lint-static
|
||||
- name: govulncheck (windows)
|
||||
run: make vulncheck-windows
|
||||
@@ -14,6 +14,9 @@ on:
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate action.yml
|
||||
|
||||
@@ -81,6 +81,13 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
|
||||
paths on Windows). Prefer one cross-platform function with small `GOOS`
|
||||
checks over duplicated helpers that drift.
|
||||
- **Supported targets only:** CONTRIBUTING.md names the five native prebuilt
|
||||
release artifacts (`linux-x64`, `linux-arm64`, `macos-arm64`, `macos-x64`,
|
||||
`windows-x64`) and the additional supported Android/Termux source-build and
|
||||
artifact-fallback path. Other `GOOS`/`GOARCH` combinations, including
|
||||
`plan9`, are unsupported and are not a review criterion. Do not block a PR
|
||||
for failing to compile on an unsupported `GOOS`/`GOARCH`. Tests may use
|
||||
`plan9` as a fixture for "unsupported".
|
||||
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
|
||||
success and error paths (including stderr). Fail closed on ownership, lease,
|
||||
and permission checks. Do not rely on pre-open path resolution
|
||||
|
||||
@@ -126,6 +126,31 @@ not enough. Dependency changes need a clear project benefit, such as fixing a
|
||||
specific bug, addressing a security issue, improving compatibility, or supporting
|
||||
an approved feature.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
Zero publishes five native prebuilt artifacts from
|
||||
`.github/workflows/release-artifacts.yml`:
|
||||
|
||||
- `linux-x64` (`linux/amd64`)
|
||||
- `linux-arm64` (`linux/arm64`)
|
||||
- `macos-arm64` (`darwin/arm64`)
|
||||
- `macos-x64` (`darwin/amd64`)
|
||||
- `windows-x64` (`windows/amd64`)
|
||||
|
||||
The shell installer and npm wrapper resolve the applicable artifacts from that
|
||||
list. The PowerShell installer supports the published `windows-x64` artifact;
|
||||
there is no native Windows ARM64 artifact. Windows on ARM users can run the x64
|
||||
build under emulation or build from source.
|
||||
|
||||
Android/Termux is also supported even though it has no distinct release
|
||||
artifact. The npm wrapper maps Android to the Linux artifact, and
|
||||
`docs/INSTALL.md` documents the native `android/arm64` source build needed on
|
||||
devices whose seccomp policy blocks the Linux build's syscall path.
|
||||
|
||||
Other `GOOS`/`GOARCH` values, including Plan 9, are unsupported. They are not a
|
||||
review criterion. Do not block a pull request for failing to compile on an
|
||||
unsupported target. Tests may use `plan9` as a fixture for an unsupported OS.
|
||||
|
||||
## What We Prioritize
|
||||
|
||||
The core team will prioritize:
|
||||
|
||||
@@ -7,7 +7,7 @@ DEADCODE_VERSION := v0.46.0
|
||||
GOLANGCI_LINT_VERSION := v2.12.2
|
||||
GOVULNCHECK_VERSION := v1.3.0
|
||||
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
@@ -44,7 +44,8 @@ lint: fmt-check vet
|
||||
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
|
||||
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
|
||||
# The target-specific export is shell-independent.
|
||||
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
lint-static deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off
|
||||
|
||||
lint-static:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||
@@ -55,6 +56,11 @@ deadcode:
|
||||
vulncheck:
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
vulncheck-windows:
|
||||
mkdir -p "$(CURDIR)/.cache/gobin"
|
||||
GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
|
||||
GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
@@ -77,4 +83,4 @@ baseline: build
|
||||
--output internal/perfbench/reports/baseline.json
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline"
|
||||
@@ -26,7 +26,7 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
## Why Zero
|
||||
|
||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
|
||||
OpenAI-/Anthropic-compatible endpoint.
|
||||
- **Stay in control.** File writes, shell commands, network access, and
|
||||
out-of-workspace writes go through Zero's permission and sandbox policy.
|
||||
@@ -182,8 +182,12 @@ zero providers add custom-openai-compatible \
|
||||
--set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
`zero providers detect`.
|
||||
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
|
||||
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
|
||||
load a model and enable its local OpenAI-compatible API (default
|
||||
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
|
||||
loaded model ID in the add command. If no usable ID is discovered, load a model
|
||||
and retry. Model IDs requiring shell-specific quoting use interactive setup.
|
||||
|
||||
## Daily Use
|
||||
|
||||
@@ -273,6 +277,19 @@ zero sandbox grants list
|
||||
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
||||
and MCP support for additional tools.
|
||||
|
||||
`web_fetch` refuses loopback, private and other special-use addresses: it checks
|
||||
the URL before asking permission, resolves the host, and dials the address it
|
||||
validated so a name cannot resolve to something else in between.
|
||||
|
||||
If `HTTP_PROXY`/`HTTPS_PROXY` is set, `web_fetch` and the provider connectivity
|
||||
probe use it, and that last step changes: the proxy is dialed and the target
|
||||
hostname is sent to it, so the proxy decides which address the request actually
|
||||
reaches. The URL is still checked and resolved locally first, but a proxy that
|
||||
answers differently can reach a private service. A forward proxy already sees
|
||||
and can rewrite every request through it, so this is the trust you accept by
|
||||
configuring one. Leave the variables unset for the checks to be enforced end to
|
||||
end.
|
||||
|
||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
||||
so the normal sandbox and permission policy applies. Long-running commands stay
|
||||
attached to a background terminal session and can be listed or stopped from the
|
||||
|
||||
+6
-2
@@ -23,7 +23,7 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||
@@ -114,7 +114,11 @@ export LONGCAT_API_KEY=...
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
|
||||
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。
|
||||
|
||||
## 日常使用
|
||||
|
||||
|
||||
@@ -302,8 +302,9 @@ Conceptually, one requested tool goes through these phases:
|
||||
the model can inspect it and decide what to do next.
|
||||
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
|
||||
writer, or ACP client. TUI and exec record rich tool/session events for
|
||||
resume/history; ACP currently persists conversational messages and streams
|
||||
tool/permission updates to the client.
|
||||
resume/history; ACP persists conversational messages and completed tool-call
|
||||
starts/results for load replay. Live streaming details and permission
|
||||
exchanges remain client notifications rather than durable history.
|
||||
|
||||
Tool success and failure are both informative context. A successful `read_file`
|
||||
result gives the model file contents; a failed `edit_file` result gives the model
|
||||
@@ -627,7 +628,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=
|
||||
+599
-61
@@ -2,10 +2,14 @@ package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -14,6 +18,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,15 +35,42 @@ 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)
|
||||
Store *sessions.Store
|
||||
AgentInfo Implementation
|
||||
// PersistEvents is an optional test seam for injecting event-store failures.
|
||||
// It receives a COMPLETED TURN as one batch, never one event at a time: the
|
||||
// batch is the unit the shared store keeps contiguous (see persistTurn).
|
||||
PersistEvents func(sessionID string, inputs []sessions.AppendEventInput) error
|
||||
Store *sessions.Store
|
||||
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).
|
||||
@@ -82,6 +114,8 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
|
||||
conn.Handle(MethodInitialize, a.handleInitialize)
|
||||
conn.Handle(MethodSessionNew, a.handleSessionNew)
|
||||
conn.Handle(MethodSessionLoad, a.handleSessionLoad)
|
||||
conn.Handle(MethodSessionList, a.handleSessionList)
|
||||
conn.Handle(MethodSessionResume, a.handleSessionResume)
|
||||
conn.Handle(MethodSessionPrompt, a.handleSessionPrompt)
|
||||
conn.Handle(MethodSessionSetMode, a.handleSetMode)
|
||||
conn.Handle(MethodSessionSetConfigOption, a.handleSetConfigOption)
|
||||
@@ -110,11 +144,11 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
||||
return InitializeResult{
|
||||
ProtocolVersion: negotiated,
|
||||
AgentCapabilities: AgentCapabilities{
|
||||
// Only advertise what ZERO actually implements: session/load (loadSession)
|
||||
// and image prompts. session/resume + the session-capability sub-object
|
||||
// are intentionally omitted since there is no resume handler yet.
|
||||
LoadSession: true,
|
||||
PromptCapabilities: PromptCapabilities{Image: true},
|
||||
// ACP v1 optional methods are advertised as empty capability objects;
|
||||
// clients must gate session/list and session/resume on their presence.
|
||||
LoadSession: true,
|
||||
PromptCapabilities: PromptCapabilities{Image: true},
|
||||
SessionCapabilities: &SessionCapabilities{List: &struct{}{}, Resume: &struct{}{}},
|
||||
},
|
||||
AgentInfo: &info,
|
||||
// ZERO owns credentials (BYOK) and does not delegate auth to the editor.
|
||||
@@ -124,12 +158,41 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
||||
|
||||
// ---- session lifecycle ----
|
||||
|
||||
// requestedWorkspace is the single rule for a cwd that arrived from the client,
|
||||
// applied before the value can reach ResolveWorkspaceRoot.
|
||||
//
|
||||
// THE REQUEST'S OWN CWD IS THE ONLY THING ALLOWED TO PICK A ROOT. Every params
|
||||
// type spells cwd as a plain string, so an absent field and an empty one decode
|
||||
// to exactly the same "", and the resolver treats "" as "use the directory this
|
||||
// process was started in" and joins a relative cwd onto it. Those two defaults
|
||||
// meet in the middle: `{"sessionId":"known"}` used to activate a persisted
|
||||
// session, and `{}` used to create one, both rooted at wherever the editor
|
||||
// happened to spawn `zero acp` — which then becomes the sandbox and file-tool
|
||||
// confinement root. The client never named that directory, so it is a root the
|
||||
// server invented, and ACP requires an absolute cwd on every method carrying
|
||||
// one. Rejecting here is what keeps a missing field from being answered with a
|
||||
// guess.
|
||||
func requestedWorkspace(cwd string) (string, error) {
|
||||
trimmed := strings.TrimSpace(cwd)
|
||||
if trimmed == "" {
|
||||
return "", RPCError(codeInvalidParams, "cwd is required and must be an absolute path")
|
||||
}
|
||||
if !filepath.IsAbs(trimmed) {
|
||||
return "", RPCError(codeInvalidParams, "cwd must be an absolute path: "+trimmed)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p NewSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(p.Cwd)
|
||||
cwd, err := requestedWorkspace(p.Cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(cwd)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
@@ -141,7 +204,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
@@ -154,22 +217,90 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
||||
}
|
||||
return a.activatePersistedSession(ctx, p, persistedSessionLoad)
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionResume(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p ResumeSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/resume params")
|
||||
}
|
||||
return a.activatePersistedSession(ctx, p, persistedSessionResume)
|
||||
}
|
||||
|
||||
type persistedSessionOperation uint8
|
||||
|
||||
const (
|
||||
persistedSessionLoad persistedSessionOperation = iota
|
||||
persistedSessionResume
|
||||
)
|
||||
|
||||
// activatePersistedSession restores the agent's internal conversation context
|
||||
// for both lifecycle methods. session/load additionally replays user-visible
|
||||
// history as ordered session/update notifications; session/resume deliberately
|
||||
// does not, which makes it safe for an already-rendered desktop reconnect.
|
||||
func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParams, operation persistedSessionOperation) (any, error) {
|
||||
// BOTH METHODS, BEFORE ANYTHING IS LOOKED UP. session/resume shares this
|
||||
// params type with session/load, so the wire cannot tell an omitted cwd from
|
||||
// an empty one; the blank case used to be answered with the persisted cwd,
|
||||
// which made {"sessionId":"known"} a complete, successful activation request.
|
||||
// Validating here rather than in one handler is what keeps the two entry
|
||||
// points from drifting apart.
|
||||
cwd, err := requestedWorkspace(p.Cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, err := a.deps.Store.Get(p.SessionID)
|
||||
if err != nil || meta == nil {
|
||||
return nil, RPCError(codeInvalidParams, "session not found: "+p.SessionID)
|
||||
}
|
||||
cwdInput := p.Cwd
|
||||
if strings.TrimSpace(cwdInput) == "" {
|
||||
cwdInput = meta.Cwd
|
||||
if operation == persistedSessionResume && !sessions.IsResumableKind(meta.SessionKind) {
|
||||
return nil, RPCError(codeInvalidParams, "session is not resumable: "+p.SessionID)
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(cwdInput)
|
||||
if strings.TrimSpace(meta.Cwd) == "" {
|
||||
return nil, RPCError(codeInvalidParams, "session has no persisted workspace: "+p.SessionID)
|
||||
}
|
||||
// SAME RULE ON THE WAY IN. Omitting a relative entry from the listing is not
|
||||
// enough: a stored "." still resolves against this process's directory, so a
|
||||
// client that hands back that same directory as its cwd would be sold the
|
||||
// invented root as a match. A workspace that cannot be identified is not one
|
||||
// this session can be restored into.
|
||||
if !filepath.IsAbs(meta.Cwd) {
|
||||
return nil, RPCError(codeInvalidParams, "session workspace is not an absolute path, so it cannot be identified: "+p.SessionID)
|
||||
}
|
||||
persistedRoot, err := a.deps.ResolveWorkspaceRoot(meta.Cwd)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "persisted session workspace is unavailable: "+err.Error())
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(cwd)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
// ACP session cwd is immutable. Loading history under a different root
|
||||
// would give a conversation from one workspace access to another
|
||||
// workspace's configuration, files, and tools.
|
||||
if !sameWorkspace(root, persistedRoot) {
|
||||
return nil, RPCError(codeInvalidParams, "session cwd does not match its persisted workspace")
|
||||
}
|
||||
// Load history BEFORE publishing the session so no concurrent prompt observes
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
requireHistoryLog := operation == persistedSessionResume && meta.EventCount > 0
|
||||
history, messages, historyWarning, historyErr := a.loadHistory(meta.SessionID, requireHistoryLog)
|
||||
// RESUME PROMISES RESTORED CONTEXT, SO A FAILED RESTORATION IS A FAILED
|
||||
// RESUME. historyErr used to do nothing but suppress replay and raise a
|
||||
// warning: the session was registered and reported ready regardless, so a
|
||||
// corrupt record or an unreadable events file left the caller holding a live,
|
||||
// promptable session ID whose next prompt ran as a brand-new conversation
|
||||
// under the old identity. Nothing in the response said so.
|
||||
//
|
||||
// Publication is now gated on restoration for resume. LOAD keeps the
|
||||
// best-effort policy deliberately rather than by inheriting this helper: it
|
||||
// replays what it has and warns about the rest, which is the right trade for
|
||||
// a client rebuilding a transcript it can still scroll. Reported by @jatmn.
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
|
||||
}
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
@@ -180,12 +311,36 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
|
||||
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||
}
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
if existed {
|
||||
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
|
||||
}
|
||||
}
|
||||
note := ¬ifier{conn: a.conn, sessionID: sess.id}
|
||||
if operation == persistedSessionLoad && historyErr == nil {
|
||||
for _, message := range messages {
|
||||
if message.tool != nil {
|
||||
note.send(*message.tool)
|
||||
continue
|
||||
}
|
||||
note.send(replayMessageChunk(message.role, replayMessageID(message.eventID), message.content))
|
||||
}
|
||||
}
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
note,
|
||||
"rehydrate session compaction",
|
||||
"Could not apply session compaction. Raw session history was restored instead.",
|
||||
historyWarning,
|
||||
operation == persistedSessionLoad,
|
||||
)
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"load session history",
|
||||
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
|
||||
historyErr,
|
||||
operation == persistedSessionLoad,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
@@ -193,6 +348,80 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionList(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p ListSessionsParams
|
||||
if len(params) > 0 {
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/list params")
|
||||
}
|
||||
}
|
||||
if p.Cursor != "" {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/list cursor")
|
||||
}
|
||||
items, err := a.deps.Store.ListResumable()
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "list sessions: "+err.Error())
|
||||
}
|
||||
// The filter is the one cwd ACP leaves optional, so a blank one means "no
|
||||
// filter" rather than an error — but a relative one is rebased onto this
|
||||
// process's directory exactly like the others, and would then silently answer
|
||||
// about a workspace the client never named.
|
||||
var cwd string
|
||||
if strings.TrimSpace(p.Cwd) != "" {
|
||||
filter, err := requestedWorkspace(p.Cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cwd, err = a.deps.ResolveWorkspaceRoot(filter)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
}
|
||||
result := ListSessionsResult{Sessions: make([]SessionInfo, 0, len(items))}
|
||||
for _, item := range items {
|
||||
// EVERY ENTRY IS RESOLVED, FILTER OR NO FILTER. Listing is a menu, and
|
||||
// every item on it has to be orderable: activatePersistedSession
|
||||
// resolves the persisted workspace and refuses what it cannot reach, so
|
||||
// anything this loop cannot resolve is something the client would be
|
||||
// offered and then denied.
|
||||
//
|
||||
// Resolving only when a cwd filter was supplied left two shapes through —
|
||||
// a session whose workspace has since been deleted, and a legacy entry
|
||||
// holding a relative path, which was then reported as cwd "." even though
|
||||
// ACP requires SessionInfo.cwd to be absolute.
|
||||
if strings.TrimSpace(item.Cwd) == "" {
|
||||
continue
|
||||
}
|
||||
// A RELATIVE PERSISTED CWD HAS NO RECOVERABLE IDENTITY. Resolving one
|
||||
// rebases it onto wherever this ACP server happens to be running and
|
||||
// advertises that invented absolute path as the session's workspace — so a
|
||||
// conversation created for one project could be resumed against another
|
||||
// project's configuration, files and tools. The original base is not
|
||||
// knowable from the metadata, so the honest answer is to omit the entry
|
||||
// rather than to guess at it.
|
||||
if !filepath.IsAbs(item.Cwd) {
|
||||
continue
|
||||
}
|
||||
itemRoot, err := a.deps.ResolveWorkspaceRoot(item.Cwd)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cwd != "" && !sameWorkspace(itemRoot, cwd) {
|
||||
continue
|
||||
}
|
||||
result.Sessions = append(result.Sessions, SessionInfo{
|
||||
SessionID: item.SessionID,
|
||||
Title: item.Title,
|
||||
// The RESOLVED root, not the stored string: absolute as ACP
|
||||
// requires, and the same value the client will hand back on resume.
|
||||
Cwd: itemRoot,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
Meta: &SessionInfoMeta{ModelID: item.ModelID, CreatedAt: item.CreatedAt},
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -241,13 +470,43 @@ 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")
|
||||
}
|
||||
}
|
||||
// Buffer the complete turn until RunAgent has a valid ACP outcome. A hard run
|
||||
// failure is an aborted turn: neither live history nor durable history may
|
||||
// retain its user/tool prefix under an RPC error. StopCancelled is a valid
|
||||
// outcome and commits through the same boundary below.
|
||||
pendingEvents := make([]sessions.AppendEventInput, 0, 4)
|
||||
queue := func(input sessions.AppendEventInput) {
|
||||
pendingEvents = append(pendingEvents, input)
|
||||
}
|
||||
queue(messageEvent("user", userText))
|
||||
|
||||
opts := agent.Options{
|
||||
Cwd: sess.cwd,
|
||||
@@ -256,13 +515,18 @@ 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,
|
||||
OnReasoning: note.thought,
|
||||
OnToolCall: note.toolCall,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
queue(toolCallEvent(call))
|
||||
note.toolCall(call)
|
||||
},
|
||||
OnToolResult: func(result agent.ToolResult) {
|
||||
queue(toolResultEvent(result))
|
||||
note.toolResult(result)
|
||||
if result.Name == "update_plan" {
|
||||
a.emitPlan(registry, note)
|
||||
@@ -275,19 +539,36 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), userText)
|
||||
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
|
||||
|
||||
if result.FinalAnswer != "" {
|
||||
queue(messageEvent("assistant", result.FinalAnswer))
|
||||
}
|
||||
reason, stopErr := stopReasonFor(result, runErr)
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
err,
|
||||
)
|
||||
}
|
||||
// ONE BATCH, ONE LOCK. The outcome gate above decides WHETHER the turn is
|
||||
// committed; the store batch decides WHICH RECORDS STAY TOGETHER relative to
|
||||
// other writers, and these are different guarantees. Persisting the buffer
|
||||
// one event per call took and released the store's session lock between
|
||||
// events, so a second ACP instance holding the same session — its turnMu is
|
||||
// its own, only the store is shared — could append its whole turn in the
|
||||
// gap: user A, user B, answer B, answer A. Every write was individually
|
||||
// locked and the log was valid, but a fresh load pairs prose by order and
|
||||
// reconstructed three wrong turns from two right ones. Before this branch,
|
||||
// persistTurn handed the user and assistant events to AppendEvents together;
|
||||
// buffering until the outcome is known was correct, splitting the commit
|
||||
// into singleton writes was not. The store writes a batch under one lock and
|
||||
// one file write, which is the contiguity restored here — not a disk
|
||||
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
|
||||
persistenceErr := a.persistTurn(sess.id, pendingEvents)
|
||||
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
persistenceErr,
|
||||
true,
|
||||
)
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
@@ -546,39 +827,159 @@ func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
// persistTurn commits a completed turn's buffered events as one store batch, so
|
||||
// no other writer's records can land between them. A failure is the failure of
|
||||
// the whole batch: nothing after a failed prerequisite is appended on its own.
|
||||
func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput) error {
|
||||
if len(inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if a.deps.PersistEvents != nil {
|
||||
return a.deps.PersistEvents(sessionID, inputs)
|
||||
}
|
||||
if a.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
events := []sessions.AppendEventInput{
|
||||
{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
},
|
||||
}
|
||||
if assistant != "" {
|
||||
events = append(events, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
_, err := a.deps.Store.AppendEvents(sess.id, events)
|
||||
_, err := a.deps.Store.AppendEvents(sessionID, inputs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil
|
||||
func messageEvent(role, content string) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": role, "content": content},
|
||||
}
|
||||
events, err := a.deps.Store.ReadEvents(sessionID)
|
||||
}
|
||||
|
||||
func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventToolCall,
|
||||
Payload: map[string]any{
|
||||
"toolCallId": call.ID,
|
||||
"name": call.Name,
|
||||
"arguments": call.Arguments,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func toolResultEvent(result agent.ToolResult) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventToolResult,
|
||||
Payload: map[string]any{
|
||||
"toolCallId": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": result.Status,
|
||||
"output": result.Output,
|
||||
"changedFiles": append([]string(nil), result.ChangedFiles...),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// persistedMessage is one entry of the restored transcript in stored order. It
|
||||
// is either a message (role/content) or a tool-call notification (tool), never
|
||||
// both: keeping them in ONE ordered slice is what preserves the interleaving a
|
||||
// client needs to redraw the conversation as it originally happened.
|
||||
type persistedMessage struct {
|
||||
eventID string
|
||||
role string
|
||||
content string
|
||||
// tool is set for a replayed tool call or its result, and is already in wire
|
||||
// shape — it comes from the same toolCallStart/toolCallResult mapping the
|
||||
// live turn uses, so a replayed tool call renders identically to a fresh one
|
||||
// and there is no second mapping to drift out of sync.
|
||||
tool *ToolCallUpdate
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRecord, []persistedMessage, error, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
// THE EFFECTIVE CONVERSATION, NOT THE RAW LOG. A compacted session keeps its
|
||||
// original prefix on disk alongside an EventCompaction that names the events
|
||||
// it replaced and carries their durable summary. ReadEvents hands back both,
|
||||
// so restoring from it replayed superseded turns AND dropped the summary that
|
||||
// replaced them — resume then seeded the next turn with context the
|
||||
// conversation had already moved past, and load visibly resurrected
|
||||
// transcript entries the user had seen compacted away.
|
||||
//
|
||||
// ReadRehydratedEvents is the projection the TUI and exec paths already use
|
||||
// (internal/tui/session.go, internal/sessions/exec_session.go), so all three
|
||||
// now agree on what the conversation IS. Switching readers alone is not
|
||||
// enough: rehydration substitutes the compaction event in place of the events
|
||||
// it replaced, so a loop that skips everything but EventMessage would drop the
|
||||
// summary exactly as before. It is projected below. Reported by @jatmn.
|
||||
events, eventLogPresent, err := a.deps.Store.ReadRehydratedEventsWithPresence(sessionID)
|
||||
var rehydrateWarning error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
rehydrateWarning = err
|
||||
events, eventLogPresent, err = a.deps.Store.ReadEventsWithPresence(sessionID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
if requireHistoryLog && !eventLogPresent {
|
||||
return nil, nil, nil, fmt.Errorf("zero session %s event log is missing", sessionID)
|
||||
}
|
||||
var records []turnRecord
|
||||
var messages []persistedMessage
|
||||
activeToolCalls := make(map[string]string)
|
||||
var pendingUser string
|
||||
havePending := false
|
||||
for _, e := range events {
|
||||
if e.Type == sessions.EventCompaction {
|
||||
// The summary stands in for the turns it replaced, so it enters the
|
||||
// history as an assistant message: it is prior context the next turn
|
||||
// must see, and the wire has no separate role for it.
|
||||
raw, marshalErr := json.Marshal(e.Payload)
|
||||
if marshalErr != nil {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.Summary) == "" {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, persistedMessage{
|
||||
eventID: persistedMessageIdentity(sessionID, e),
|
||||
role: "assistant",
|
||||
content: payload.Summary,
|
||||
})
|
||||
records = append(records, turnRecord{user: pendingUser, assistant: payload.Summary})
|
||||
pendingUser = ""
|
||||
havePending = false
|
||||
continue
|
||||
}
|
||||
// TOOL ACTIVITY IS PART OF THE TRANSCRIPT, NOT DECORATION. A restored
|
||||
// conversation that shows only the prose reads as if the agent asserted
|
||||
// its edits and command output from nowhere: the user sees "I updated
|
||||
// scope.go" with no record that any tool ran. These are collected for
|
||||
// replay only. They deliberately do NOT enter turnRecord, so the prompt
|
||||
// context a resumed turn receives stays byte-identical to what it was —
|
||||
// load and resume continue to consume the SAME effective history, and
|
||||
// only the wire rendering differs. Reported by @jatmn.
|
||||
if e.Type == sessions.EventToolCall || e.Type == sessions.EventToolResult {
|
||||
if upd := replayToolUpdate(e); upd != nil {
|
||||
rawID := upd.ToolCallID
|
||||
if e.Type == sessions.EventToolResult {
|
||||
replayID, ok := activeToolCalls[rawID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
upd.ToolCallID = replayID
|
||||
delete(activeToolCalls, rawID)
|
||||
} else {
|
||||
replayID := replayToolCallID(persistedMessageIdentity(sessionID, e))
|
||||
activeToolCalls[rawID] = replayID
|
||||
upd.ToolCallID = replayID
|
||||
}
|
||||
messages = append(messages, persistedMessage{
|
||||
eventID: persistedMessageIdentity(sessionID, e),
|
||||
tool: upd,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if e.Type != sessions.EventMessage {
|
||||
continue
|
||||
}
|
||||
@@ -595,12 +996,14 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
}
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
pendingUser = msg.Content
|
||||
havePending = true
|
||||
case "assistant":
|
||||
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
|
||||
records = append(records, turnRecord{user: pendingUser, assistant: msg.Content})
|
||||
pendingUser = ""
|
||||
havePending = false
|
||||
@@ -609,10 +1012,89 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records, nil
|
||||
return records, messages, rehydrateWarning, nil
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
|
||||
// replayToolUpdate rebuilds the ACP notification for one stored tool event.
|
||||
//
|
||||
// The stored toolCallId is returned here as a correlation key. loadHistory maps
|
||||
// each call occurrence to a stable session-wide replay identity and applies that
|
||||
// identity to its result. Older records wrote the field as "id", so both
|
||||
// spellings are accepted — the TUI projection already does the same
|
||||
// (internal/tui/session.go).
|
||||
//
|
||||
// A record that carries no usable id is skipped rather than replayed under a
|
||||
// synthesized one: an unpairable update is worse than a missing row.
|
||||
func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
|
||||
raw, err := json.Marshal(event.Payload)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ID string `json:"id"`
|
||||
Arguments string `json:"arguments"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output"`
|
||||
ChangedFiles []string `json:"changedFiles"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil {
|
||||
return nil
|
||||
}
|
||||
id := payload.ToolCallID
|
||||
if id == "" {
|
||||
id = payload.ID
|
||||
}
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if event.Type == sessions.EventToolCall {
|
||||
upd := toolCallStart(agent.ToolCall{ID: id, Name: payload.Name, Arguments: payload.Arguments})
|
||||
return &upd
|
||||
}
|
||||
status := tools.Status(payload.Status)
|
||||
if status == "" {
|
||||
status = tools.StatusOK
|
||||
}
|
||||
upd := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: id,
|
||||
Name: payload.Name,
|
||||
Status: status,
|
||||
Output: payload.Output,
|
||||
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
|
||||
})
|
||||
return &upd
|
||||
}
|
||||
|
||||
func persistedMessageIdentity(sessionID string, event sessions.Event) string {
|
||||
if event.ID != "" {
|
||||
return event.ID
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", sessionID, event.Sequence)
|
||||
}
|
||||
|
||||
// replayMessageID maps ZERO's stable event identity to a standards-shaped UUID
|
||||
// without leaking or parsing the event id on the wire. The same stored message
|
||||
// receives the same opaque id across loads, which also gives clients an exact
|
||||
// chunk boundary when two adjacent persisted messages have the same role.
|
||||
func replayMessageID(eventID string) string {
|
||||
return replayOpaqueID("message", eventID)
|
||||
}
|
||||
|
||||
func replayToolCallID(eventID string) string {
|
||||
return replayOpaqueID("tool-call", eventID)
|
||||
}
|
||||
|
||||
func replayOpaqueID(kind, eventID string) string {
|
||||
sum := sha256.Sum256([]byte("zero-acp-" + kind + ":" + eventID))
|
||||
b := sum[:16]
|
||||
b[6] = (b[6] & 0x0f) | 0x50
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error, notify bool) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
@@ -621,7 +1103,7 @@ func (a *Agent) warnPersistence(note *notifier, action string, message string, e
|
||||
sessionID = note.sessionID
|
||||
}
|
||||
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
|
||||
if note != nil {
|
||||
if note != nil && notify {
|
||||
note.text("\n\n[zero warning] " + message + "\n")
|
||||
}
|
||||
}
|
||||
@@ -668,18 +1150,34 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
|
||||
// registerSession publishes a session under the agent's lock. If one is already
|
||||
// registered for id (e.g. a re-load of an in-flight session) the existing live
|
||||
// session is returned unchanged rather than orphaning its turn or resetting its
|
||||
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
||||
// half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
|
||||
// session is returned rather than orphaning its turn or resetting its mode/model.
|
||||
// The bool reports that case so persisted activation can re-read and apply disk
|
||||
// history under turnMu. history is set BEFORE first publication so no concurrent
|
||||
// prompt can read a half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) (*acpSession, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing
|
||||
return existing, true
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
a.sessions[id] = sess
|
||||
return sess
|
||||
return sess, false
|
||||
}
|
||||
|
||||
// refreshSessionHistory is the activation apply boundary for an already-live
|
||||
// session. The history is read while prompts are excluded, then applied to the
|
||||
// same object the prompt path uses. Re-reading here avoids overwriting a turn
|
||||
// that committed between activatePersistedSession's pre-publication read and
|
||||
// discovering that another activation had already registered the session.
|
||||
func (a *Agent) refreshSessionHistory(sess *acpSession, requireHistoryLog bool) ([]turnRecord, []persistedMessage, error, error) {
|
||||
sess.turnMu.Lock()
|
||||
defer sess.turnMu.Unlock()
|
||||
history, messages, warning, err := a.loadHistory(sess.id, requireHistoryLog)
|
||||
if err == nil {
|
||||
sess.setHistory(history)
|
||||
}
|
||||
return history, messages, warning, err
|
||||
}
|
||||
|
||||
func (a *Agent) session(id string) *acpSession {
|
||||
@@ -762,8 +1260,48 @@ func (s *acpSession) appendHistory(rec turnRecord) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) setHistory(history []turnRecord) {
|
||||
s.mu.Lock()
|
||||
s.history = append([]turnRecord(nil), history...)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) snapshotHistory() []turnRecord {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]turnRecord(nil), s.history...)
|
||||
}
|
||||
|
||||
// sameWorkspace reports whether two resolved roots name the same directory.
|
||||
//
|
||||
// STRING EQUALITY IS NOT DIRECTORY IDENTITY. ResolveWorkspaceRoot is abs plus
|
||||
// filepath.Clean plus a stat: it does not fold case and does not resolve
|
||||
// junctions, so one directory reached by two spellings produces two different
|
||||
// roots. A session persisted from the TUI was then unresumable from an editor
|
||||
// holding a different spelling of the same project folder, and session/list
|
||||
// filtered by the other spelling returned nothing — not a failed resume but an
|
||||
// invisible one, on exactly the case this feature exists for.
|
||||
//
|
||||
// filepath.EvalSymlinks is NOT the fix on Windows: it normalises a drive letter
|
||||
// but returns a junction path unchanged, so the alias survives it. Junctions need
|
||||
// no privilege, so this is ordinary rather than exotic. os.SameFile compares the
|
||||
// filesystem's own identity for the two directories, which is the question being
|
||||
// asked.
|
||||
//
|
||||
// The string comparison stays as the fast path, and a stat failure falls back to
|
||||
// it rather than widening the match — this gate refuses access to another
|
||||
// workspace's files and configuration, so an unanswerable comparison denies.
|
||||
func sameWorkspace(left, right string) bool {
|
||||
if left == right {
|
||||
return true
|
||||
}
|
||||
leftInfo, err := os.Stat(left)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rightInfo, err := os.Stat(right)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return os.SameFile(leftInfo, rightInfo)
|
||||
}
|
||||
+1836
-17
File diff suppressed because it is too large.
Load diff
+43
-10
@@ -89,6 +89,13 @@ type Conn struct {
|
||||
|
||||
handlers map[string]HandlerFunc
|
||||
notifiers map[string]NotifyFunc
|
||||
// Notifications for one method are an ordered stream: session/update in
|
||||
// particular is stateful, so dispatching every frame in an unrelated
|
||||
// goroutine can make a later chunk overtake an earlier one. Different
|
||||
// methods retain independent tails, keeping session/cancel responsive while
|
||||
// an update handler is busy.
|
||||
notifyMu sync.Mutex
|
||||
notifyTails map[string]chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
@@ -106,11 +113,12 @@ type Conn struct {
|
||||
// lifetime.
|
||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
return &Conn{
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
notifyTails: make(map[string]chan struct{}),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,11 +311,7 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
}(msg)
|
||||
case msg.isNotify():
|
||||
if fn := c.notifiers[msg.Method]; fn != nil {
|
||||
c.wg.Add(1)
|
||||
go func(m rpcMessage) {
|
||||
defer c.wg.Done()
|
||||
fn(ctx, m.Params)
|
||||
}(msg)
|
||||
c.dispatchNotification(ctx, msg, fn)
|
||||
}
|
||||
default:
|
||||
// Malformed frame; reply only if we can identify a request id.
|
||||
@@ -317,6 +321,35 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchNotification preserves wire order within one method without making
|
||||
// unrelated notification methods wait for each other. The read loop installs
|
||||
// each tail before starting its goroutine, so goroutine scheduling cannot
|
||||
// reorder the chain it observes.
|
||||
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
|
||||
c.notifyMu.Lock()
|
||||
previous := c.notifyTails[msg.Method]
|
||||
done := make(chan struct{})
|
||||
c.notifyTails[msg.Method] = done
|
||||
c.notifyMu.Unlock()
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
defer func() {
|
||||
close(done)
|
||||
c.notifyMu.Lock()
|
||||
if c.notifyTails[msg.Method] == done {
|
||||
delete(c.notifyTails, msg.Method)
|
||||
}
|
||||
c.notifyMu.Unlock()
|
||||
}()
|
||||
if previous != nil {
|
||||
<-previous
|
||||
}
|
||||
fn(ctx, msg.Params)
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
|
||||
fn := c.handlers[msg.Method]
|
||||
if fn == nil {
|
||||
|
||||
@@ -360,6 +360,86 @@ func TestConnNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
firstStarted := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
delivered := make(chan int, 2)
|
||||
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
|
||||
var in struct{ Sequence int }
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if in.Sequence == 1 {
|
||||
close(firstStarted)
|
||||
<-releaseFirst
|
||||
}
|
||||
delivered <- in.Sequence
|
||||
})
|
||||
|
||||
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
|
||||
t.Fatalf("first notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-firstStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first notification did not start")
|
||||
}
|
||||
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
|
||||
t.Fatalf("second notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case sequence := <-delivered:
|
||||
t.Fatalf("notification %d overtook the blocked first notification", sequence)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
close(releaseFirst)
|
||||
for want := 1; want <= 2; want++ {
|
||||
select {
|
||||
case got := <-delivered:
|
||||
if got != want {
|
||||
t.Fatalf("notification order = ...,%d; want %d", got, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("notification %d was not delivered", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
updateStarted := make(chan struct{})
|
||||
releaseUpdate := make(chan struct{})
|
||||
cancelDelivered := make(chan struct{})
|
||||
b.HandleNotify("update", func(context.Context, json.RawMessage) {
|
||||
close(updateStarted)
|
||||
<-releaseUpdate
|
||||
})
|
||||
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
|
||||
close(cancelDelivered)
|
||||
})
|
||||
|
||||
if err := a.Notify("update", nil); err != nil {
|
||||
t.Fatalf("update notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-updateStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("update notification did not start")
|
||||
}
|
||||
if err := a.Notify("cancel", nil); err != nil {
|
||||
t.Fatalf("cancel notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-cancelDelivered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("different notification method was blocked behind update")
|
||||
}
|
||||
close(releaseUpdate)
|
||||
}
|
||||
|
||||
func TestConnMethodNotFound(t *testing.T) {
|
||||
a, _, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
@@ -19,6 +19,14 @@ func agentMessageChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
func replayMessageChunk(role, messageID, text string) ContentChunk {
|
||||
update := UpdateAgentMessageChunk
|
||||
if role == "user" {
|
||||
update = UpdateUserMessageChunk
|
||||
}
|
||||
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
|
||||
}
|
||||
|
||||
func agentThoughtChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
+54
-5
@@ -12,6 +12,8 @@ const (
|
||||
MethodAuthenticate = "authenticate"
|
||||
MethodSessionNew = "session/new"
|
||||
MethodSessionLoad = "session/load"
|
||||
MethodSessionList = "session/list"
|
||||
MethodSessionResume = "session/resume"
|
||||
MethodSessionPrompt = "session/prompt"
|
||||
MethodSessionCancel = "session/cancel" // notification
|
||||
MethodSessionUpdate = "session/update" // notification (agent -> client)
|
||||
@@ -62,8 +64,16 @@ type PromptCapabilities struct {
|
||||
}
|
||||
|
||||
type AgentCapabilities struct {
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
|
||||
}
|
||||
|
||||
// Empty capability objects are presence flags in ACP v1. Pointers preserve
|
||||
// the wire distinction between an advertised `{}` and an omitted capability.
|
||||
type SessionCapabilities struct {
|
||||
List *struct{} `json:"list,omitempty"`
|
||||
Resume *struct{} `json:"resume,omitempty"`
|
||||
}
|
||||
|
||||
type AuthMethod struct {
|
||||
@@ -117,9 +127,14 @@ type McpServer struct {
|
||||
}
|
||||
|
||||
type NewSessionParams struct {
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
// AdditionalDirectories is declared by the protocol and consumed nowhere in
|
||||
// this repo yet. When it is wired up it must go through requestedWorkspace
|
||||
// like Cwd does: these are client-supplied paths with no absoluteness rule of
|
||||
// their own, which is the same shape as the resume-cwd hole that made
|
||||
// {"sessionId":"known"} activate a session against this process's directory.
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
}
|
||||
|
||||
type NewSessionResult struct {
|
||||
@@ -140,6 +155,39 @@ type LoadSessionResult struct {
|
||||
Modes *SessionModeState `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
// ListSessionsParams and the following types implement ACP v1 session/list.
|
||||
// Transcript contents stay behind session/load; this method returns metadata
|
||||
// only and leaves the optional pagination cursor opaque.
|
||||
type ListSessionsParams struct {
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfoMeta struct {
|
||||
ModelID string `json:"modelId,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
Title string `json:"title,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
Meta *SessionInfoMeta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
type ListSessionsResult struct {
|
||||
Sessions []SessionInfo `json:"sessions"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// session/resume takes session/load's wire shape, so its cwd is a plain string
|
||||
// too and an omitted one decodes exactly like an empty one. Sharing the type is
|
||||
// safe only because activatePersistedSession requires an absolute cwd from the
|
||||
// request itself — nothing downstream may supply a default for either method.
|
||||
type ResumeSessionParams = LoadSessionParams
|
||||
type ResumeSessionResult = LoadSessionResult
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
type PromptParams struct {
|
||||
@@ -174,6 +222,7 @@ type SessionNotification struct {
|
||||
// ContentBlock under "content"; the variant is set via SessionUpdate.
|
||||
type ContentChunk struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Content ContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+37
-49
@@ -49,6 +49,14 @@ const (
|
||||
exitInterrupted = 130
|
||||
)
|
||||
|
||||
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
|
||||
// An interactive run can be continued past the shared interactive default by the
|
||||
// user; an exec run has nobody to unstick it, so evals and automation would
|
||||
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
|
||||
// still finite against a genuinely runaway loop, which the loop's empty-turn
|
||||
// guard also cuts independently of this budget.
|
||||
const defaultExecMaxTurns = config.MaxTurnsCeiling
|
||||
|
||||
type execOutputFormat string
|
||||
type execInputFormat string
|
||||
|
||||
@@ -320,6 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
resolved.MaxTurns = execTurnBudget(resolved)
|
||||
var displacedMaxTurns int
|
||||
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
@@ -442,57 +451,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)
|
||||
@@ -1257,6 +1231,20 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
|
||||
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
|
||||
}
|
||||
|
||||
// execTurnBudget applies the headless default to the resolved turn budget: an
|
||||
// interactive run can be continued past the shared default by the user, but an
|
||||
// exec run has nobody to unstick it, so an unconfigured budget starts at the
|
||||
// documented ceiling instead of truncating mid-task. Explicit sources
|
||||
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
|
||||
// MaxTurnsSet and pass through; an exec-profile budget still wins via
|
||||
// applyProfileTurnBudget afterward.
|
||||
func execTurnBudget(resolved config.ResolvedConfig) int {
|
||||
if resolved.MaxTurnsSet {
|
||||
return resolved.MaxTurns
|
||||
}
|
||||
return defaultExecMaxTurns
|
||||
}
|
||||
|
||||
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
|
||||
// The profile displaces the RESOLVED value, not the flag: a pinned budget
|
||||
// always wins and the profile backs off entirely, while an env/config budget
|
||||
|
||||
@@ -1975,3 +1975,23 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
|
||||
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resolved config.ResolvedConfig
|
||||
want int
|
||||
}{
|
||||
// A headless run has no user to continue past the shared interactive
|
||||
// default, so an unconfigured budget starts at the ceiling.
|
||||
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
|
||||
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
|
||||
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
|
||||
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := execTurnBudget(tc.resolved); got != tc.want {
|
||||
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MaxTurnsSet: cfg.maxTurnsSet,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
@@ -234,6 +235,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
}
|
||||
if src.MaxTurns > 0 {
|
||||
dst.MaxTurns = src.MaxTurns
|
||||
dst.maxTurnsSet = true
|
||||
}
|
||||
for _, provider := range src.Providers {
|
||||
mergeProvider(dst, provider)
|
||||
@@ -294,6 +296,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
}
|
||||
if src.MaxTurns > 0 {
|
||||
dst.MaxTurns = src.MaxTurns
|
||||
dst.maxTurnsSet = true
|
||||
}
|
||||
for _, provider := range src.Providers {
|
||||
candidate := providerMergeCandidate(*dst, provider)
|
||||
@@ -606,6 +609,7 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
|
||||
n = MaxTurnsCeiling
|
||||
}
|
||||
cfg.MaxTurns = n
|
||||
cfg.maxTurnsSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +730,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
}
|
||||
if overrides.MaxTurns > 0 {
|
||||
cfg.MaxTurns = overrides.MaxTurns
|
||||
cfg.maxTurnsSet = true
|
||||
}
|
||||
if overrides.Sandbox.Enabled != nil {
|
||||
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
|
||||
|
||||
@@ -2252,3 +2252,45 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
const providerJSON = `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
|
||||
}`
|
||||
providerWithTurns := strings.Replace(providerJSON, `"activeProvider": "p"`, `"activeProvider": "p", "maxTurns": 40`, 1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts ResolveOptions
|
||||
set bool
|
||||
}{
|
||||
{"built-in default only", ResolveOptions{
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, false},
|
||||
{"user config", ResolveOptions{UserConfigPath: writeConfig(t, providerWithTurns)}, true},
|
||||
{"project config", ResolveOptions{
|
||||
UserConfigPath: writeConfig(t, providerJSON),
|
||||
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
|
||||
}, true},
|
||||
{"env", ResolveOptions{
|
||||
Env: map[string]string{MaxTurnsEnv: "120"},
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, true},
|
||||
{"cli override", ResolveOptions{
|
||||
Overrides: Overrides{
|
||||
MaxTurns: 42,
|
||||
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
|
||||
},
|
||||
}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resolved, err := Resolve(tc.opts)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Resolve() error = %v", tc.name, err)
|
||||
}
|
||||
if resolved.MaxTurnsSet != tc.set {
|
||||
t.Fatalf("%s: MaxTurnsSet = %v, want %v (MaxTurns=%d)", tc.name, resolved.MaxTurnsSet, tc.set, resolved.MaxTurns)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -363,6 +364,13 @@ type FileConfig struct {
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
// maxTurnsSet records that some merge source supplied a positive maxTurns —
|
||||
// i.e. the value is configured, not the built-in default. Unexported like
|
||||
// Tools.deferThresholdSet: merge bookkeeping, not a config key.
|
||||
maxTurnsSet bool
|
||||
// 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 +408,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 {
|
||||
@@ -432,10 +465,15 @@ type Overrides struct {
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
// MaxTurnsSet reports whether MaxTurns came from an explicit source (a
|
||||
// config file, a provider command, ZERO_MAX_TURNS, or CLI overrides) rather
|
||||
// than the built-in default — callers that need a different default can
|
||||
// tell the two apart.
|
||||
MaxTurnsSet bool
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
@@ -516,6 +554,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 +587,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)
|
||||
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user