Compare commits

..
Author SHA1 Message Date
anandh8x c2caa9ab93 Preserve patch context bytes
Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
2026-08-19 20:15:05 +05:30
anandh8x b9894f0eca Align structured patch failure semantics
Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation.
2026-08-19 17:56:05 +05:30
anandh8x a6892f2b38 Bind patch rollback to committed versions
Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races.
2026-08-19 16:04:02 +05:30
anandh8x de2f071dd2 Harden patch transactions and review edges
Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture.
2026-08-19 15:28:55 +05:30
anandh8x 1439b25a34 Address final review correctness gaps
Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility.
2026-08-19 14:03:18 +05:30
anandh8x 00baeac3ca Resolve plan test seam after rebase
Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes.
2026-08-19 13:28:42 +05:30
anandh8x b6cd6c1bab Fix structured patch safety boundaries
Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files.
2026-08-19 13:24:09 +05:30
anandh8x 99a8b9f588 Fix Windows CI process checks 2026-08-19 13:24:09 +05:30
anandh8x 889f109630 Reuse rooted patch temp allocator 2026-08-19 13:24:09 +05:30
anandh8x 8b4605b121 Retry structured patch temp allocation 2026-08-19 13:24:09 +05:30
anandh8x 09c78b527f Use unique structured patch temp files 2026-08-19 13:24:09 +05:30
anandh8x d3d7efdcf8 Bind structured patches to workspace root 2026-08-19 13:24:09 +05:30
anandh8x 94458fd0d1 Harden patch and transcript edge cases 2026-08-19 13:24:09 +05:30
anandh8x b94e956893 Fix smoke test regressions 2026-08-19 13:24:09 +05:30
anandh8x 4a289c286a Address UI polish review findings 2026-08-19 13:24:09 +05:30
anandh8x 6c7a5d8f2a Resolve rebased diff renderer conflict 2026-08-19 13:24:09 +05:30
anandh8x c4de188713 Polish transcript and tool feedback 2026-08-19 13:24:09 +05:30
anandh8x 0d6cc7c5a5 Render staged image attachments in terminal 2026-08-19 13:21:17 +05:30
anandh8x 76fbe0421e Normalize ChatGPT model aliases 2026-08-19 13:21:17 +05:30
anandh8x 93034797ad Preserve semantic colors behind overlays 2026-08-19 13:21:17 +05:30
anandh8x 17ed6fd9bb Smooth active run status feedback 2026-08-19 13:21:17 +05:30
anandh8x f7a4158875 Cover resumed edit previews 2026-08-19 13:21:17 +05:30
anandh8x 7ce81090a2 Remove inert transcript interactions 2026-08-19 13:21:17 +05:30
anandh8x 634aa7990e Replace persistent sidebar with run details 2026-08-19 13:21:16 +05:30
anandh8x 5a3a7dd0ca Improve theme-aware code rendering 2026-08-19 13:21:16 +05:30
anandh8x 7b1180ea0a Restore tool previews when resuming sessions 2026-08-19 13:21:16 +05:30
anandh8x eee66ed0e8 Fix structured apply patch handling 2026-08-19 13:21:16 +05:30
anandh8x e41d2e579c Checkpoint UI polish 2026-08-19 13:21:16 +05:30
185 changed files with 1948 additions and 14695 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.8.0"}
{".":"0.7.0"}
-39
View File
@@ -111,42 +111,3 @@ These classes drive multi-round reviews. Fix them before requesting review:
shipped. Wire advertised entry points or shrink the claim. Do not bundle
unrelated fixes. Do not widen security allowlists by name alone; check
classification and side effects too.
- **Host tool version floors:** Zero shells out to `git` and other host tools,
and the project does not declare a minimum version for any of them. A flag,
subcommand, or config key that is not long-established therefore raises that
floor silently, and the failure on an older host is an unhelpful usage error
rather than a clear message. Before adding one, check when it landed and say
so where a caller would look. Record the floor for the exact subcommand you
call, not for the flag in general: `--end-of-options` reached Git's shared
parser in 2.24, but `git rev-parse` only accepted it from 2.30, and
`git checkout` and `git reset` later still. Likewise `--show-current` is
`git branch` 2.22, and `branch.autoSetupMerge=inherit` is 2.35. Then either
keep a fallback for older versions or gate the call. A test that depends on a
newer feature must skip below its floor instead of failing, and say which
version it needs.
- **Prove the test fails without the change:** A regression test earns its
place only if it fails on the unfixed code, for the reason it claims. Run it
both ways and quote the failure in the PR. The recurring miss is not a
missing test but a test that never reaches the behavior it names, because an
earlier guard rejects the input first, so it passes with and without the fix.
When a test targets a specific layer, call that layer directly rather than
relying on a public entry point that may refuse earlier.
- **Hermetic tests:** Tests must not read or write the developer's real config,
cache, or state directories. Redirect every root the code under test
resolves, not just config. `os.UserConfigDir` and `os.UserCacheDir` read
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` only on Linux and the BSDs. On macOS
they derive from `HOME`, and on Windows from `%AppData%` and `%LocalAppData%`.
Setting the XDG variables alone still leaves a macOS run writing to the real
home, so set `HOME` as well, or take the directory as a parameter. Apply it to
every test that reaches the same storage, not only the one where it was first
needed; a test that passes on a clean machine and pollutes a real one is a
blocker.
- **Error codes vary by platform and by flag combination:** A check written
against the code one target returns can be silently inert on another while
still looking correct. `openat(O_DIRECTORY|O_NOFOLLOW)` on a symlink may
report `ENOTDIR` or `ELOOP` depending on the platform, because the kernel can
reject the directory mismatch before it ever resolves the link; accept both
rather than assuming the one POSIX names. On Windows, `\\?\` and `\\.\`
prefixes are not UNC paths, and an `NtCreateFile` information class must match
the struct actually passed. Confirm the code each supported target returns
instead of reasoning from the man page.
-21
View File
@@ -6,27 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
### Features
* **security:** path-jail primitive, credential-store locking, worktree git hardening ([#891](https://github.com/Gitlawb/zero/issues/891)) ([060d38c](https://github.com/Gitlawb/zero/commit/060d38ce716feb7c5b6657b082cd35cb8dda3d75))
* **update:** recognise Homebrew installs and add mise, go install docs ([#910](https://github.com/Gitlawb/zero/issues/910)) ([01c3498](https://github.com/Gitlawb/zero/commit/01c3498205e69d26e5f929225f9455b276444f39))
* use Exa as default search provider ([#926](https://github.com/Gitlawb/zero/issues/926)) ([1ec7219](https://github.com/Gitlawb/zero/commit/1ec7219aee7b2840481ab0905f131644e27d2a4e))
* **zerogit:** auto-create a conventional branch before push/pr on default branch ([#671](https://github.com/Gitlawb/zero/issues/671)) ([d065467](https://github.com/Gitlawb/zero/commit/d065467cd3ec3ed91c5dc60554c36fc4b4d6de4c))
### Bug Fixes
* **acp:** three defects a desktop ACP client hits ([#915](https://github.com/Gitlawb/zero/issues/915)) ([a78c6c3](https://github.com/Gitlawb/zero/commit/a78c6c357e1d21146d7cd2e8030a754e06f7f1f5))
* bump Go to 1.26.6 for stdlib vulnerability fixes ([#903](https://github.com/Gitlawb/zero/issues/903)) ([dc15e82](https://github.com/Gitlawb/zero/commit/dc15e822987d8d7de467c62c375518f435b74d8c))
* **daemon:** clean up child after startup timeout ([#774](https://github.com/Gitlawb/zero/issues/774)) ([9c29209](https://github.com/Gitlawb/zero/commit/9c292092a26c9092fd873d072b0af1b0d9d39ef0))
* **dictation:** redact API keys from streaming transcriber errors ([#852](https://github.com/Gitlawb/zero/issues/852)) ([64a783b](https://github.com/Gitlawb/zero/commit/64a783b08caa2c7b21f188033b9ed84a14e00e84))
* **modelregistry:** expose reasoning effort for DeepSeek V4 models ([#931](https://github.com/Gitlawb/zero/issues/931)) ([90dcfd1](https://github.com/Gitlawb/zero/commit/90dcfd127e8a6d9902ec9f4e72f6d03fef1a0fc6))
* **providers:** discover ChatGPT capabilities ([#890](https://github.com/Gitlawb/zero/issues/890)) ([2d2450e](https://github.com/Gitlawb/zero/commit/2d2450e9a744349f0d01b1d4e9ba29c24ba5650d))
* **sandbox:** normalize launcher names before the command-prefix denylist ([#934](https://github.com/Gitlawb/zero/issues/934)) ([6edf9a8](https://github.com/Gitlawb/zero/commit/6edf9a8b78dc030dc44598919db1c7fa9d4f809a))
## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
+5 -22
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.26.6+.
Source builds require Go 1.26.5+.
```bash
git clone https://github.com/Gitlawb/zero.git
@@ -273,19 +273,6 @@ 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
@@ -384,13 +371,9 @@ go run ./cmd/zero-perf-bench
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
session (background connection prewarm + request-prefix telemetry) for headless
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
with the variable unset and set.
Native ChatGPT Responses sessions are enabled by default. Set
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
transport.
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
variable unset and set.
### Code Quality and Security Checks
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.26.6+。
源码构建需要 Go 1.26.5+。
```bash
git clone https://github.com/Gitlawb/zero.git
+1 -1
View File
@@ -33,4 +33,4 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
keyless web search, to the documented Firecrawl endpoint — see the README).
+1 -6
View File
@@ -627,12 +627,7 @@ 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. `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.
- **Network tools**: `web_fetch` and optional web search backend.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
+8 -83
View File
@@ -1,30 +1,14 @@
# Installing Zero
Pick whichever fits how you already install things. None of these is the
blessed one.
Zero is distributed as:
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.26.5+
`zero upgrade` follows whatever owns the binary, with one exception it cannot
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
keg and names `brew upgrade zero`, and replaces the binary in place for
everything else — including a mise-managed one, which is the exception.
mise is the one case it cannot detect: a mise-managed binary is an ordinary
standalone install on disk, so `zero upgrade` will replace it and leave mise
describing a version that is no longer there. Use `mise upgrade` instead.
Release archives are the substrate for most of the above: the install scripts
and the npm fallback both download a platform archive from a published GitHub
Release and verify its checksum.
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
## npm
@@ -127,45 +111,6 @@ Defaults:
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
## mise
[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release
archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
mise-managed binary is an ordinary standalone install, but then mise's records
describe a version that is no longer on disk. Prefer `mise upgrade`.
## go install
```bash
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.6+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
macOS and Windows need no extra helper.
## From Source
```bash
@@ -180,7 +125,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.6+.
Source builds require Go 1.26.5+.
### Sandbox Helpers For Source Builds
@@ -297,23 +242,3 @@ zero upgrade
```
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
`zero upgrade` behaves differently depending on how Zero was installed, because
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
- **everything else**: downloads the verified release archive and replaces the
binary in place.
Homebrew is detected by the binary living inside a Cellar keg that carries
Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a
directory tree you built yourself can have exactly that shape, and refusing to
update an install Homebrew never touched would be a real cost for a made-up
reason. The receipt is the thing only Homebrew writes.
An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac
where that is also the Homebrew prefix.
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.6
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.9
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/Microsoft/go-winio v0.6.2
github.com/alecthomas/chroma/v2 v2.27.0
+4 -2
View File
@@ -1,7 +1,7 @@
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -64,6 +64,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+2 -16
View File
@@ -296,16 +296,6 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
if errors.Is(err, context.Canceled) {
return StopCancelled, nil
}
// A CLIENT THAT CANCELLED A PERMISSION PROMPT DID NOT FAIL. Only
// context.Canceled was matched here, so dismissing a permission dialog —
// a deliberate action, and for apply_patch the only refusal a client is
// offered — came back as JSON-RPC -32603 carrying the internal sentinel
// text. Editors and the desktop app both render that as a crashed turn,
// so declining a tool looked like ZERO falling over. It is a
// cancellation, and StopCancelled is what ACP has for saying so.
if errors.Is(err, agent.ErrPermissionApprovalCanceled) {
return StopCancelled, nil
}
return "", err
}
if result.FinishReason == "length" {
@@ -321,14 +311,10 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
// session/request_permission request and maps the outcome back. Failure to reach
// the client fails closed to deny.
func (a *Agent) requestPermission(ctx context.Context, sessionID string, req agent.PermissionRequest) (agent.PermissionDecision, error) {
// Built ONCE and used for both halves. Sending one list and validating the
// reply against another is the defect this whole path had; see
// decisionFromOutcome.
options := buildPermissionOptions(req)
params := RequestPermissionParams{
SessionID: sessionID,
ToolCall: permissionToolCall(req),
Options: options,
Options: buildPermissionOptions(req),
}
var result RequestPermissionResult
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
@@ -337,7 +323,7 @@ func (a *Agent) requestPermission(ctx context.Context, sessionID string, req age
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission request failed: " + err.Error()}, nil
}
return decisionFromOutcome(result.Outcome, options), nil
return decisionFromOutcome(result.Outcome, req.AvailableDecisions), nil
}
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
-217
View File
@@ -1,217 +0,0 @@
package acp
// desktopinterop_test.go covers three defects found by driving the real `zero
// acp` binary from a desktop ACP client. Each is a case where ZERO and a
// conforming client disagreed about the wire, and none of them failed loudly —
// they produced a silent denial, a spurious crash, or two buttons the user
// could not tell apart.
//
// A fourth, session/load restoring history without replaying it, is fixed by
// #914 rather than here: that one also gives the replayed messages stable ids
// and keeps session/resume replay-free, which this did not.
import (
"errors"
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
// The list the options were BUILT from and the list the answer was CHECKED
// against were different whenever ZERO did not enumerate: the client was sent
// Allow and Reject, and its reply was validated against an empty slice, so
// every button it could possibly show failed closed to deny. The user clicked
// Allow and ZERO recorded a denial, with nothing on screen to say so.
func TestAnOfferedOptionIsAccepted(t *testing.T) {
// No AvailableDecisions: the fallback path, which is every permission event
// that is not a prompt.
req := agent.PermissionRequest{ToolName: "bash"}
options := buildPermissionOptions(req)
if len(options) == 0 {
t.Fatal("no options were offered")
}
for _, option := range options {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
buildPermissionOptions(req),
)
if string(decision.Action) != option.OptionID {
t.Fatalf("option %q was answered with %q (%s) — an offered option must be accepted",
option.OptionID, decision.Action, decision.Reason)
}
}
}
// The narrowing this validation exists for still holds: a client must not be
// able to return a broader grant than it was shown.
func TestAnOptionThatWasNotOfferedIsStillRefused(t *testing.T) {
req := agent.PermissionRequest{ToolName: "bash"}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAlwaysAllow)},
buildPermissionOptions(req),
)
if decision.Action != agent.PermissionDecisionDeny {
t.Fatalf("an unoffered broader grant was accepted as %q", decision.Action)
}
}
// The resolver is the single source of truth for both sides, which is what
// makes the two agree by construction rather than by being kept in step.
func TestOfferedDecisionsMatchWhatWasSent(t *testing.T) {
for _, req := range []agent.PermissionRequest{
{ToolName: "bash"},
{ToolName: "bash", AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionDeny,
}},
} {
offered := buildPermissionOptions(req)
for _, option := range buildPermissionOptions(req) {
if !actionOffered(option.OptionID, offered) {
t.Fatalf("option %q was sent but is not in the offered set %v", option.OptionID, offered)
}
}
}
}
// ---- two options the user can tell apart ----
// request_permissions offers plain allow and strict-review allow together, and
// both were labelled "Allow". The label is the only thing an ACP client shows,
// so the panel presented two identical buttons where one silently enabled
// strict auto-review of what was granted.
func TestEveryOfferedOptionHasADistinctLabel(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "request_permissions",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowStrict,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionDeny,
},
}
seen := map[string]string{}
for _, option := range buildPermissionOptions(req) {
if previous, clash := seen[option.Name]; clash {
t.Fatalf("options %q and %q share the label %q — a user cannot tell them apart",
previous, option.OptionID, option.Name)
}
seen[option.Name] = option.OptionID
}
}
// The two remain distinct decisions: the label changed, the round trip did not.
func TestStrictAllowStillRoundTrips(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "request_permissions",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowStrict,
},
}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAllowStrict)},
buildPermissionOptions(req),
)
if decision.Action != agent.PermissionDecisionAllowStrict {
t.Fatalf("strict allow round-tripped as %q", decision.Action)
}
}
// ---- cancelling a permission is a cancellation, not a crash ----
// Only context.Canceled was recognised, so dismissing a permission dialog came
// back as JSON-RPC -32603 carrying the internal sentinel text. Clients render
// that as a failed turn, so declining a tool looked like ZERO falling over —
// and for apply_patch, dismissing is the ONLY refusal a client is offered.
func TestCancellingAPermissionEndsTheTurnAsCancelled(t *testing.T) {
canceled := fmt.Errorf("%w for apply_patch", agent.ErrPermissionApprovalCanceled)
reason, err := stopReasonFor(agent.Result{}, canceled)
if err != nil {
t.Fatalf("stopReasonFor returned an error for a cancellation: %v", err)
}
if reason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", reason, StopCancelled)
}
}
// A genuine failure must still be an error. Mapping too much to cancelled would
// hide real faults behind a clean ending, which is the opposite mistake.
func TestARealFailureIsStillAnError(t *testing.T) {
failure := errors.New("provider exploded")
if _, err := stopReasonFor(agent.Result{}, failure); !errors.Is(err, failure) {
t.Fatalf("stopReasonFor(%v) = %v, want the failure preserved", failure, err)
}
}
// The sentinel is matched through errors.Is, so it survives the wrapping every
// return site applies — each one adds the tool name.
func TestPermissionCancellationSurvivesWrapping(t *testing.T) {
if !errors.Is(fmt.Errorf("%w for bash", agent.ErrPermissionApprovalCanceled), agent.ErrPermissionApprovalCanceled) {
t.Fatal("a wrapped permission cancellation was not recognised")
}
if errors.Is(errors.New("something else"), agent.ErrPermissionApprovalCanceled) {
t.Fatal("an unrelated error was reported as a permission cancellation")
}
}
// A decision that never became an option cannot be selected.
//
// PermissionDecisionCancel is the case that exists today: optionKindFor drops
// it because ACP expresses cancellation through the outcome, yet ZERO
// enumerates it in AvailableDecisions for shell commands and apply_patch. While
// the reply was validated against the DECISIONS rather than the sent options, a
// client could return {"outcome":"selected","optionId":"cancel"} — an id it was
// never shown — and abort the whole turn.
//
// Written over every option-less decision rather than over "cancel", so a
// future action that optionKindFor declines to render is covered the day it is
// added rather than the day someone remembers this test.
func TestADecisionThatIsNotAnOptionCannotBeSelected(t *testing.T) {
for _, toolName := range []string{"bash", "apply_patch"} {
req := agent.PermissionRequest{
ToolName: toolName,
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
agent.PermissionDecisionCancel,
},
}
options := buildPermissionOptions(req)
for _, action := range req.AvailableDecisions {
if actionOffered(string(action), options) {
continue // it was sent; selecting it is legitimate
}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(action)},
options,
)
if decision.Action != agent.PermissionDecisionDeny {
t.Fatalf("%s: %q was never sent as an option but was accepted as %q",
toolName, action, decision.Action)
}
}
}
}
// Cancelling is still reachable — through the outcome, which is where ACP puts
// it. Closing the selected-id route must not close the real one.
func TestCancellingThroughTheOutcomeStillWorks(t *testing.T) {
options := buildPermissionOptions(agent.PermissionRequest{ToolName: "apply_patch"})
decision := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, options)
if decision.Action != agent.PermissionDecisionCancel {
t.Fatalf("outcome=cancelled gave %q, want cancel", decision.Action)
}
}
+19 -60
View File
@@ -15,7 +15,14 @@ import (
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
actions := offeredDecisions(req)
actions := req.AvailableDecisions
if len(actions) == 0 {
// Sensible default if ZERO didn't enumerate: allow once / reject.
actions = []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
}
}
options := make([]PermissionOption, 0, len(actions))
for _, action := range actions {
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
@@ -31,32 +38,6 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
return options
}
// offeredDecisions resolves what this request actually offers the client.
//
// ONE RESOLVER, USED BY BOTH SIDES, and that is the entire point of it existing.
// The fallback below used to live inside buildPermissionOptions while
// requestPermission validated the client's answer against the raw
// req.AvailableDecisions. When ZERO did not enumerate — which is every
// permission event that is not a prompt, and includes a shell call whose sandbox
// decision came back deny — the two disagreed completely: the client was sent
// "Allow" and "Reject", and validation was performed against an EMPTY list, so
// every option the client could possibly click failed closed to deny with
// "permission option was not offered".
//
// The user clicked Allow and ZERO recorded a denial. Nothing surfaced it,
// because a denial is a legitimate answer — the tool was simply refused and the
// turn carried on as though the user had rejected it.
func offeredDecisions(req agent.PermissionRequest) []agent.PermissionDecisionAction {
if len(req.AvailableDecisions) > 0 {
return req.AvailableDecisions
}
// Sensible default if ZERO didn't enumerate: allow once / reject.
return []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
}
}
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
// human label. Returns an empty kind for actions that ACP expresses through the
// outcome rather than an option (cancel).
@@ -68,16 +49,8 @@ func offeredDecisions(req agent.PermissionRequest) []agent.PermissionDecisionAct
// disclosure cannot be TUI-only.
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
switch action {
case agent.PermissionDecisionAllow:
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
return PermAllowOnce, "Allow"
case agent.PermissionDecisionAllowStrict:
// SPLIT FROM PLAIN ALLOW because the label is the only thing an ACP
// client shows. Both were labelled "Allow", and request_permissions
// offers them together — so the panel presented two identical buttons,
// and whichever the user picked looked the same while one of them
// silently turned on strict auto-review of what was granted. The option
// id carries no room for the distinction, so it has to be in the name.
return PermAllowOnce, "Allow with strict review"
case agent.PermissionDecisionAllowForSession:
return PermAllowAlways, "Allow for this session"
case agent.PermissionDecisionAllowPrefix:
@@ -97,31 +70,19 @@ func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind,
// decisionFromOutcome maps the client's permission outcome back to a ZERO
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
// action verbatim (validated against what was actually sent); anything
// unrecognized fails closed to deny.
//
// IT TAKES THE OPTIONS THAT WERE SENT, not the decisions the request offered,
// and the two are not the same list. Some actions never become options —
// PermissionDecisionCancel is dropped by optionKindFor because ACP expresses
// cancellation through the outcome instead — yet it IS enumerated in
// AvailableDecisions for shell commands and apply_patch, the two commonest
// prompts. Validating against the decisions therefore accepted
// {"outcome":"selected","optionId":"cancel"}: an identifier no client was ever
// shown, aborting the whole turn.
//
// Passing the sent options makes "what we offered" and "what we accept" the
// same slice rather than two things kept in step, so anything optionKindFor
// declines to render is excluded structurally instead of by name.
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []PermissionOption) agent.PermissionDecision {
// action verbatim (validated against what was offered); anything unrecognized
// fails closed to deny.
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
switch outcome.Outcome {
case OutcomeCancelled:
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
case OutcomeSelected:
action := agent.PermissionDecisionAction(outcome.OptionID)
// Bind to what was actually offered for THIS call: a client must not be able
// to return a broader grant (always_allow / allow_for_session) that wasn't
// presented. Anything not offered fails closed to deny.
if actionOffered(outcome.OptionID, offered) {
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
if actionOffered(action, offered) {
return agent.PermissionDecision{Action: action}
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
default:
@@ -129,9 +90,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []PermissionO
}
}
func actionOffered(optionID string, offered []PermissionOption) bool {
for _, option := range offered {
if option.OptionID == optionID {
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
for _, a := range offered {
if a == action {
return true
}
}
@@ -142,15 +103,13 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
// session/request_permission request from a ZERO permission request.
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
args := marshalArgs(req.Args)
upd := ToolCallUpdate{
return ToolCallUpdate{
ToolCallID: req.ToolCallID,
Title: toolTitle(req.ToolName, string(args)),
Kind: toolKindFor(req.ToolName),
Status: ToolStatusPending,
RawInput: rawInputBytes(args),
}
attachBrowserToolDetails(&upd, req.ToolName)
return upd
}
func marshalArgs(args map[string]any) []byte {
+2 -3
View File
@@ -56,11 +56,10 @@ func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
// what was offered, so decorating it would fail every prefix approval closed.
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
request := escalationRequest(true)
options := buildPermissionOptions(request)
for _, option := range options {
for _, option := range buildPermissionOptions(request) {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
options,
request.AvailableDecisions,
)
if string(decision.Action) != option.OptionID {
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
+5 -22
View File
@@ -39,14 +39,11 @@ func TestBuildPermissionOptionsDefault(t *testing.T) {
}
func TestDecisionFromOutcome(t *testing.T) {
offered := buildPermissionOptions(agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
},
})
offered := []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
}
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
t.Errorf("cancelled -> %q, want cancel", d.Action)
}
@@ -82,17 +79,3 @@ func TestPermissionToolCall(t *testing.T) {
t.Error("expected rawInput from args")
}
}
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
call := permissionToolCall(agent.PermissionRequest{
ToolCallID: "browser-1",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
})
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
t.Fatalf("browser descriptor = %#v", got)
}
if call.Title != "browser connect" {
t.Fatalf("title = %q", call.Title)
}
}
+1 -119
View File
@@ -2,9 +2,7 @@ package acp
import (
"encoding/json"
"net/url"
"strings"
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
@@ -46,125 +44,12 @@ func toolKindFor(name string) string {
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
func toolTitle(name, rawArgs string) string {
if browser, ok := browserToolDetails(name); ok {
return browserToolTitle(browser.Command, rawArgs)
}
if hint := primaryArgHint(rawArgs); hint != "" {
return name + " " + hint
}
return name
}
// browserToolDetails identifies ZERO's local browser helpers without treating
// similarly named MCP tools as browser automation. The descriptor intentionally
// contains no request data: ACP tool input is already protocol-visible, but a
// durable UI must not need to retain text, local CDP targets, or full URLs just
// to recognise the browser operation.
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
const prefix = "browser_"
command, ok := strings.CutPrefix(name, prefix)
if !ok {
return nil, false
}
switch command {
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
return &BrowserToolDetails{Version: 1, Command: command}, true
default:
return nil, false
}
}
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
// extension channel. Keeping this in one helper prevents start, result, and
// permission payloads from drifting onto different wire shapes.
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
browser, ok := browserToolDetails(name)
if !ok {
return
}
raw, err := json.Marshal(browser)
if err != nil {
return
}
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
}
// browserToolTitle avoids putting browser_type text, an attached DevTools
// endpoint, or a URL query/fragment in a tool-card title. Those values can
// carry credentials or session data; the UI only needs the operation and, for
// navigation, a human-recognisable origin.
func browserToolTitle(command, rawArgs string) string {
switch command {
case "action":
action, ok := exactJSONStringArg(rawArgs, "command")
if !ok {
return "browser action"
}
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
return "browser action " + action
}
return "browser action"
case "open":
rawURL, ok := exactJSONStringArg(rawArgs, "url")
if !ok {
return "browser open"
}
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
if err != nil {
return "browser open"
}
u, err := url.Parse(normalized)
if err != nil || u.Scheme == "" || u.Host == "" {
return "browser open"
}
origin := u.Scheme + "://" + u.Host
if !browserTitleTextSafe(origin) {
return "browser open"
}
return "browser open " + truncateHint(origin)
default:
return "browser " + command
}
}
// browserTitleTextSafe validates text after URL parsing has decoded escaped
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: control,
// format/bidi, and line/paragraph separator runes can reorder or split the
// permission label shown to a user. The execution URL remains unchanged.
func browserTitleTextSafe(text string) bool {
if !utf8.ValidString(text) {
return false
}
for _, r := range text {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
return false
}
}
return true
}
// exactJSONStringArg mirrors ZERO's map-based tool argument decoding: only the
// exact JSON key is considered, and a non-string value is invalid. In
// particular, an incidental "URL" key must not change a permission title when
// browser_open will only read "url".
func exactJSONStringArg(rawArgs, key string) (string, bool) {
var args map[string]json.RawMessage
if json.Unmarshal([]byte(rawArgs), &args) != nil {
return "", false
}
raw, ok := args[key]
if !ok {
return "", false
}
var value string
if json.Unmarshal(raw, &value) != nil {
return "", false
}
return value, true
}
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
// raw JSON arguments. Best-effort; returns "" when it can't parse.
func primaryArgHint(rawArgs string) string {
@@ -204,7 +89,7 @@ func rawInput(args string) json.RawMessage {
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
// update (status in_progress — ZERO executes immediately after advertising).
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
upd := ToolCallUpdate{
return ToolCallUpdate{
SessionUpdate: UpdateToolCall,
ToolCallID: call.ID,
Title: toolTitle(call.Name, call.Arguments),
@@ -212,8 +97,6 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
Status: ToolStatusInProgress,
RawInput: rawInput(call.Arguments),
}
attachBrowserToolDetails(&upd, call.Name)
return upd
}
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
@@ -233,7 +116,6 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
if locs := toolResultLocations(result); len(locs) > 0 {
upd.Locations = locs
}
attachBrowserToolDetails(&upd, result.Name)
return upd
}
-226
View File
@@ -1,29 +1,14 @@
package acp
import (
"encoding/json"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
t.Helper()
raw, ok := update.Meta[zeroBrowserMetaKey]
if !ok {
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
}
var details BrowserToolDetails
if err := json.Unmarshal(raw, &details); err != nil {
t.Fatalf("decode browser metadata: %v", err)
}
return details
}
func TestAgentMessageAndThoughtChunks(t *testing.T) {
m := agentMessageChunk("hello")
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
@@ -71,217 +56,6 @@ func TestToolTitleAndHint(t *testing.T) {
}
}
func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
start := toolCallStart(agent.ToolCall{
ID: "browser-1",
Name: "browser_open",
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
})
if got := browserDescriptor(t, start); got != (BrowserToolDetails{Version: 1, Command: "open"}) {
t.Fatalf("browser descriptor = %#v, want open", got)
}
if start.Title != "browser open https://example.com" {
t.Fatalf("browser title = %q", start.Title)
}
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
var wire struct {
Meta map[string]json.RawMessage `json:"_meta"`
}
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if _, ok := wire.Meta[zeroBrowserMetaKey]; !ok {
t.Fatalf("browser wire metadata = %#v", wire.Meta)
}
typed := toolCallStart(agent.ToolCall{
ID: "browser-2",
Name: "browser_type",
Arguments: `{"ref":"email","text":"[email protected]"}`,
})
if got := browserDescriptor(t, typed); got.Command != "type" {
t.Fatalf("browser type descriptor = %#v", got)
}
if typed.Title != "browser type" || strings.Contains(typed.Title, "[email protected]") {
t.Fatalf("browser type title = %q", typed.Title)
}
action := toolCallStart(agent.ToolCall{
ID: "browser-3",
Name: "browser_action",
Arguments: `{"command":"keyboard_insert_text","args":["[email protected]"]}`,
})
if action.Title != "browser action keyboard_insert_text" {
t.Fatalf("browser action title = %q", action.Title)
}
result := toolCallResult(agent.ToolResult{
ToolCallID: "browser-2",
Name: "browser_type",
Status: tools.StatusOK,
})
if got := browserDescriptor(t, result); got.Command != "type" {
t.Fatalf("browser result descriptor = %#v", got)
}
}
func TestBrowserDescriptorSurvivesProtocolShapedRoundTrip(t *testing.T) {
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{
ID: "start",
Name: "browser_open",
Arguments: `{"url":"https://user:[email protected]/private?token=secret#fragment"}`,
}),
toolCallResult(agent.ToolResult{
ToolCallID: "result",
Name: "browser_type",
Status: tools.StatusOK,
}),
permissionToolCall(agent.PermissionRequest{
ToolCallID: "permission",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
}),
}
type protocolToolCallUpdate struct {
SessionUpdate string `json:"sessionUpdate,omitempty"`
ToolCallID string `json:"toolCallId"`
Title string `json:"title,omitempty"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}
for _, update := range updates {
encoded, err := json.Marshal(update)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(encoded, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["browser"]; ok {
t.Fatalf("browser descriptor escaped ACP _meta: %s", encoded)
}
var protocol protocolToolCallUpdate
if err := json.Unmarshal(encoded, &protocol); err != nil {
t.Fatal(err)
}
forwarded, err := json.Marshal(protocol)
if err != nil {
t.Fatal(err)
}
var roundTripped ToolCallUpdate
if err := json.Unmarshal(forwarded, &roundTripped); err != nil {
t.Fatal(err)
}
details := browserDescriptor(t, roundTripped)
if details.Version != 1 || details.Command == "" {
t.Fatalf("round-tripped browser descriptor = %#v", details)
}
descriptorJSON := string(roundTripped.Meta[zeroBrowserMetaKey])
for _, secret := range []string{"password", "private", "token", "fragment", "127.0.0.1", "9222"} {
if strings.Contains(descriptorJSON, secret) {
t.Fatalf("browser descriptor leaked %q: %s", secret, descriptorJSON)
}
}
}
}
func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
if got := browserToolTitle("open", `{"url":"evil.example.test/pay?token=hidden#fragment"}`); got != "browser open https://evil.example.test" {
t.Fatalf("bare-host title = %q", got)
}
if got := browserToolTitle("open", `{"URL":"https://different.example.test"}`); got != "browser open" {
t.Fatalf("case-variant URL title = %q", got)
}
if got := browserToolTitle("open", `{"URL":"https://different.example.test","url":"https://actual.example.test/path"}`); got != "browser open https://actual.example.test" {
t.Fatalf("exact URL key title = %q", got)
}
if got := browserToolTitle("action", `{"command":"not an action"}`); got != "browser action" {
t.Fatalf("unknown browser action title = %q", got)
}
longHost := "https://" + strings.Repeat("a", 200) + ".example.test/path?token=hidden"
title := browserToolTitle("open", `{"url":"`+longHost+`"}`)
if !utf8.ValidString(title) || utf8.RuneCountInString(title) > len("browser open ")+61 || strings.Contains(title, "token=") {
t.Fatalf("bounded browser origin title = %q", title)
}
}
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
for _, rawURL := range []string{
"https://safe.example%E2%80%AEevil.test/path",
"https://safe.example%E2%81%A6evil.test/path",
"https://safe.example%C2%85evil.test/path",
"https://safe.example%E2%80%A8evil.test/path",
"https://safe.example%E2%80%A9evil.test/path",
} {
t.Run(rawURL, func(t *testing.T) {
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
if err != nil {
t.Fatalf("execution URL rejected: %v", err)
}
if normalized != rawURL {
t.Fatalf("execution URL = %q, want unchanged %q", normalized, rawURL)
}
args, err := json.Marshal(map[string]any{"url": rawURL})
if err != nil {
t.Fatal(err)
}
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
}
for _, update := range updates {
encoded, err := json.Marshal(update)
if err != nil {
t.Fatal(err)
}
var decoded ToolCallUpdate
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Title != "browser open" {
t.Fatalf("unsafe browser title survived wire round trip: %q", decoded.Title)
}
for _, r := range decoded.Title {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
t.Fatalf("browser title contains unsafe presentation rune %U: %q", r, decoded.Title)
}
}
}
})
}
}
func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
if len(start.Meta) != 0 {
t.Fatalf("MCP-like tool received built-in browser metadata: %#v", start.Meta)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), `"browser"`) {
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
}
}
func TestToolCallStart(t *testing.T) {
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
if upd.SessionUpdate != UpdateToolCall {
-15
View File
@@ -210,21 +210,6 @@ type ToolCallUpdate struct {
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
// namespaced key so protocol-shaped clients can preserve them while decoding
// and re-encoding a tool call.
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}
// BrowserToolDetails identifies the browser helper operation behind a tool
// call. Version is the schema version for this optional ZERO extension;
// Command is one of install, launch, connect, open, snapshot, click, type,
// press, or action. Future fields must remain display-safe and must not
// include browser profile data, cookies, typed text, URL paths/queries, or
// DevTools endpoints.
type BrowserToolDetails struct {
Version int `json:"version"`
Command string `json:"command"`
}
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
-37
View File
@@ -99,43 +99,6 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
}
}
// The launcher denylist is only worth anything if it holds on the path a user
// actually sees. A model naming its own prefix_rule is that path: before
// launcher names were normalized, "python3.11" was offered as a one-token
// grant, and hasStringPrefix then matched every later python3.11 invocation.
func TestProposedCommandPrefixRejectsLauncherSpellingsInRequestedPrefix(t *testing.T) {
for _, testCase := range []struct {
command string
rule []any
}{
{"python3.11 script.py", []any{"python3.11"}},
{"python.exe script.py", []any{"python.exe"}},
{"node.exe app.js", []any{"node.exe"}},
{"cmd /c whoami", []any{"cmd"}},
{"busybox sh -c id", []any{"busybox"}},
{"uv run main.py", []any{"uv", "run"}},
{"python3.11 -c import_os", []any{"python3.11", "-c"}},
} {
got := proposedCommandPrefix("bash", map[string]any{
"command": testCase.command,
"prefix_rule": testCase.rule,
})
if got != nil {
t.Errorf("prefix_rule %v for %q was offered as %#v, want rejected", testCase.rule, testCase.command, got)
}
}
}
func TestProposedCommandPrefixStillOffersOrdinaryCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "cargo build --release",
"prefix_rule": []any{"cargo", "build"},
})
if len(got) != 2 || got[0] != "cargo" || got[1] != "build" {
t.Fatalf("ordinary prefix rule = %#v, want [cargo build]", got)
}
}
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
cases := []string{
"cat < in > out",
+11 -178
View File
@@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -146,17 +144,11 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
for _, tool := range tools {
total += ApproxTextTokens(tool.Name)
total += ApproxTextTokens(tool.Description)
total += ApproxTextTokens(string(tool.Type))
if len(tool.Parameters) > 0 {
if encoded, err := json.Marshal(tool.Parameters); err == nil {
total += ApproxTextTokens(string(encoded))
}
}
if tool.Format != nil {
total += ApproxTextTokens(tool.Format.Type)
total += ApproxTextTokens(tool.Format.Syntax)
total += ApproxTextTokens(tool.Format.Definition)
}
total += 4 // per-tool overhead
}
return total
@@ -398,27 +390,6 @@ type compactionState struct {
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
// (Options.Summarizer) for the current main model. summarizerProvider
// memoizes the built provider and summarizerResolved records that the
// factory ran (it may legitimately return no provider: the main model is
// already the cheap one). summarizerBroken is the sticky fallback: once a
// build or call fails, the run stays on the main provider until the next
// model switch — a misconfigured cheap model must never break (or
// repeatedly delay) compaction.
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
summarizerProvider Provider
summarizerResolved bool
summarizerBroken bool
// window is the context window the threshold was derived from. The
// reactive free-prune stage compares against it (the limit the provider
// enforces), not against the 70% proactive threshold — a pruned history
// between the two would still succeed on retry.
window int
// mainModel is the model the run currently streams on; the summarizer
// choice is made against it and remade after a mid-run switch.
mainModel string
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
@@ -453,88 +424,17 @@ func (state *compactionState) calibratedTokens(raw int) int {
func newCompactionState(options Options, task *taskState) *compactionState {
state := &compactionState{
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
window: options.ContextWindow,
mainModel: options.Model,
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
summarizerFactory: options.Summarizer,
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
}
return state
}
// updateWindow re-derives the compaction threshold after a mid-run model
// switch. The low-water mark is reset: it was measured against the old
// threshold, and keeping it could suppress a compaction the smaller window
// now requires. <= 0 (unknown model) leaves everything unchanged.
func (state *compactionState) updateWindow(window int) {
if window <= 0 {
return
}
state.enabled = true
state.window = window
state.threshold = compactionThreshold(window)
state.lowWaterMark = 0
if state.planner != nil {
state.planner.config.contextWindow = window
}
}
// switchModel records a mid-run model switch: the window is re-derived
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
// the next compaction re-resolves it against the new main model. A run that
// started on the cheap model (no dedicated summarizer) and escalated to an
// expensive one therefore regains cheap summaries — the exact workflow a
// dedicated summarizer exists for.
func (state *compactionState) switchModel(modelID string, window int) {
state.mainModel = modelID
state.updateWindow(window)
state.summarizerProvider = nil
state.summarizerResolved = false
state.summarizerBroken = false
}
// summarizeProvider returns the provider summarization calls should use and
// whether it is the dedicated summarizer: that one when it applies to the
// current main model and is healthy, else the main provider. The factory runs
// at most once per main model; a build error marks the summarizer broken for
// that model. Callers branch on the boolean rather than comparing Provider
// values — interface comparison panics on a non-comparable dynamic type.
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
if state.summarizerFactory == nil || state.summarizerBroken {
return main, false
}
if !state.summarizerResolved {
state.summarizerResolved = true
built, err := state.summarizerFactory(ctx, state.mainModel)
if err != nil {
state.summarizerBroken = true
return main, false
}
state.summarizerProvider = built
}
if state.summarizerProvider == nil {
return main, false
}
return state.summarizerProvider, true
}
// fitsWindow reports whether a calibrated size is safely under the limit the
// provider enforces. The proactive threshold (70% of the window) is a trigger
// for the paid summarizer, not the hard limit; the reactive path compares
// against the window itself, with a small margin for estimate error, so a
// history pruned to, say, 85% of the window takes the free retry.
func (state *compactionState) fitsWindow(size int) bool {
if state.window <= 0 {
return size <= state.threshold
}
return size <= state.window-state.window/20
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
// when the history is under threshold, or when the history has not grown past
@@ -628,34 +528,10 @@ func (state *compactionState) recover(
// context-limit error never triggers an unexpected summarization call.
return messages, false, nil
}
if !isContextLimitError(errorMessage) {
if state.reactiveAttempted {
return messages, false, nil
}
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
// often be fixed for free by dropping superseded reads and pruning old
// tool-result bodies. It runs regardless of the one-shot reactive budget
// (it costs nothing and cannot loop: a second pass finds nothing left to
// prune). The free retry is taken only when the pruned history fits the
// window the provider enforces — otherwise a retry would fail the same
// way, so fall through to the paid summarizer in this same call.
toolTokens := estimateToolDefTokens(tools)
prunedAny := false
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if prunedAny {
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if state.fitsWindow(size) {
state.lowWaterMark = size
return messages, true, nil
}
}
if state.reactiveAttempted {
if !isContextLimitError(errorMessage) {
return messages, false, nil
}
@@ -695,21 +571,11 @@ func (state *compactionState) recover(
// provider call. The summary stream intentionally does NOT forward OnText (so
// compaction stays invisible on the user-facing surface), but it DOES forward
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
// The call goes to the dedicated (cheap) summarizer when one applies; if that
// call fails the summarizer is marked broken for the current model and the
// same slice is retried once on the main provider, so the caller sees at most
// one (main-provider-quality) error.
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
if state.planner == nil {
state.planner = newContextPlanner(contextPlannerConfig{})
}
return func(toSummarize []zeroruntime.Message) (string, error) {
chosen, dedicated := state.summarizeProvider(ctx, provider)
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
if err == nil || !dedicated {
return summary, err
}
state.summarizerBroken = true
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -793,41 +659,8 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
return summary, nil
}
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
// search results), yet the summary instructions only need their gist — the
// model that ACTED on the full output already turned it into decisions the
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
// head+tail of a result while cutting the summarizer input by 5-10x on
// tool-heavy sessions; the summarizer's own cost is the price being cut.
const summaryToolResultClamp = 2000
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
// a write_file call carries the whole file body in its arguments.
const summaryToolArgsClamp = 500
// clampForSummary head+tail-truncates text for the summarizer transcript,
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
func clampForSummary(text string, max int) string {
if max <= 0 || len(text) <= max {
return text
}
head := max * 3 / 4
tail := max - head
for head > 0 && !utf8.RuneStart(text[head]) {
head--
}
start := len(text) - tail
for start < len(text) && !utf8.RuneStart(text[start]) {
start++
}
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
}
// renderTranscript flattens messages into a plain-text transcript for the
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
// Tool-result bodies and tool-call arguments are clamped (see the constants
// above): the summary needs what was done and decided, not the raw payloads.
func renderTranscript(messages []zeroruntime.Message) string {
lines := make([]string, 0, len(messages))
for _, message := range messages {
@@ -837,13 +670,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
if len(message.ToolCalls) > 0 {
calls := make([]string, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
calls = append(calls, call.Name+"("+call.Arguments+")")
}
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
}
lines = append(lines, line)
case zeroruntime.MessageRoleTool:
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
lines = append(lines, "tool result: "+message.Content)
default:
lines = append(lines, string(message.Role)+": "+message.Content)
}
-469
View File
@@ -1,469 +0,0 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
// the proactive threshold for a 1000-token window.
func compactableHistory() []zeroruntime.Message {
return []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
}
}
// Summarization must run on the dedicated (cheap) summarizer when one is
// configured — the main provider sees no summarize traffic at all.
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
main := &mockProvider{}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return cheap, nil
},
}, nil)
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
if len(cheap.requests) == 0 {
t.Fatal("dedicated summarizer never received the summarize call")
}
if len(main.requests) != 0 {
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
}
if factoryCalls != 1 {
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
}
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
t.Fatal("compaction did not shrink the history")
}
found := false
for _, message := range compacted {
if strings.Contains(message.Content, "CHEAP SUMMARY") {
found = true
}
}
if !found {
t.Fatal("cheap summarizer's summary missing from the compacted history")
}
}
// A failing summarizer falls back to the main provider for the SAME slice and
// stays broken for the rest of the run — no repeated failed cheap calls.
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
summary, err := summarize(input)
if err != nil {
t.Fatalf("fallback must succeed via the main provider: %v", err)
}
if summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, want the main provider's", summary)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
}
if !state.summarizerBroken {
t.Fatal("a failed summarizer must be marked broken for the run")
}
// Second summarization: straight to main, no cheap retry.
if _, err := summarize(input); err != nil {
t.Fatal(err)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
}
if len(main.requests) != 2 {
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
}
}
// A factory that errors (misconfigured model) is equivalent to no summarizer:
// main provider used, factory not retried.
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return nil, errors.New("unknown model")
},
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
}
if factoryCalls != 1 {
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("after a factory failure the run must stay on the main provider")
}
if factoryCalls != 1 {
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
}
}
// Tool payloads are clamped in the summarizer transcript: results to
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
bigResult := strings.Repeat("r", 50_000)
bigArgs := strings.Repeat("w", 20_000)
transcript := renderTranscript([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "c1", Name: "write_file", Arguments: bigArgs},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
})
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
}
if !strings.Contains(transcript, "chars omitted for summarization") {
t.Fatal("clamped transcript must carry the omission marker")
}
if !strings.Contains(transcript, "keep going") {
t.Fatal("non-tool content must survive unclamped")
}
// Head AND tail of the clamped payloads survive.
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
}
}
// bigToolHistory returns a history with n large tool results (~40k estimated
// tokens each): the trailing protection window keeps the newest verbatim,
// the older ones are prunable.
func bigToolHistory(n int) []zeroruntime.Message {
bigBody := strings.Repeat("x", 160_000)
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
for i := 0; i < n; i++ {
id := string(rune('a' + i))
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
)
}
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
}
func summaryProvider(text string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
}}
}
// A context-limit error is first answered with the free prune stage; when the
// pruned history fits the window the retry is free (no summarizer call) and
// the one-shot reactive budget is untouched. The paid summarizer only runs
// when a later error finds nothing left to prune.
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
provider := summaryProvider("SUMMARY")
// The protected trailing tool output (~40k tokens) must fit the window.
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("prunable history that fits must produce a free retry")
}
if len(provider.requests) != 0 {
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
}
if state.reactiveAttempted {
t.Fatal("a free prune retry must not consume the reactive budget")
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// Second context-limit error on the pruned history: nothing meaningfully
// prunable remains, so the paid summarizer fires and consumes the budget.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("second recover must compact and retry")
}
if len(provider.requests) == 0 {
t.Fatal("second recover must reach the summarizer")
}
if !state.reactiveAttempted {
t.Fatal("a paid recover must consume the reactive budget")
}
}
// When pruning reclaims something but the pruned history still exceeds the
// window, recover falls through to the paid summarizer in the SAME call
// instead of spending a retry that would fail the same way.
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
provider := summaryProvider("SUMMARY")
// A 1,000-token window: even the protected trailing output overflows it.
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("insufficient prune must still end in a retry via summarization")
}
if len(provider.requests) == 0 {
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
}
if !state.reactiveAttempted {
t.Fatal("the paid summarizer consumes the reactive budget")
}
if estimateTokens(compacted) >= estimateTokens(messages) {
t.Fatal("recover did not shrink the history")
}
}
// The free prune stage is not gated behind the one-shot budget: after a paid
// reactive compaction, a later context-limit error still gets a free retry
// when pruning makes the history fit.
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
provider := summaryProvider("SUMMARY")
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
state.reactiveAttempted = true
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// With nothing left to prune and the budget spent, the caller keeps its
// original error — recover neither loops nor summarizes.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil || retried || len(provider.requests) != 0 {
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
}
}
// The free-retry gate compares against the window the provider enforces, not
// the 70% proactive threshold: a history pruned to ~80% of the window still
// takes the free retry.
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
provider := summaryProvider("SUMMARY")
messages := bigToolHistory(5)
pruned, _ := pruneStaleToolOutput(messages, 2)
prunedTokens := estimateTokens(pruned)
// Choose a window so the pruned size sits between 70% and 95% of it.
window := prunedTokens * 100 / 80
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
if prunedTokens <= state.threshold {
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
}
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
}
}
// After a mid-run model switch the summarizer is re-resolved against the new
// main model: a run that started on the cheap model (no dedicated summarizer)
// and escalated gets a cheap summarizer, and a previously broken one is
// retried for the new model.
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
cheap := summaryProvider("CHEAP SUMMARY")
main := summaryProvider("MAIN SUMMARY")
var factoryModels []string
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Model: "cheap-model",
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
factoryModels = append(factoryModels, mainModelID)
if mainModelID == "cheap-model" {
return nil, nil // main already is the cheap model
}
return cheap, nil
},
}, nil)
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("on the cheap model the main provider must summarize")
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
}
state.switchModel("big-model", 400_000)
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
}
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
}
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
}
// A broken summarizer is retried after the next switch.
state.summarizerBroken = true
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("a broken summarizer must fall back to main")
}
state.switchModel("other-model", 0)
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
}
}
func TestCompactionUpdateWindow(t *testing.T) {
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
state.lowWaterMark = 50_000
before := state.threshold
state.updateWindow(0) // unknown model: unchanged
if state.threshold != before || state.lowWaterMark != 50_000 {
t.Fatal("updateWindow(0) must be a no-op")
}
state.updateWindow(10_000)
if state.threshold >= before {
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
}
if state.lowWaterMark != 0 {
t.Fatal("low-water mark must reset on a window change")
}
if !state.enabled {
t.Fatal("a positive window must keep compaction enabled")
}
}
// End-to-end: after escalate_model switches providers, the loop re-derives the
// context window via ContextWindowFor, visible in the OnContext report.
func TestRunEscalationRederivesContextWindow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "big-model"})
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
}}}
var windows []int
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
ContextWindow: 100_000,
Model: "small-model",
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
ContextWindowFor: func(modelID string) int {
if modelID == "big-model" {
return 400_000
}
return 0
},
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(windows) < 2 {
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
}
if windows[0] != 100_000 {
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
}
if windows[len(windows)-1] != 400_000 {
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
}
}
// nonComparableProvider has a slice field, so comparing two Provider interface
// values holding it would panic; the summarizer path must never compare them.
type nonComparableProvider struct {
inner *mockProvider
tags []string
}
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return p.inner.StreamCompletion(ctx, request)
}
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
}, nil)
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
}
// And with no dedicated summarizer at all the closure still works.
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
t.Fatal(err)
}
}
+1 -4
View File
@@ -126,11 +126,8 @@ func capCompactionBrief(brief string) (string, bool) {
func compactionToolCallLine(call zeroruntime.ToolCall) string {
detail := ""
if call.Freeform && call.Name == "apply_patch" {
detail = strings.Join(patchPaths(call.Arguments), ", ")
}
var arguments map[string]any
if detail == "" && json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
detail = value
+1 -2
View File
@@ -73,7 +73,6 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
{ID: "raw-patch", Name: "apply_patch", Freeform: true, Arguments: "*** Begin Patch\n*** Update File: internal/api.go\n*** Add File: internal/config.go\n*** End Patch\n"},
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
}},
@@ -81,7 +80,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
}
content := projectCompactionInput(messages).messages[0].Content
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "internal/api.go", "internal/config.go", "https://example.com/spec", "Inspect the Windows implementation"} {
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
if !strings.Contains(content, want) {
t.Fatalf("projection missing %q: %q", want, content)
}
-21
View File
@@ -275,27 +275,6 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
if estimateToolDefTokens(one) <= 0 {
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
}
typed := append([]zeroruntime.ToolDefinition(nil), one...)
typed[0].Type = zeroruntime.ToolDefinitionFreeform
if estimateToolDefTokens(typed) <= estimateToolDefTokens(one) {
t.Fatal("a non-empty tool type must increase the estimated request cost")
}
for _, test := range []struct {
name string
format zeroruntime.ToolDefinitionFormat
}{
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: WORD"}},
} {
t.Run("format "+test.name, func(t *testing.T) {
formatted := append([]zeroruntime.ToolDefinition(nil), one...)
formatted[0].Format = &test.format
if estimateToolDefTokens(formatted) <= estimateToolDefTokens(one) {
t.Fatalf("a non-empty format %s must increase the estimated request cost", test.name)
}
})
}
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
Name: "write_file",
Description: "Write contents to a file in the workspace.",
-4
View File
@@ -102,10 +102,6 @@ func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.To
for index, definition := range toolDefs {
copied[index] = definition
copied[index].Parameters = copySchemaMap(definition.Parameters)
if definition.Format != nil {
format := *definition.Format
copied[index].Format = &format
}
}
return copied
}
-3
View File
@@ -15,7 +15,6 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
}
toolDefs := []zeroruntime.ToolDefinition{{
Name: "read_file", Description: "Read a file",
Format: &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: "start: PATH"},
Parameters: map[string]any{
"type": "object",
"required": []string{"path"},
@@ -54,12 +53,10 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
toolDefs[0].Parameters["required"].([]string)[0] = "other"
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
toolDefs[0].Format.Definition = "changed"
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
}
if plan.Request.Tools[0].Name != "read_file" ||
plan.Request.Tools[0].Format == nil || plan.Request.Tools[0].Format.Definition != "start: PATH" ||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
-378
View File
@@ -1,378 +0,0 @@
package agent
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type customApplyPatchTool struct {
calls int
}
type fixedPathScope struct {
roots []string
}
func (scope fixedPathScope) Roots() []string {
return append([]string(nil), scope.roots...)
}
func newFixedPathScope(t *testing.T, roots ...string) fixedPathScope {
t.Helper()
resolved := make([]string, 0, len(roots))
for _, root := range roots {
absolute, err := filepath.Abs(root)
if err != nil {
t.Fatalf("Abs(%q): %v", root, err)
}
if physical, err := filepath.EvalSymlinks(absolute); err == nil {
absolute = physical
}
resolved = append(resolved, absolute)
}
return fixedPathScope{roots: resolved}
}
func (*customApplyPatchTool) Name() string { return "apply_patch" }
func (*customApplyPatchTool) Description() string { return "custom JSON tool" }
func (*customApplyPatchTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"value": {Type: "string"}}}
}
func (*customApplyPatchTool) Safety() tools.Safety {
return tools.Safety{Permission: tools.PermissionAllow, SideEffect: tools.SideEffectRead}
}
func (tool *customApplyPatchTool) Run(context.Context, map[string]any) tools.Result {
tool.calls++
return tools.Result{Status: tools.StatusOK}
}
func TestFreeformApplyPatchUsesExistingToolExecutionPath(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1",
Name: "apply_patch",
Arguments: patch,
Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("freeform apply_patch status = %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatalf("read patched file: %v", err)
}
if string(content) != "hello\n" {
t.Fatalf("patched content = %q, want hello newline", content)
}
}
func TestFreeformApplyPatchSupportsGrantedExtraRoot(t *testing.T) {
root := t.TempDir()
extra := t.TempDir()
scope, err := sandbox.NewScope(root, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
target := filepath.Join(extra, "hello.txt")
patch := "*** Begin Patch\n*** Add File: " + target + "\n+hello\n*** End Patch\n"
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("freeform extra-root apply_patch status = %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read extra-root patched file: %v", err)
}
if string(content) != "hello\n" {
t.Fatalf("patched content = %q, want hello newline", content)
}
}
func TestFreeformApplyPatchRejectsMixedWorkspaceAndExtraRoots(t *testing.T) {
for _, extraHasMatchingPath := range []bool{false, true} {
name := "extra target missing"
if extraHasMatchingPath {
name = "extra target exists"
}
t.Run(name, func(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspace, "src"), 0o755); err != nil {
t.Fatal(err)
}
workspaceTarget := filepath.Join(workspace, "src", "a.go")
if err := os.WriteFile(workspaceTarget, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
extraTarget := filepath.Join(extra, "src", "a.go")
if extraHasMatchingPath {
if err := os.MkdirAll(filepath.Dir(extraTarget), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(extraTarget, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
}
scope, err := sandbox.NewScope(workspace, []string{extra})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
extraCreated := filepath.Join(extra, "b.go")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: src/a.go",
"@@",
"-old",
"+new",
"*** Add File: " + extraCreated,
"+extra",
"*** End Patch",
}, "\n")
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError || !strings.Contains(result.Output, "mixes workspace-relative paths with an extra write root") {
t.Fatalf("mixed-root patch result = %s: %s", result.Status, result.Output)
}
if content, err := os.ReadFile(workspaceTarget); err != nil || string(content) != "old\n" {
t.Fatalf("workspace target changed: content=%q err=%v", content, err)
}
if extraHasMatchingPath {
if content, err := os.ReadFile(extraTarget); err != nil || string(content) != "old\n" {
t.Fatalf("extra-root target changed: content=%q err=%v", content, err)
}
}
if _, err := os.Stat(extraCreated); !os.IsNotExist(err) {
t.Fatalf("mixed-root patch created extra file: %v", err)
}
})
}
}
func TestFreeformApplyPatchAcceptsOneSemanticRoot(t *testing.T) {
for _, test := range []struct {
name string
useExtraRoot bool
}{
{name: "workspace"},
{name: "extra root", useExtraRoot: true},
} {
t.Run(test.name, func(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
scope, err := sandbox.NewScope(workspace, []string{extra})
if err != nil {
t.Fatal(err)
}
root := workspace
if test.useExtraRoot {
root = extra
}
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "src", "a.go")
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
updatePath := target
if !test.useExtraRoot {
updatePath = filepath.Join("src", "a.go")
}
created := filepath.Join(root, "b.go")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: " + updatePath,
"@@",
"-old",
"+new",
"*** Add File: " + created,
"+created",
"*** End Patch",
}, "\n")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("single-root patch status = %s: %s", result.Status, result.Output)
}
if content, err := os.ReadFile(target); err != nil || string(content) != "new\n" {
t.Fatalf("updated content=%q err=%v", content, err)
}
if content, err := os.ReadFile(created); err != nil || string(content) != "created\n" {
t.Fatalf("created content=%q err=%v", content, err)
}
})
}
}
func TestFreeformApplyPatchRejectsAbsolutePathOutsideGrantedRoots(t *testing.T) {
root := t.TempDir()
granted := t.TempDir()
outside := t.TempDir()
scope := newFixedPathScope(t, root, granted)
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
target := filepath.Join(outside, "blocked.txt")
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: root})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("ungranted absolute patch status = %s, want error: %s", result.Status, result.Output)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("ungranted absolute patch created target: %v", err)
}
}
func TestPreparedFreeformPatchRejectsIntermediateSymlinkSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating directory symlinks requires privileges on Windows")
}
root := t.TempDir()
extra := t.TempDir()
outside := t.TempDir()
intermediate := filepath.Join(extra, "nested")
if err := os.Mkdir(intermediate, 0o755); err != nil {
t.Fatalf("Mkdir: %v", err)
}
scope := newFixedPathScope(t, root, extra)
tool := tools.NewScopedApplyPatchTool(root, scope)
target := filepath.Join(intermediate, "blocked.txt")
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
args, err := tools.PrepareFreeformApplyPatchArguments(tool, patch)
if err != nil {
t.Fatalf("PrepareFreeformApplyPatchArguments: %v", err)
}
if err := os.Remove(intermediate); err != nil {
t.Fatalf("Remove intermediate: %v", err)
}
if err := os.Symlink(outside, intermediate); err != nil {
t.Fatalf("Symlink: %v", err)
}
result := tool.Run(context.Background(), args)
if result.Status != tools.StatusError {
t.Fatalf("symlink-swapped patch status = %s, want error: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(outside, "blocked.txt")); !os.IsNotExist(err) {
t.Fatalf("symlink-swapped patch wrote outside root: %v", err)
}
}
func TestUnknownFreeformToolFailsClosed(t *testing.T) {
result, err := executeToolCall(context.Background(), tools.NewRegistry(), zeroruntime.ToolCall{
ID: "call-1", Name: "unknown", Arguments: "raw", Freeform: true,
}, PermissionModeUnsafe, Options{})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("unknown freeform status = %s, want error", result.Status)
}
}
func TestCustomApplyPatchCannotUseFreeformContract(t *testing.T) {
custom := &customApplyPatchTool{}
registry := tools.NewRegistry()
registry.Register(custom)
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: "raw patch", Freeform: true,
}, PermissionModeUnsafe, Options{})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError || !strings.Contains(result.Output, "Unsupported freeform tool call") {
t.Fatalf("custom freeform apply_patch result = %#v, want unsupported error", result)
}
if custom.calls != 0 {
t.Fatalf("custom apply_patch executed %d times", custom.calls)
}
}
func TestDeniedFreeformApplyPatchDoesNotWrite(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
patch := "*** Begin Patch\n*** Add File: denied.txt\n+must not exist\n*** End Patch\n"
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1",
Name: "apply_patch",
Arguments: patch,
Freeform: true,
}, PermissionModeAsk, Options{
Cwd: root,
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "test denial"}, nil
},
})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("denied freeform status = %s, want error", result.Status)
}
if _, err := os.Stat(filepath.Join(root, "denied.txt")); !os.IsNotExist(err) {
t.Fatalf("denied freeform patch changed the workspace: %v", err)
}
}
func TestHistorySafeToolCallsPreservesRawFreeformArguments(t *testing.T) {
raw := "*** Begin Patch\n*** Update File: internal/main.go\n@@\n-old\n+new\n*** End Patch\n"
safe := historySafeToolCalls([]ToolCall{{
ID: "call-1", ProviderCallID: "provider-1", Name: "apply_patch", Arguments: raw, Freeform: true,
}})
if len(safe) != 1 || safe[0].Arguments != raw || safe[0].ProviderCallID != "provider-1" || !safe[0].Freeform {
t.Fatalf("freeform history changed raw call: %#v", safe)
}
}
func TestAbortedToolResultPreservesProviderCallID(t *testing.T) {
messages := appendAbortedToolResults(nil, []ToolCall{{
ID: "call-1", ProviderCallID: "provider-1", Name: "read_file",
}})
if len(messages) != 1 || messages[0].ToolCallID != "call-1" || messages[0].ToolCallProviderID != "provider-1" || !messages[0].IsError {
t.Fatalf("aborted tool result lost provider identity: %#v", messages)
}
}
+9 -8
View File
@@ -33,11 +33,11 @@ const (
// knows before spending more tool turns.
toolOnlyProgressReminderAt = 6
// planReminderTurn and planReminderToolThreshold keep bounded implementation
// work plan-free while still nudging a genuinely sustained run that has not
// established any structured progress. Both thresholds must be met.
planReminderTurn = 7
planReminderToolThreshold = 7
// planReminderTurn is the turn (1-based) by the end of which a multi-step
// task should have called update_plan; if it hasn't, a one-time reminder is
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
// without a spurious planning nag.
planReminderTurn = 3
// planToolName is the planning tool the loop watches for by name.
planToolName = "update_plan"
@@ -591,12 +591,13 @@ func (state *guardState) planReminder(turn int) string {
return planStaleReminder(state.toolCallsSincePlanUpdate)
}
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
// sustained work should have called update_plan. One-shot for the whole run.
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
// called update_plan if it's doing a multi-step task (>=1 other tool call).
// One-shot for the whole run.
if !state.notCalledReminderSent &&
!state.planEverCalled &&
turn >= planReminderTurn &&
state.totalToolCalls >= planReminderToolThreshold {
state.totalToolCalls >= 1 {
state.notCalledReminderSent = true
return planNotCalledReminder()
}
+8 -43
View File
@@ -257,16 +257,19 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
}
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
const expectedThreshold = 7
if planReminderToolThreshold != expectedThreshold {
t.Fatalf("plan reminder tool threshold = %d, want %d", planReminderToolThreshold, expectedThreshold)
}
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // turn 1: other tool call
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`), // turn 2: still no update_plan
toolTurn("call-3", "read_file", `{"path":"notes.txt"}`), // turn 3: reminder fires here
textTurn("done"),
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
@@ -284,44 +287,6 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
}
}
func TestRunDoesNotInjectPlanReminderForBoundedToolSequence(t *testing.T) {
const expectedThreshold = 7
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold-1), textTurn("done"))}
result, err := Run(context.Background(), "go", provider, Options{Registry: registry, MaxTurns: 12})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
if count := countUserMessagesContaining(result.Messages, planNotCalledReminderMarker); count != 0 {
t.Fatalf("bounded tool sequence must not draw a plan reminder, got %d", count)
}
}
func TestPlanReminderWaitsForTurnThresholdAfterEnoughToolCalls(t *testing.T) {
state := guardState{totalToolCalls: 7}
if got := state.planReminder(6); got != "" {
t.Fatalf("planReminder(6) = %q, want no reminder before turn threshold", got)
}
if state.notCalledReminderSent {
t.Fatal("early turn marked the not-called reminder as sent")
}
}
func repeatedReadTurns(count int) [][]zeroruntime.StreamEvent {
turns := make([][]zeroruntime.StreamEvent, 0, count)
for range count {
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
}
return turns
}
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
+21 -68
View File
@@ -40,15 +40,7 @@ const (
toolResultControlSpecReview = "spec_review_required"
)
// ErrPermissionApprovalCanceled ends a run because the caller cancelled a
// permission prompt, rather than because anything failed.
//
// EXPORTED FOR THE SURFACES. A cancel is a normal ending and each surface says
// so its own way — the TUI unwinds, and ACP has a "cancelled" stop reason for
// exactly this. While it was unexported ACP could not distinguish it from a
// fault and reported it as an internal error, so declining a tool looked like
// the agent crashing.
var ErrPermissionApprovalCanceled = errors.New("permission approval cancelled")
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
// isImageRejectionError reports whether err is a provider 400 that rejects
// image/multimodal content. This is checked BEFORE the compaction-retry path
@@ -141,7 +133,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
options.Trace.Counter(trace.CounterInputTokens, int64(usage.InputTokens))
options.Trace.Counter(trace.CounterCachedInputTokens, int64(usage.CachedInputTokens))
options.Trace.Counter(trace.CounterCacheWriteTokens, int64(usage.CacheWriteTokens))
options.Trace.Counter(trace.CounterOutputTokens, int64(usage.OutputTokens))
}
}
@@ -698,12 +689,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
turnRequestedModel = toolResult.RequestedModel
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.ModelOutput(),
ToolCallID: toolResult.ToolCallID,
ToolCallProviderID: call.ProviderCallID,
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
Role: zeroruntime.MessageRoleTool,
Content: toolResult.ModelOutput(),
ToolCallID: toolResult.ToolCallID,
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
})
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
@@ -846,21 +836,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// Re-derive the context window for the new model so compaction and
// the OnContext budget report track the model actually in force —
// without this a switch to a smaller-window model can overflow the
// target, and a switch to a larger one over-compacts. An unknown
// model (<= 0) keeps the original window. The compactor also
// re-resolves its dedicated summarizer against the new model.
window := 0
if options.ContextWindowFor != nil {
window = options.ContextWindowFor(turnRequestedModel)
}
if window > 0 {
options.ContextWindow = window
planner.config.contextWindow = window
}
compactor.switchModel(turnRequestedModel, window)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs the switcher to also
// report the new window — out of scope for this change.
}
}
}
@@ -1047,9 +1027,6 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
safe := make([]ToolCall, len(calls))
for i, call := range calls {
safe[i] = call
if call.Freeform {
continue
}
args := strings.TrimSpace(call.Arguments)
switch {
case args == "":
@@ -1133,28 +1110,8 @@ func decodeToolArguments(arguments string, v any) error {
}
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
tool, toolFound := registry.Get(call.Name)
args := map[string]any{}
if call.Freeform {
if !toolFound || !tools.IsBuiltInApplyPatch(tool) {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Unsupported freeform tool call for " + call.Name + ".",
}, nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Invalid freeform apply_patch input: " + err.Error(),
}, nil
}
args = prepared
} else if call.Arguments != "" {
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return ToolResult{
ToolCallID: call.ID,
@@ -1182,6 +1139,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
DenialReason: DenialFiltered,
}, nil
}
tool, toolFound := registry.Get(call.Name)
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
modeName := string(permissionMode)
return ToolResult{
@@ -1406,7 +1364,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, decisionReason)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, decisionReason)
return deniedPermissionResult(call, decisionReason, requestEvent), nil
@@ -1590,7 +1548,7 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
@@ -1638,7 +1596,7 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
@@ -3244,15 +3202,11 @@ func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition,
// runtimeToolDefinition renders a tool's advertised definition (name, description,
// JSON-schema parameters) as sent to the provider.
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
definition := zeroruntime.ToolDefinition{
return zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
}
if tools.IsBuiltInApplyPatch(tool) {
definition.Type = zeroruntime.ToolDefinitionFreeform
}
return definition
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
@@ -3407,11 +3361,10 @@ func loadedToolsFromResult(meta map[string]string) []string {
func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Message {
for _, call := range remaining {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: abortedToolResultNotice,
ToolCallID: call.ID,
ToolCallProviderID: call.ProviderCallID,
IsError: true,
Role: zeroruntime.MessageRoleTool,
Content: abortedToolResultNotice,
ToolCallID: call.ID,
IsError: true,
})
}
return messages
+2 -97
View File
@@ -1853,7 +1853,7 @@ func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
},
})
if !errors.Is(err, ErrPermissionApprovalCanceled) {
if !errors.Is(err, errPermissionApprovalCanceled) {
t.Fatalf("expected permission approval cancel error, got %v", err)
}
if result.FinalAnswer != "" {
@@ -4096,7 +4096,7 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
// caller's callback AND stamps token counters, and the run completes.
func TestRunTracingWrapperStampsUsage(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 10, CompletionTokens: 40}},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
@@ -4135,9 +4135,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
t.Fatalf("cached input token counter = %d, want 20", got)
}
if got := tr.Counter(trace.CounterCacheWriteTokens); got != 10 {
t.Fatalf("cache-write token counter = %d, want 10", got)
}
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
t.Fatalf("output token counter = %d, want 40", got)
}
@@ -4343,95 +4340,3 @@ func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
t.Fatalf("expected tool result to mention the beforeTool veto, got %q", combined)
}
}
// A cancelled permission on either sandbox-retry path must abort the run with a
// recognisable cancellation, and must not run the escalated retry.
//
// BOTH PATHS, because both wrap ErrPermissionApprovalCanceled and each is
// reached by a different kind of denial: one by an unrestricted platform denial
// asking for approval, the other by an external-network denial. Surfaces map
// this sentinel onto their own idea of a cancel — ACP returns stopReason
// "cancelled" for it — so a path that produced a bare error instead would show
// the user a crash where they had simply declined.
func TestCancellingASandboxRetryAbortsWithoutRetrying(t *testing.T) {
denial := func(capability execution.CapabilityKind, scope string) tools.Result {
return tools.Result{
Status: tools.StatusError,
ExecutionOutcome: &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: capability, Scope: scope},
Source: execution.DenialSourcePlatformSandbox,
Reason: "denied by the sandbox",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
},
},
}
}
for _, testCase := range []struct {
name string
result tools.Result
run func(context.Context, *tools.Registry, ToolCall, tools.Tool, map[string]any, tools.Result, Options) error
}{
{
name: "unsandboxed retry",
result: denial(execution.CapabilityUnrestricted, "host"),
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
_, _, _, _, _, _, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
return abortErr
},
},
{
name: "network retry",
result: denial(execution.CapabilityExternalNetwork, ""),
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
_, _, _, _, _, abortErr := maybeRetryWithNetworkAfterSandboxDenial(
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
return abortErr
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
retryTool := &sandboxNamespaceLimitedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
asked := 0
options := Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: sandbox.DefaultPolicy(),
}),
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
asked++
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "client cancelled"}, nil
},
}
call := ToolCall{ID: "call-1", Name: "bash", Arguments: `{"command":"curl example.com"}`}
args := map[string]any{"command": "curl example.com"}
abortErr := testCase.run(context.Background(), registry, call, retryTool, args, testCase.result, options)
if asked != 1 {
t.Fatalf("permission was requested %d times, want exactly 1", asked)
}
if !errors.Is(abortErr, ErrPermissionApprovalCanceled) {
t.Fatalf("abort error = %v, want it to match ErrPermissionApprovalCanceled", abortErr)
}
// The escalated retry is the thing the permission was gating. A
// cancelled prompt that still ran it would have run the command the
// user just declined.
if len(retryTool.calls) != 0 {
t.Fatalf("the escalated retry ran %d times after a cancel: %+v", len(retryTool.calls), retryTool.calls)
}
})
}
}
-13
View File
@@ -31,19 +31,6 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
return tools.Result{Status: tools.StatusOK}
}
func TestRuntimeToolDefinitionMarksOnlyBuiltInApplyPatchFreeform(t *testing.T) {
builtIn := runtimeToolDefinition(tools.NewScopedApplyPatchTool(t.TempDir(), nil))
if builtIn.Type != zeroruntime.ToolDefinitionFreeform {
t.Fatalf("built-in apply_patch type = %q, want freeform", builtIn.Type)
}
calls := 0
custom := runtimeToolDefinition(countingSchemaTool{name: "apply_patch", calls: &calls})
if custom.Type != "" {
t.Fatalf("custom apply_patch type = %q, want legacy function shape", custom.Type)
}
}
// The cache renders a tool's schema once and reuses it across calls, and its
// output is identical to the uncached path.
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
+4 -4
View File
@@ -68,7 +68,7 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
}
}
func TestAgentAdvertisesBothEditTools(t *testing.T) {
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
@@ -79,9 +79,9 @@ func TestAgentAdvertisesBothEditTools(t *testing.T) {
names[definition.Name] = true
}
if !names["apply_patch"] {
t.Fatal("agent must retain apply_patch for multi-hunk changes")
t.Fatal("agent must retain apply_patch for existing-file changes")
}
if !names["edit_file"] {
t.Fatal("agent must receive edit_file for targeted exact replacements")
if names["edit_file"] {
t.Fatal("agent must not receive the ambiguous string-replacement tool")
}
}
-6
View File
@@ -58,18 +58,12 @@ func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSu
for _, def := range exposed {
writeLengthPrefixed(&toolsSB, def.Name)
writeLengthPrefixed(&toolsSB, def.Description)
writeLengthPrefixed(&toolsSB, string(def.Type))
// Parameters is rendered to a canonical JSON string per tool. The
// schema-render cache in internal/agent (used by partitionToolsCached)
// guarantees the same render is byte-identical across turns for the
// same tool, so this substring is a stable hash input.
writeLengthPrefixed(&schemaSB, def.Name)
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
if def.Format != nil {
writeLengthPrefixed(&schemaSB, def.Format.Type)
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
writeLengthPrefixed(&schemaSB, def.Format.Definition)
}
}
return toolsSB.String(), schemaSB.String()
}
-32
View File
@@ -73,38 +73,6 @@ func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
}
}
func TestComputePrefixFingerprintIncludesToolTypeAndFormat(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
base := zeroruntime.ToolDefinition{Name: "apply_patch", Description: "Patch files"}
baseFingerprint := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{base})
typed := base
typed.Type = zeroruntime.ToolDefinitionFreeform
if got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{typed}); got.ToolsHash == baseFingerprint.ToolsHash {
t.Fatal("ToolsHash must change when tool Type changes")
}
formatCases := []struct {
name string
format zeroruntime.ToolDefinitionFormat
}{
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: PATCH"}},
{name: "empty", format: zeroruntime.ToolDefinitionFormat{}},
}
for _, test := range formatCases {
t.Run(test.name, func(t *testing.T) {
withFormat := base
withFormat.Format = &test.format
got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{withFormat})
if got.SchemaHash == baseFingerprint.SchemaHash {
t.Fatalf("SchemaHash must change for %s format input", test.name)
}
})
}
}
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
// model addendum change) must move the base-instructions hash, which moves
+24 -32
View File
@@ -30,10 +30,13 @@ work.
read-before-edit discipline: inspect the target file and nearby callers,
tests, or config before you modify behavior. Never edit a file you have not
read.
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
work. Skip it for bounded changes in one component. Update only when the
overall phase changes, keep at most one item in_progress, and never create a
plan after the work is complete.
2. **Plan.** Use update_plan for implementation or investigation that has at
least three meaningful dependent steps, then keep it current at milestones.
Mark completed work and the next in-progress step together when practical;
do not spend separate turns updating it after every file or command. Keep at
most one item in_progress. Skip plans for simple lookups, explanations, code
navigation, and other short tasks. Never create a plan after the work is
already complete merely to describe what you did.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
@@ -49,48 +52,38 @@ work.
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
edit_file, apply_patch, write_file - over shelling out to
write_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Use read_minified_file only to explore large or unfamiliar source. Use
read_file for small or likely edit targets and exact text, comments, or line
numbers. Do not read both unless a new need for exact content appears.
- Prefer read_minified_file when initially exploring source code; it preserves
code while removing comments and redundant whitespace. Use read_file when
exact text, comments, or line numbers are needed.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- For edits to existing files, use edit_file for a targeted change (old_string
must match the file exactly and be unique, so include a few surrounding
lines) and apply_patch when one coherent change spans several hunks or
files. Use write_file only to create a file or when most of it changes; do
not rewrite a whole file to change a few lines. Match the existing
indentation, imports, and idioms. Match the file's comment density: do not
add explanatory comments unless the user asks or the code is already
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
and enough unchanged context to identify the intended location. Match the
existing indentation, imports, and idioms. Match the file's comment density:
do not add explanatory comments unless the user asks or the code is already
comment-dense.
- A successful edit result already confirms the change; do not re-read a file
just to verify an edit that succeeded. If an edit fails, read the error, fix
the old_string or hunk, and retry the same tool rather than switching to a
full rewrite.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
over-built; if a 200-line solution could be 50, rewrite it. Once the required
behavior passes validation, stop instead of adding optional cleanup.
over-built; if a 200-line solution could be 50, rewrite it.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
## Testing gate (mandatory)
- After code changes, run the project's documented appropriate validators.
Scope iteration checks, combine compatible commands when diagnostics stay
useful, and run the final set once after the last edit. Rerun only after a
change or failure.
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
do not invent a parallel flow.
- After any change to code, verify after edits by running the project's
validators before you summarize or commit: tests, type-checks, linters, and/or
the build, as appropriate. Scope them to the change while iterating; reserve
full-suite runs for milestones.
- If you are unsure which validators apply, search the repo (Makefile, package
manifests, CI config) to find them.
- Never claim a task is done, and never commit, while validators are failing. If
they fail, fix the cause and rerun; do not paper over it. If you could not run
a validator, say so explicitly rather than implying success.
- A successful native patch confirms the edit. Reread only when the next change
needs exact content or the result was ambiguous.
## Tool use
@@ -112,7 +105,7 @@ work.
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use edit_file/apply_patch/write_file for file changes.
with exec_command, or use write_file/apply_patch for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
@@ -148,7 +141,6 @@ work.
- Default to concise, skimmable output. Lead with the answer or the result.
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
code blocks for code, and `inline code` for file paths, commands, symbols, and
short snippets. Reference code with file paths and include line numbers when
already known; do not look them up solely for the final summary.
short snippets. Reference code as `file:line` so it is clickable.
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
say so; when something is done and verified, state it plainly without hedging.
+3 -6
View File
@@ -60,17 +60,14 @@ const openAIPromptAddendum = `<model_guidance>
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
file work. Use them directly; never run apply_patch through exec_command.
read_file, edit_file and write_file take one file per call, while one coherent
apply_patch may span several files. Keep independent native calls in the same
turn, and do not batch file writes into a script.
write_file, apply_patch) over shelling out to cat/sed/awk/python for
file work. Make one tool call per file; do not batch file writes into a script.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
const geminiPromptAddendum = `<model_guidance>
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
@@ -29,9 +29,6 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
if got := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{Model: "gpt-5"}))), " "); !strings.Contains(got, "never run apply_patch through exec_command") {
t.Fatalf("OpenAI prompt must route edits through the native patch tool:\n%s", got)
}
// Claude is aligned with the core prompt and gets no family addendum now that
// comment discipline is universal; it must not pick up another family's block.
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
+2 -12
View File
@@ -29,17 +29,14 @@ func TestMain(m *testing.M) {
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
if strings.Contains(prompt, "clickable file paths") {
t.Fatalf("system prompt must not claim inline-code file paths are clickable: %s", prompt)
}
prompt := strings.ToLower(buildSystemPrompt(Options{}))
for _, want := range []string{
"read-before-edit",
"inspect the target file",
"plan then act",
"choose the narrowest tool",
"for edits to existing files, use edit_file",
"for edits to existing files, prefer apply_patch",
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
@@ -47,13 +44,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"do not recognize",
"scaled to the work",
"comment density",
"skip it for bounded changes in one component",
"do not read both unless a new need for exact content appears",
"combine compatible commands when diagnostics stay useful",
"do not invent a parallel flow",
"a successful native patch confirms the edit",
"once the required behavior passes validation, stop",
"do not look them up solely for the final summary",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
+4 -20
View File
@@ -348,26 +348,10 @@ type Options struct {
// CompactionPreserveLast is how many trailing messages compaction keeps
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
CompactionPreserveLast int
// Summarizer, when set, lazily builds the provider used for compaction
// summarization calls — typically a cheap/fast model, since summaries at
// main-model prices are the single most expensive recurring event in a
// long run. It receives the main model currently in force and may return
// (nil, nil) when no dedicated summarizer applies to it (the main model is
// already the cheap one). Built on the first paid compaction and again
// after a mid-run model switch. Any failure (build or call) falls back to
// the run's main provider until the next switch, so a misconfigured
// summarizer can never break compaction. nil keeps today's behavior.
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
// ContextWindowFor, when set, resolves a model ID to its context window so
// the compactor can re-derive its threshold after a mid-run model switch
// (escalate_model). Without it a switch keeps compacting against the
// original model's window — overflowing a smaller target or over-compacting
// a larger one. Return <= 0 when the model is unknown (window unchanged).
ContextWindowFor func(modelID string) int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// FileTracker records per-session file read/write versions so the write tools
// can detect a file changed on disk outside Zero since it was last read. nil
// disables the check. Created once per session and threaded into every tool run.
+13 -50
View File
@@ -839,26 +839,24 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
mcpTokenStore = nil
err = nil
}
criticalMCPConfig, optionalMCPConfig := splitMCPStartupConfig(mcpConfig)
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
if len(criticalMCPConfig.Servers) > 0 {
runtime, registerErr := deps.registerMCPTools(context.Background(), registry, criticalMCPConfig, mcp.RegisterOptions{
if len(mcpConfig.Servers) > 0 {
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
Execution: executionRunner,
WorkspaceRoot: workspaceRoot,
})
if registerErr != nil {
closeMCPRuntime(stderr, runtime)
return writeAppError(stderr, redaction.ErrorMessage(registerErr, redaction.Options{}), 1)
}
mcpRuntime = runtime
}
if err != nil {
closeMCPRuntime(stderr, mcpRuntime)
return writeAppError(stderr, err.Error(), 1)
}
defer closeMCPRuntime(stderr, mcpRuntime)
// A server that could not be reached or validated is skipped, not fatal (one
// bad MCP server must not abort startup) — surface each so a missing tool set is
// explained rather than silently absent. A built-in default the user never
// configured (e.g. keyless Exa with no credentials) is the exception: it
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
// was never asked for, so its failure is not worth a startup warning — only
// servers the user actually configured warn on failure (issue #552).
for _, skipped := range mcpRuntime.Skipped() {
@@ -911,35 +909,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
// Activate deferred MCP-tool loading for the interactive run only when the
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
// This first pass covers every prompt-critical tool. Optional built-in MCP
// defaults re-run the same gate after publishing their atomic batch, so a
// later catalog generation cannot miss its loader. The interactive surface
// applies no operator tool filters, so enabled/disabled are nil — matching the
// AgentOptions below.
// The registry is complete (core + specialist + MCP + plugins) here, so the
// count is accurate; below threshold this is a no-op and the surface is
// unchanged. The interactive surface applies no operator tool filters, so
// enabled/disabled are nil — matching the AgentOptions below.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
var optionalMCPRuntime *optionalMCPStartup
var awaitToolReadiness func(context.Context)
if len(optionalMCPConfig.Servers) > 0 {
optionalMCPRuntime = startOptionalMCP(
context.Background(),
registry,
optionalMCPConfig,
mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
Execution: executionRunner,
WorkspaceRoot: workspaceRoot,
},
deps.registerMCPTools,
func() {
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
},
)
defer closeMCPRuntime(stderr, optionalMCPRuntime)
awaitToolReadiness = func(ctx context.Context) {
optionalMCPRuntime.Await(ctx, optionalMCPPromptGrace)
}
}
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
@@ -974,20 +948,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
CompactionModel: resolved.Preferences.CompactionModel,
Provider: provider,
NewProvider: deps.newProvider,
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
if provider == nil {
return nil
}
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
return optimized
}
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
},
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
PrepareRunCompletionWarning: func() {
scratchBaseline = scratchFileSnapshot(workspaceRoot)
},
@@ -995,7 +959,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return scratchFileWarning(workspaceRoot, scratchBaseline)
},
Registry: registry,
AwaitToolReadiness: awaitToolReadiness,
SessionStore: sessionStore,
PeerService: peerService,
SandboxStore: sandboxStore,
+4 -80
View File
@@ -17,7 +17,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/update"
@@ -212,12 +211,12 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"exa": config.DefaultMCPServers()["exa"],
"firecrawl": config.DefaultMCPServers()["firecrawl"],
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "exa", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
@@ -228,7 +227,7 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stderr.String(), "exa") {
if strings.Contains(stderr.String(), "firecrawl") {
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
}
}
@@ -487,74 +486,6 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
}
}
func TestRunNoArgsPaintsBeforeOptionalDefaultMCPIsReady(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
if err != nil {
t.Fatalf("NewPermissionStore() error = %v", err)
}
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
started := make(chan struct{})
release := make(chan struct{})
configResult := make(chan error, 1)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
userConfigPath: func() (string, error) { return userConfigPath, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: config.DefaultMCPServers()}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return permissionStore, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return tokenStore, nil },
registerMCPTools: func(_ context.Context, registry *tools.Registry, cfg config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
var configErr error
if _, ok := cfg.Servers["exa"]; !ok || len(cfg.Servers) != 1 {
configErr = fmt.Errorf("optional MCP config = %#v, want only exa", cfg.Servers)
}
configResult <- configErr
close(started)
<-release
registry.Register(cliFakeMCPRegistryTool{})
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("optional MCP initialization did not start")
}
if options.AwaitToolReadiness == nil {
t.Fatal("TUI did not receive optional tool readiness barrier")
}
if _, ok := options.Registry.Get("mcp_docs_lookup"); ok {
t.Fatal("optional MCP tool was published before registration completed")
}
close(release)
options.AwaitToolReadiness(ctx)
if _, ok := options.Registry.Get("mcp_docs_lookup"); !ok {
t.Fatal("optional MCP tool was not visible after readiness completed")
}
return 0
},
})
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if configErr := <-configResult; configErr != nil {
t.Fatal(configErr)
}
}
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -634,7 +565,6 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
t.Fatalf("NewTokenStore() error = %v", err)
}
runtimeClosed := false
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
@@ -661,7 +591,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
return closeFunc(func() error {
runtimeClosed = true
return nil
}), fmt.Errorf("register mcp tools failed: %s", secret)
}), errors.New("register mcp tools failed")
},
runTUI: func(ctx context.Context, options tui.Options) int {
t.Fatal("TUI should not launch after MCP registration fails")
@@ -675,12 +605,6 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
if !strings.Contains(stderr.String(), "register mcp tools failed") {
t.Fatalf("stderr missing registration error: %s", stderr.String())
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("stderr leaked MCP registration secret: %s", stderr.String())
}
if !strings.Contains(stderr.String(), redaction.RedactedSecret) {
t.Fatalf("stderr missing redaction marker: %s", stderr.String())
}
if !runtimeClosed {
t.Fatal("partial MCP runtime was not closed after registration error")
}
+10 -17
View File
@@ -682,20 +682,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
ModelSessionSwitcher: modelSessionSwitcher,
Summarizer: summarizerFactory(resolved, deps.newProvider),
ContextWindowFor: func(modelID string) int {
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
},
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
@@ -943,9 +939,6 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
if deferThreshold <= 0 {
return
}
if _, registered := registry.Get(tools.ToolSearchToolName); registered {
return
}
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
return
}
+9 -15
View File
@@ -267,29 +267,23 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
func (writer *execEventWriter) usage(usage agent.Usage) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
"type": "usage",
"prompt_tokens": usage.PromptTokens,
"completion_tokens": usage.CompletionTokens,
"cached_input_tokens": usage.CachedInputTokens,
"cache_write_tokens": usage.CacheWriteTokens,
"total_tokens": usage.TotalTokens(),
"type": "usage",
"prompt_tokens": usage.PromptTokens,
"completion_tokens": usage.CompletionTokens,
"total_tokens": usage.TotalTokens(),
})
return
}
if writer.format == execOutputStreamJSON {
promptTokens := usage.EffectiveInputTokens()
completionTokens := usage.EffectiveOutputTokens()
cachedInputTokens := usage.CachedInputTokens
cacheWriteTokens := usage.CacheWriteTokens
totalTokens := usage.TotalTokens()
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventUsage,
RunID: writer.runID,
PromptTokens: &promptTokens,
CompletionTokens: &completionTokens,
CachedInputTokens: &cachedInputTokens,
CacheWriteTokens: &cacheWriteTokens,
TotalTokens: &totalTokens,
Type: streamjson.EventUsage,
RunID: writer.runID,
PromptTokens: &promptTokens,
CompletionTokens: &completionTokens,
TotalTokens: &totalTokens,
})
}
}
-36
View File
@@ -52,39 +52,3 @@ func TestExecWriterPropagatesToolResultTruncation(t *testing.T) {
})
}
}
func TestExecWriterPropagatesCacheUsage(t *testing.T) {
for _, format := range []execOutputFormat{execOutputJSON, execOutputStreamJSON} {
t.Run(string(format), func(t *testing.T) {
var stdout bytes.Buffer
writer := execEventWriter{
stdout: &stdout,
format: format,
runID: "run_usage",
streamedText: &strings.Builder{},
}
writer.usage(agent.Usage{
InputTokens: 100,
OutputTokens: 20,
PromptTokens: 100,
CompletionTokens: 20,
CachedInputTokens: 60,
CacheWriteTokens: 10,
})
if writer.err != nil {
t.Fatalf("usage: %v", writer.err)
}
var payload map[string]any
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil {
t.Fatalf("decode output %q: %v", stdout.String(), err)
}
cachedKey, writeKey := "cached_input_tokens", "cache_write_tokens"
if format == execOutputStreamJSON {
cachedKey, writeKey = "cachedInputTokens", "cacheWriteTokens"
}
if payload[cachedKey] != float64(60) || payload[writeKey] != float64(10) {
t.Fatalf("cache usage missing from payload: %#v", payload)
}
})
}
}
+8 -8
View File
@@ -9,15 +9,15 @@ import (
"github.com/Gitlawb/zero/internal/config"
)
// `zero mcp disable exa` must work even though exa is a built-in
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
// default that is not written to the user's config file until overridden.
func TestRunMCPDisableSeededExaDefault(t *testing.T) {
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
// A config with no Exa entry — the default lives in code, not the file.
// A config with no firecrawl entry — the default lives in code, not the file.
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
var out, errBuf bytes.Buffer
code := runWithDeps([]string{"mcp", "disable", "exa", "--json"}, &out, &errBuf, appDeps{
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
})
if code != exitSuccess {
@@ -31,8 +31,8 @@ func TestRunMCPDisableSeededExaDefault(t *testing.T) {
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
}
if payload.ServerName != "exa" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want exa disabled+changed", payload)
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
}
// End-to-end: resolving that config now turns the default off.
@@ -40,7 +40,7 @@ func TestRunMCPDisableSeededExaDefault(t *testing.T) {
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
if !cfg.Servers["firecrawl"].Disabled {
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
}
}
-147
View File
@@ -1,147 +0,0 @@
package cli
import (
"context"
"sync"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
)
const (
optionalMCPPromptGrace = time.Second
optionalMCPCloseGrace = 250 * time.Millisecond
)
// splitMCPStartupConfig keeps explicitly configured servers on the
// prompt-critical path. Only unchanged built-in defaults may initialize after
// first paint; their failure was already intentionally silent.
func splitMCPStartupConfig(cfg config.MCPConfig) (critical config.MCPConfig, optional config.MCPConfig) {
critical.Servers = map[string]config.MCPServerConfig{}
optional.Servers = map[string]config.MCPServerConfig{}
for name, server := range cfg.Servers {
if server.Disabled {
continue
}
if config.IsUnconfiguredDefault(name, server) {
optional.Servers[name] = server
continue
}
critical.Servers[name] = server
}
return critical, optional
}
type optionalMCPStartup struct {
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
runtime mcpToolRuntime
closeOnce sync.Once
runtimeCloseOnce sync.Once
closeErr error
}
func startOptionalMCP(
parent context.Context,
registry *tools.Registry,
cfg config.MCPConfig,
options mcp.RegisterOptions,
register func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error),
onReady func(),
) *optionalMCPStartup {
ctx, cancel := context.WithCancel(parent)
startup := &optionalMCPStartup{cancel: cancel, done: make(chan struct{})}
go func() {
runtime, err := register(ctx, registry, cfg, options)
if err == nil && onReady != nil {
onReady()
}
startup.mu.Lock()
startup.runtime = runtime
startup.mu.Unlock()
close(startup.done)
}()
return startup
}
// Await gives optional tools one bounded opportunity to join the next turn's
// immutable registry snapshot. A timeout leaves this turn on the existing core
// tool set; a later turn observes the completed generation.
func (startup *optionalMCPStartup) Await(ctx context.Context, timeout time.Duration) bool {
if startup == nil {
return true
}
select {
case <-startup.done:
return true
default:
}
if timeout <= 0 {
select {
case <-startup.done:
return true
case <-ctx.Done():
return false
}
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-startup.done:
return true
case <-timer.C:
return false
case <-ctx.Done():
return false
}
}
func (startup *optionalMCPStartup) Close() error {
if startup == nil {
return nil
}
startup.closeOnce.Do(func() {
startup.cancel()
reaped := make(chan struct{})
go func() {
defer close(reaped)
<-startup.done
startup.closeRuntime()
}()
timer := time.NewTimer(optionalMCPCloseGrace)
defer timer.Stop()
select {
case <-reaped:
case <-timer.C:
}
})
startup.mu.Lock()
defer startup.mu.Unlock()
return startup.closeErr
}
func (startup *optionalMCPStartup) closeRuntime() {
startup.runtimeCloseOnce.Do(func() {
startup.mu.Lock()
runtime := startup.runtime
startup.mu.Unlock()
if runtime == nil {
return
}
err := runtime.Close()
startup.mu.Lock()
if err != nil && startup.closeErr == nil {
startup.closeErr = err
}
startup.mu.Unlock()
})
}
func (startup *optionalMCPStartup) Skipped() []mcp.SkippedServer {
return nil
}
-187
View File
@@ -1,187 +0,0 @@
package cli
import (
"context"
"errors"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
)
type closeTrackingMCPRuntime struct {
closed chan struct{}
err error
}
func (runtime *closeTrackingMCPRuntime) Close() error {
close(runtime.closed)
return runtime.err
}
func (*closeTrackingMCPRuntime) Skipped() []mcp.SkippedServer { return nil }
func TestSplitMCPStartupConfigDefersOnlyUnconfiguredDefaults(t *testing.T) {
defaults := config.DefaultMCPServers()
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"exa": defaults["exa"],
"docs": {Type: "http", URL: "https://docs.example.test/mcp"},
"disabled": {Type: "http", URL: "https://disabled.example.test/mcp", Disabled: true},
}}
critical, optional := splitMCPStartupConfig(cfg)
if _, ok := critical.Servers["docs"]; !ok || len(critical.Servers) != 1 {
t.Fatalf("critical servers = %#v, want only docs", critical.Servers)
}
if _, ok := optional.Servers["exa"]; !ok || len(optional.Servers) != 1 {
t.Fatalf("optional servers = %#v, want only exa", optional.Servers)
}
}
func TestOptionalMCPAwaitTimesOutThenObservesPublishedTools(t *testing.T) {
registry := tools.NewRegistry()
release := make(chan struct{})
started := make(chan struct{})
startup := startOptionalMCP(
context.Background(),
registry,
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(ctx context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
close(started)
select {
case <-release:
registry.RegisterBatch([]tools.Tool{tools.NewScopedReadFileTool(t.TempDir(), nil)})
return noopMCPRuntime{}, nil
case <-ctx.Done():
return noopMCPRuntime{}, ctx.Err()
}
},
nil,
)
defer func() { _ = startup.Close() }()
<-started
if startup.Await(t.Context(), time.Millisecond) {
t.Fatal("optional startup unexpectedly completed before release")
}
if got := len(registry.All()); got != 0 {
t.Fatalf("registry published tools before readiness: %d", got)
}
close(release)
if !startup.Await(t.Context(), time.Second) {
t.Fatal("optional startup did not complete after release")
}
if _, ok := registry.Get("read_file"); !ok {
t.Fatal("completed optional startup did not publish its tool batch")
}
}
func TestOptionalMCPFailedRegistrationSkipsReadinessCallback(t *testing.T) {
readyCalls := 0
startup := startOptionalMCP(
context.Background(),
tools.NewRegistry(),
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, errors.New("connect failed")
},
func() { readyCalls++ },
)
defer func() { _ = startup.Close() }()
if !startup.Await(t.Context(), time.Second) {
t.Fatal("failed optional startup did not settle")
}
if readyCalls != 0 {
t.Fatalf("readiness callback ran after failed registration: %d", readyCalls)
}
}
func TestOptionalMCPCloseCancelsBlockedStartup(t *testing.T) {
started := make(chan struct{})
startup := startOptionalMCP(
context.Background(),
tools.NewRegistry(),
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(ctx context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
close(started)
<-ctx.Done()
return noopMCPRuntime{}, nil
},
nil,
)
<-started
if err := startup.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestOptionalMCPCloseReturnsRuntimeErrorWithinGrace(t *testing.T) {
closeErr := errors.New("close failed")
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{}), err: closeErr}
startup := startOptionalMCP(
context.Background(),
tools.NewRegistry(),
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return runtime, nil
},
nil,
)
if !startup.Await(t.Context(), time.Second) {
t.Fatal("optional startup did not settle")
}
if err := startup.Close(); !errors.Is(err, closeErr) {
t.Fatalf("Close() error = %v, want %v", err, closeErr)
}
}
func TestOptionalMCPCloseBoundsRegistrationThatIgnoresCancellation(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
released := false
defer func() {
if !released {
close(release)
}
}()
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{})}
startup := startOptionalMCP(
context.Background(),
tools.NewRegistry(),
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
close(started)
<-release
return runtime, nil
},
nil,
)
<-started
closeResult := make(chan error, 1)
go func() { closeResult <- startup.Close() }()
select {
case err := <-closeResult:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close blocked on registration after cancellation")
}
close(release)
released = true
select {
case <-runtime.closed:
case <-time.After(time.Second):
t.Fatal("late optional MCP runtime was not closed")
}
}
-17
View File
@@ -1,17 +0,0 @@
package cli
import (
"context"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// summarizerFactory adapts the resolved profile and the authenticated provider
// builder into agent.Options.Summarizer; the selection rules live in
// providers.CompactionSummarizerFactory, shared with the TUI.
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
}
+13 -98
View File
@@ -5,20 +5,22 @@ import (
"strings"
)
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
// web search and page fetching work out of the box with no setup and no API
// key. They are seeded before user/project config is merged (see ResolveMCP),
// so a user can override any field — for example add an API-key header to lift
// Exa's anonymous rate limit — or disable it entirely with
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
// search and scraping work out of the box with no setup and no API key. They are
// seeded before user/project config is merged (see ResolveMCP), so a user can
// override any field — for example point firecrawl at a self-hosted instance, or
// add an API-key header to lift the free-tier limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
// Exa API key for higher limits.
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
// use. Zero only calls it over the network, so Firecrawl's license never reaches
// into Zero's own code.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"exa": {
"firecrawl": {
Type: "http",
URL: "https://mcp.exa.ai/mcp",
URL: "https://mcp.firecrawl.dev/v2/mcp",
},
}
}
@@ -33,13 +35,13 @@ func IsDefaultMCPServer(name string) bool {
// IsUnconfiguredDefault reports whether server is one of Zero's built-in
// defaults that the user never wrote an entry for in their config — i.e. it is
// running with whatever Zero ships (e.g. keyless Exa, no credentials).
// running with whatever Zero ships (e.g. keyless Firecrawl, no credentials).
//
// Both conditions below must hold:
// - !server.configured: the user's JSON never declared an object for this
// server key at all (set by MCPServerConfig.UnmarshalJSON only when it
// actually ran for this key). Any explicit action — including a
// disable/enable toggle like `zero mcp enable exa` that leaves the
// disable/enable toggle like `zero mcp enable firecrawl` that leaves the
// resolved value unchanged — sets configured, so it always counts as
// user-configured, even though the value comparison below could not tell
// the difference on its own.
@@ -56,90 +58,3 @@ func IsUnconfiguredDefault(name string, server MCPServerConfig) bool {
def, ok := DefaultMCPServers()[strings.TrimSpace(name)]
return ok && !server.configured && reflect.DeepEqual(def, server)
}
// retiredDefaultMCPServer describes a built-in default Zero no longer ships.
type retiredDefaultMCPServer struct {
// successor is the default that replaced it.
successor string
// shipped is the value Zero used to seed for this name. A user entry
// written while the default was still shipped may name only an override
// (a header, say) and rely on these fields for the rest, so they have to
// outlive the default itself.
shipped MCPServerConfig
}
// retiredDefaultMCPServers maps a retired built-in default to what replaced it
// and to the value Zero used to ship for it.
var retiredDefaultMCPServers = map[string]retiredDefaultMCPServer{
"firecrawl": {
successor: "exa",
shipped: MCPServerConfig{
Type: "http",
URL: "https://mcp.firecrawl.dev/v2/mcp",
},
},
}
// migrateRetiredDefaultMCPServers keeps an upgrade across a default-provider
// swap from changing what the user's config means. It handles the two ways a
// rename can break a config written against the old default:
//
// - A user who ran `zero mcp disable <old default>` made an explicit choice
// not to open that outbound connection. Without carrying that decision to
// the successor, the upgrade re-opens it under the new name — and because
// the replacement looks like an untouched default, even the startup
// warning stays quiet (see IsUnconfiguredDefault and issue #552).
// - A user who customized the old default could name only the field they
// were changing, because the seeded default supplied the transport. Once
// the default stops being seeded that entry resolves to a server with no
// type, url, or command, which fails NormalizeConfig and takes the whole
// startup down rather than just that one server.
//
// It runs immediately after the user layer merges (see ResolveMCP), which
// scopes it to user-level decisions — the only scope whose disable is sticky.
// The carried-over disable applies only when the user never declared the
// successor themselves: an explicit `exa` entry wins whether it enables or
// disables. Because the disable is recorded as a user-level decision, the
// lower-trust project layer cannot lift it, while `zero mcp enable exa` still
// can (the CLI override scope merges with canReenable=true).
func migrateRetiredDefaultMCPServers(cfg *MCPConfig) {
for name, retired := range retiredDefaultMCPServers {
entry, ok := cfg.Servers[name]
if !ok {
continue
}
if inheritsRetiredTransport(entry) {
// Re-seed what Zero used to ship underneath the user's fields, so a
// partial entry still resolves to the complete server it named when
// it was written. Their own values still win — this only fills the
// gap the retired default used to cover.
entry = mergeMCPServer(retired.shipped, entry, true)
cfg.Servers[name] = entry
}
if !entry.disabledSet || !entry.Disabled {
continue
}
replacement, ok := cfg.Servers[retired.successor]
if !ok || replacement.configured {
continue
}
replacement.Disabled = true
replacement.disabledSet = true
cfg.Servers[retired.successor] = replacement
}
}
// inheritsRetiredTransport reports whether entry named no transport of its own
// — no type, url, command, or args — and so relied entirely on the retired
// default to supply one.
//
// Anything else is a complete definition the user owns (a self-hosted url, a
// stdio command) and must be left alone: grafting the retired default's http
// type onto an entry that names a command produces a server that is rejected
// outright, turning a working config into a startup failure.
func inheritsRetiredTransport(entry MCPServerConfig) bool {
return strings.TrimSpace(entry.Type) == "" &&
strings.TrimSpace(entry.URL) == "" &&
strings.TrimSpace(entry.Command) == "" &&
len(entry.Args) == 0
}
+36 -197
View File
@@ -7,10 +7,10 @@ import (
)
func TestIsDefaultMCPServer(t *testing.T) {
if !IsDefaultMCPServer("exa") {
t.Fatal("exa should be a built-in default")
if !IsDefaultMCPServer("firecrawl") {
t.Fatal("firecrawl should be a built-in default")
}
if IsDefaultMCPServer(" exa ") == false {
if IsDefaultMCPServer(" firecrawl ") == false {
t.Fatal("IsDefaultMCPServer should trim whitespace")
}
if IsDefaultMCPServer("not-a-default") {
@@ -18,45 +18,45 @@ func TestIsDefaultMCPServer(t *testing.T) {
}
}
func TestResolveMCPSeedsEnabledExaDefault(t *testing.T) {
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa, ok := cfg.Servers["exa"]
firecrawl, ok := cfg.Servers["firecrawl"]
if !ok {
t.Fatal("expected the exa default to be seeded with no user config")
t.Fatal("expected the firecrawl default to be seeded with no user config")
}
if exa.Type != "http" || exa.URL != "https://mcp.exa.ai/mcp" {
t.Fatalf("unexpected exa default: %#v", exa)
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
}
if exa.Disabled {
t.Fatal("the exa default must be enabled out of the box")
if firecrawl.Disabled {
t.Fatal("the firecrawl default must be enabled out of the box")
}
}
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":true}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
if !cfg.Servers["firecrawl"].Disabled {
t.Fatal("a user must be able to disable the default by writing over it")
}
}
func TestIsUnconfiguredDefault(t *testing.T) {
if !IsUnconfiguredDefault("exa", DefaultMCPServers()["exa"]) {
t.Fatal("an untouched exa default should be reported as unconfigured")
if !IsUnconfiguredDefault("firecrawl", DefaultMCPServers()["firecrawl"]) {
t.Fatal("an untouched firecrawl default should be reported as unconfigured")
}
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://example.com/mcp"}) {
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "http://localhost:3002/mcp"}) {
t.Fatal("a server overriding the default URL is no longer unconfigured")
}
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://mcp.exa.ai/mcp", Auth: "bearer"}) {
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "https://mcp.firecrawl.dev/v2/mcp", Auth: "bearer"}) {
t.Fatal("a server with credentials added is no longer unconfigured")
}
if IsUnconfiguredDefault("not-a-default", MCPServerConfig{}) {
@@ -65,173 +65,57 @@ func TestIsUnconfiguredDefault(t *testing.T) {
}
func TestResolveMCPExplicitReenableIsNotUnconfiguredDefault(t *testing.T) {
// `zero mcp enable exa` after a prior disable writes {"disabled":false}
// `zero mcp enable firecrawl` after a prior disable writes {"disabled":false}
// explicitly. The resolved value is identical to the untouched default (both
// enabled, no credentials), but the user DID take an explicit action here, so
// IsUnconfiguredDefault must not treat it as untouched (issue #563 review).
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", exa)
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", firecrawl)
}
if IsUnconfiguredDefault("exa", exa) {
if IsUnconfiguredDefault("firecrawl", firecrawl) {
t.Fatal("an explicit enable/disable toggle must count as user-configured, even though the resolved value matches the default")
}
}
func TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault(t *testing.T) {
// A user who copies Exa's exact default type/url into their config
// A user who copies firecrawl's exact default type/url into their config
// (e.g. from an example file, planning to add credentials later) produces a
// resolved value byte-identical to DefaultMCPServers()["exa"] — the
// resolved value byte-identical to DefaultMCPServers()["firecrawl"] — the
// same trap TestResolveMCPExplicitReenableIsNotUnconfiguredDefault covers for
// the disabled toggle. IsUnconfiguredDefault must still treat this as
// user-configured because the user's JSON declared an entry for it, even
// though a plain resolved-value comparison could not tell the difference.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"type":"http","url":"https://mcp.exa.ai/mcp"}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"type":"http","url":"https://mcp.firecrawl.dev/v2/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
want := DefaultMCPServers()["exa"]
if exa.Type != want.Type || exa.URL != want.URL || exa.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", exa)
firecrawl := cfg.Servers["firecrawl"]
want := DefaultMCPServers()["firecrawl"]
if firecrawl.Type != want.Type || firecrawl.URL != want.URL || firecrawl.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", firecrawl)
}
if IsUnconfiguredDefault("exa", exa) {
if IsUnconfiguredDefault("firecrawl", firecrawl) {
t.Fatal("redeclaring the default's exact values is still an explicit user configuration, not an untouched default")
}
}
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
// Point Exa at a proxy; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"url":"https://example.com/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.URL != "https://example.com/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", exa)
}
if exa.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", exa)
}
}
func TestResolveMCPCarriesLegacyDefaultDisableToSuccessor(t *testing.T) {
// Upgrade path: the user disabled the firecrawl default Zero used to ship.
// Swapping the default to exa must not re-open an outbound connection they
// explicitly switched off.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatalf("a prior disable of the retired default must carry to its replacement: %#v", cfg.Servers["exa"])
}
}
func TestResolveMCPExplicitSuccessorBeatsLegacyDefaultDisable(t *testing.T) {
// The user disabled the old default but has since explicitly configured exa.
// Their newer, explicit choice wins — the migration must not override it.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true},"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.Disabled {
t.Fatalf("an explicit exa entry must survive the legacy-disable migration: %#v", exa)
}
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("an explicitly configured exa is not an untouched default")
}
}
func TestResolveMCPLegacyDefaultLeftEnabledDoesNotDisableSuccessor(t *testing.T) {
// A user who configured firecrawl without disabling it (e.g. added a header)
// never opted out of the default search server, so exa stays enabled.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["exa"].Disabled {
t.Fatal("only an explicit disable of the retired default should carry over")
}
}
func TestResolveMCPLegacyDefaultDisableIsLiftableByOverride(t *testing.T) {
// The carried-over disable is a user-level decision, not a permanent one:
// `zero mcp enable exa` merges through the CLI override scope, which is the
// one layer allowed to re-enable.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
enabled := MCPServerConfig{Disabled: false, disabledSet: true}
cfg, err := ResolveMCP(ResolveOptions{
UserConfigPath: path,
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"exa": enabled}}},
})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["exa"].Disabled {
t.Fatalf("an explicit enable must lift the carried-over disable: %#v", cfg.Servers["exa"])
}
}
func TestResolveMCPLegacyDisableSurvivesProjectReenable(t *testing.T) {
// The project layer is lower-trust and must not lift the carried-over
// user-level disable — the same guard a direct `zero mcp disable exa` gets.
dir := t.TempDir()
userPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(userPath, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
projectPath := filepath.Join(dir, "project.json")
if err := os.WriteFile(projectPath, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("project config must not re-enable a server the user disabled")
}
}
func TestResolveMCPKeepsRetiredDefaultTransportForPartialEntry(t *testing.T) {
// A user who added an API key to the firecrawl default wrote only the header
// — the seeded default supplied the http type and URL. Retiring the default
// must not strip those out from under them and leave an unusable server.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
// Point firecrawl at a self-hosted instance; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
@@ -239,55 +123,10 @@ func TestResolveMCPKeepsRetiredDefaultTransportForPartialEntry(t *testing.T) {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("a partial entry must keep the retired default's transport: %#v", firecrawl)
if firecrawl.URL != "http://localhost:3002/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
}
if firecrawl.Headers["Authorization"] != "Bearer k" {
t.Fatalf("the user's own field must still win: %#v", firecrawl)
}
}
func TestResolveMCPDoesNotGraftRetiredDefaultOntoOwnTransport(t *testing.T) {
// A self-hosted firecrawl over stdio is a complete definition the user owns.
// Re-seeding the retired http default under it would produce an http server
// carrying a command, which NormalizeConfig rejects outright.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Type != "" || firecrawl.URL != "" {
t.Fatalf("an entry naming its own transport must be left alone: %#v", firecrawl)
}
if firecrawl.Command != "firecrawl-mcp" {
t.Fatalf("the user's command must survive: %#v", firecrawl)
}
}
func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
// The disable carries to exa, and the retired entry itself keeps a usable
// transport so a later `zero mcp enable firecrawl` does not resolve to a
// server with no type, url, or command.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if !firecrawl.Disabled {
t.Fatalf("the retired entry must stay disabled: %#v", firecrawl)
}
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("a disabled retired entry should still carry a usable transport: %#v", firecrawl)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("the disable must still carry to the successor")
if firecrawl.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
}
}
@@ -34,7 +34,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
if _, ok := cfg.Servers["user-srv"]; !ok {
t.Fatal("the user server must always be present")
}
if _, ok := cfg.Servers["exa"]; !ok {
if _, ok := cfg.Servers["firecrawl"]; !ok {
t.Fatal("the built-in default must always be present")
}
})
@@ -54,7 +54,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
if _, ok := cfg.Servers["user-srv"]; !ok {
t.Fatal("the user server must survive when the project layer is dropped")
}
if _, ok := cfg.Servers["exa"]; !ok {
if _, ok := cfg.Servers["firecrawl"]; !ok {
t.Fatal("the built-in default must survive when the project layer is dropped")
}
})
+1 -5
View File
@@ -180,7 +180,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
}
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// Seed Zero's built-in default MCP servers (e.g. keyless Exa for free,
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
// no-setup web search/scrape) BEFORE merging user/project config, so the user
// can override any field or disable a default by writing over it.
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
@@ -194,10 +194,6 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// server the user disabled (a user-level disable is sticky, but the user
// scope itself may lift it).
mergeMCPConfig(&cfg.MCP, fileConfig.MCP, true)
// Reconcile config written against a default Zero has since retired,
// here while the user layer is the only one merged — so a carried-over
// disable counts as a user-level decision the project layer cannot lift.
migrateRetiredDefaultMCPServers(&cfg.MCP)
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
+1 -7
View File
@@ -119,12 +119,6 @@ type PreferencesConfig struct {
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
// CompactionModel routes compaction summarization calls to this model
// instead of the session's main model (summaries at main-model prices are
// the most expensive recurring event in long runs). Empty = automatic: a
// curated cheap model on official endpoints, the main model elsewhere.
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
CompactionModel string `json:"compactionModel,omitempty"`
}
// RecentModelEntry is one provider-qualified model selection recorded in
@@ -471,7 +465,7 @@ type MCPServerConfig struct {
// fields it set or what values they hold. A built-in default seeded by
// DefaultMCPServers() is never unmarshaled from JSON, so it starts false;
// any explicit entry in the user/project file — even one that happens to
// repeat a default's exact field values (e.g. re-declaring Exa's
// repeat a default's exact field values (e.g. re-declaring firecrawl's
// default URL) — sets it true. IsUnconfiguredDefault checks this alongside
// a resolved-value comparison, so redeclaring default values verbatim still
// counts as user-configured.
+66 -9
View File
@@ -5,23 +5,31 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning. The lock is held only for a single metadata
// read-modify-write (milliseconds), never across a job's exec, so the timeout
// is generous relative to any real hold.
// read-modify-write (milliseconds), never across a job's exec, so the timeout is
// generous and the stale threshold sits far above any real hold.
const (
cronLockTimeout = 10 * time.Second
cronLockStaleAfter = 60 * time.Second
cronLockRetryDelay = 20 * time.Millisecond
)
// lockJob takes a cross-process advisory lock for one job on a stable sibling
// "<id>.lock" file next to the job directory. It serializes the read-modify-write
// of a job's metadata across concurrent schedulers and commands. The kernel
// releases the lock when a holder exits; the file is never renamed or removed.
var cronLockSeq atomic.Uint64
// lockJob takes a cross-process exclusive lock for one job by O_EXCL-creating a
// sibling "<id>.lock" file next to the job directory (so Remove's RemoveAll of
// the job dir never deletes a live lock). It serializes the read-modify-write of
// a job's metadata across concurrent schedulers and commands. The lock file is
// removed on release; a stale lock from a crashed holder (older than
// cronLockStaleAfter) is reclaimed. Wall-clock time.Now is used deliberately
// (not the injectable Store.now) so lock timing never depends on a frozen test
// clock and the stale check compares against real file mtimes.
func (s *Store) lockJob(id string) (func(), error) {
if !validID(id) {
return nil, fmt.Errorf("invalid cron job id %q", id)
@@ -30,15 +38,64 @@ func (s *Store) lockJob(id string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), cronLockSeq.Add(1))
deadline := time.Now().Add(cronLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial write would leave a lock file without our token, so the
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: write job lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: close job lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("cron: acquire job lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both "reclaim" and recreate, so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename of a given file
// wins) and restores it if it turns out fresh, so a live lock is never deleted
// out from under its holder.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > cronLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= cronLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("cron: reclaim stale job lock: %w", rerr)
}
if cleared {
continue // cleared a genuinely stale lock; retry the O_EXCL create now
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait instead of hot-spinning on a reclaim that never wins (L13).
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("cron: timed out acquiring job lock for %q", id)
}
+1 -1
View File
@@ -64,7 +64,7 @@ func TestMutateRemovedJobReturnsNotFound(t *testing.T) {
if !errors.Is(err, ErrJobNotFound) {
t.Fatalf("Mutate on removed job = %v, want ErrJobNotFound", err)
}
// The stable advisory-lock file does not resurrect the removed job dir.
// And the lock file left no litter / did not resurrect the job dir.
if _, gerr := store.Get(job.ID); !errors.Is(gerr, ErrJobNotFound) {
t.Fatalf("job should remain removed, Get = %v", gerr)
}
+67 -35
View File
@@ -3,60 +3,107 @@ package daemon
import (
"errors"
"fmt"
"io/fs"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Single-instance lock. The stable PID file carries a kernel advisory lock for
// the daemon's full lifetime. Process exit releases the lock automatically, so
// stale metadata needs no rename/remove recovery protocol.
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
// daemon recovers from an unclean shutdown without manual cleanup.
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
// fileLock is an acquired single-instance lock.
type fileLock struct {
lock *lockutil.FileLock
path string
}
// processAlive reports whether pid is a live process. Implemented per-platform
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
var processAlive = osProcessAlive
// acquireLock takes the single-instance advisory lock at path. isAlive is used
// only to enrich a contention error with the current holder's PID; the kernel
// lock, not PID metadata, is authoritative.
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
// stub); nil uses the real processAlive.
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
if isAlive == nil {
isAlive = processAlive
}
lock, err := lockutil.TryAcquireFileLock(path)
if err != nil {
if !errors.Is(err, lockutil.ErrLockHeld) {
// At most two passes: create, or detect-stale-then-retry once.
for attempt := 0; attempt < 2; attempt++ {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
// A failed PID write would leave a malformed lock file that another
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
// the single-instance guarantee — so on write failure, remove it and fail.
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(path)
return nil, werr
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(path)
return nil, cerr
}
return &fileLock{path: path}, nil
}
if !errors.Is(err, fs.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, err
}
pid, perr := readPidFile(path)
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
return nil, ErrAlreadyRunning
// Stale lock (dead PID or unreadable) — reclaim it atomically, then retry the
// O_EXCL create. A blind Remove here races: two daemons starting at once could
// both read the stale PID, both Remove, and then one Removes the OTHER's
// freshly-created lock — leaving both "holding" the single-instance lock.
// reclaimStaleLock renames the file aside so only one racer wins the rename,
// and restores it if a live holder reacquired in the gap (D6).
if _, rerr := reclaimStaleLock(path, isAlive); rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break the single-instance guarantee).
// Fail closed instead of spinning to the deadline.
return nil, fmt.Errorf("daemon: reclaim stale lock: %w", rerr)
}
}
if err := lock.WriteMetadata([]byte(fmt.Sprintf("%d\n", os.Getpid()))); err != nil {
return nil, errors.Join(err, lock.Release())
}
return &fileLock{lock: lock}, nil
return nil, ErrAlreadyRunning
}
// release drops the kernel lock and closes its handle. The stable PID file is
// deliberately retained so contenders always address the same inode.
// daemonLockSeq makes each reclaim attempt's sidelined filename unique per process.
var daemonLockSeq atomic.Uint64
// reclaimStaleLock atomically reclaims a single-instance lock whose recorded
// PID is dead, via lockutil.ReclaimStaleLock: the lock file is renamed aside
// (only one racer can win the rename) and stolen only if the moved file's PID
// is still dead; a holder that reacquired the lock in the gap between the
// stale check and the rename carries a LIVE pid and is restored rather than
// stolen. Returns true only when a genuinely stale lock was removed. A
// non-nil error means the caller must fail closed instead of re-acquiring
// (see lockutil.ReclaimStaleLock).
func reclaimStaleLock(path string, isAlive func(pid int) bool) (bool, error) {
suffix := fmt.Sprintf("%d-%d", os.Getpid(), daemonLockSeq.Add(1))
return lockutil.ReclaimStaleLock(path, suffix, func(reclaimedPath string) bool {
pid, err := readPidFile(reclaimedPath)
return err == nil && pid > 0 && isAlive(pid)
})
}
// release removes the lock file. Safe to call once. An already-missing lock
// file is not an error (lockutil.RemoveLockFile swallows it on every platform).
func (l *fileLock) release() error {
if l == nil || l.lock == nil {
if l == nil || l.path == "" {
return nil
}
return l.lock.Release()
return lockutil.RemoveLockFile(l.path)
}
// readPidFile reads and parses the PID recorded in a lock file.
@@ -65,20 +112,5 @@ func readPidFile(path string) (int, error) {
if err != nil {
return 0, err
}
metadata := strings.TrimSpace(string(data))
if pid, err := strconv.Atoi(metadata); err == nil {
return pid, nil
}
pidText, sequenceText, ok := strings.Cut(metadata, "-")
if !ok || pidText == "" || sequenceText == "" || strings.Contains(sequenceText, "-") {
return 0, fmt.Errorf("daemon: parse lock PID metadata %q", metadata)
}
if _, err := strconv.ParseUint(sequenceText, 10, 64); err != nil {
return 0, fmt.Errorf("daemon: parse lock holder sequence: %w", err)
}
pid, err := strconv.Atoi(pidText)
if err != nil {
return 0, fmt.Errorf("daemon: parse lock PID: %w", err)
}
return pid, nil
return strconv.Atoi(strings.TrimSpace(string(data)))
}
+42 -54
View File
@@ -32,48 +32,27 @@ func TestLockSingleInstance(t *testing.T) {
_ = l2.release()
}
// A PID liveness probe is diagnostic only. Even if it reports the recorded PID
// dead, a contended kernel lock proves that the holder is active and must win.
// The old rename-aside protocol trusted this stale observation and admitted a
// second daemon while the first one was still inside its critical section.
func TestLockDoesNotOverrideKernelHolderWithStalePIDProbe(t *testing.T) {
func TestLockStaleRecovery(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
first, err := acquireLock(path, func(int) bool { return true })
if err != nil {
t.Fatalf("first acquireLock: %v", err)
}
defer first.release()
second, err := acquireLock(path, func(int) bool { return false })
if second != nil {
_ = second.release()
}
if !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("contended acquire with stale PID probe = %v, want ErrAlreadyRunning", err)
}
}
func TestLockIgnoresStaleMetadataWhenKernelLockIsFree(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
// A crashed daemon may leave PID metadata behind, but its kernel lock is
// released automatically, so the next daemon can acquire without moving it.
// Simulate a stale lock from a crashed daemon: a PID file whose process is
// dead.
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
dead := func(int) bool { return false }
l, err := acquireLock(path, dead)
if err != nil {
t.Fatalf("acquire over stale metadata: %v", err)
t.Fatalf("stale-lock recovery failed: %v", err)
}
// The lock now records OUR pid, not the stale one.
data, _ := os.ReadFile(path)
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
t.Fatalf("lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
}
_ = l.release()
}
func TestLockReleaseKeepsStableFile(t *testing.T) {
func TestLockReleaseRemovesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
l, err := acquireLock(path, func(int) bool { return true })
if err != nil {
@@ -82,37 +61,46 @@ func TestLockReleaseKeepsStableFile(t *testing.T) {
if err := l.release(); err != nil {
t.Fatalf("release: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("release removed stable lock file: %v", err)
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("lock file still present after release: %v", err)
}
}
func TestReadPidFileMetadataFormats(t *testing.T) {
func TestReclaimStaleLockRemovesDeadHolder(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
for _, test := range []struct {
name string
metadata string
wantPID int
wantErr bool
}{
{name: "legacy PID", metadata: "4242\n", wantPID: 4242},
{name: "PID and holder sequence", metadata: "4242-7\n", wantPID: 4242},
{name: "missing sequence", metadata: "4242-", wantErr: true},
{name: "non-numeric sequence", metadata: "4242-next", wantErr: true},
{name: "extra component", metadata: "4242-7-8", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(test.metadata), 0o600); err != nil {
t.Fatal(err)
}
got, err := readPidFile(path)
if (err != nil) != test.wantErr {
t.Fatalf("readPidFile() error = %v, wantErr %v", err, test.wantErr)
}
if !test.wantErr && got != test.wantPID {
t.Fatalf("readPidFile() = %d, want %d", got, test.wantPID)
}
})
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
if ok, err := reclaimStaleLock(path, func(int) bool { return false }); err != nil || !ok {
t.Fatalf("reclaimStaleLock must report a genuinely stale (dead-PID) lock reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("a reclaimed stale lock must be removed, stat = %v", err)
}
}
func TestReclaimStaleLockRestoresLiveHolder(t *testing.T) {
// If a holder reacquires the lock in the gap between the stale check and the
// rename, the moved file carries a LIVE pid; reclaim must restore it untouched
// rather than steal it — otherwise two daemons both "hold" the lock (D6).
path := filepath.Join(t.TempDir(), "daemon.lock")
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write lock: %v", err)
}
if ok, err := reclaimStaleLock(path, func(int) bool { return true }); err != nil || ok {
t.Fatalf("reclaimStaleLock must NOT report a live-PID lock reclaimed (ok=%v err=%v)", ok, err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("live lock must be restored in place, read = %v", err)
}
if strings.TrimSpace(string(data)) != "4242" {
t.Fatalf("restored lock content = %q, want %q (unchanged)", strings.TrimSpace(string(data)), "4242")
}
// No sidelined ".stale" leftovers.
matches, _ := filepath.Glob(path + ".stale.*")
if len(matches) != 0 {
t.Fatalf("reclaim left sidelined files: %v", matches)
}
}
+2 -6
View File
@@ -124,16 +124,12 @@ func TestServerEndToEnd(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
// Runtime endpoints are removed on exit. The advisory lock file remains as
// a stable inode, but the kernel lock on it has been released.
for _, p := range []string{paths.Socket, paths.Status} {
// Socket, status, and lock files are removed on exit.
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
}
}
if _, err := os.Stat(paths.Lock); err != nil {
t.Fatalf("stable lock file missing after shutdown: %v", err)
}
}
func TestServerSecondInstanceFails(t *testing.T) {
+1 -10
View File
@@ -9,16 +9,7 @@ import (
"strings"
)
// PolicyVersion is the version of the approval policy that recorded grants were
// evaluated under. Bump it whenever a change narrows what may be approved: the
// grant store then backs the file up, keeps deny grants, invalidates prior
// approvals, and tells the user once, instead of silently honoring approvals
// that the current rules would have refused.
//
// v2 tightened command-prefix validation so a launcher spelled with a version
// or an executable extension ("python3.11", "python.exe") can no longer be
// approved as an ordinary command.
const PolicyVersion = 2
const PolicyVersion = 1
type Origin string
+29 -157
View File
@@ -3,93 +3,13 @@
package execution
import (
"errors"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// requireProcessState refuses to let a broken ps pipeline masquerade as passing
// coverage. Every test in this file proves something about zombie detection,
// and every one of those proofs is read out of ps — so if ps cannot report
// state, the tests verify nothing whatever they do next.
//
// The old code answered that with t.Skip, which is indistinguishable from
// success in CI output. This probes BOTH ps pipelines against this test
// process, which is by definition alive and not a zombie, so a working ps must
// report a live state for it. Failing that:
//
// - In CI (or with ZERO_REQUIRE_PS=1) it is a hard failure. ps is present on
// every image used today, so its absence means the pipeline regressed and
// that has to surface immediately rather than as skipped-green.
// - Elsewhere it stays a skip, so a contributor on an exotic box is not
// blocked by an environment the project does not claim to support.
//
// This only covers "ps cannot report state at all". Once it passes, a later
// failure to observe an EXPECTED state is a real defect, and the tests below
// treat it as one.
func requireProcessState(t *testing.T) {
t.Helper()
self := os.Getpid()
var reasons []string
// The production path: ps -A -o pid=,pgid=,stat=, parsed by
// processTableStateMatches. This is what signalTargetRunning consults.
if running, ok := processIsRunning(self); !ok || !running {
reasons = append(reasons, "the process table read used by signalTargetRunning reported no live state for this test process")
}
// This file's independent oracle: ps -o stat= -p <pid>. It is deliberately
// NOT the production parser, so a regression in that parser cannot hide by
// breaking the test's own precondition check in the same way.
if zombie, ok := processIsZombie(self); !ok || zombie {
reasons = append(reasons, "the per-PID state read used by this test file reported no live state for this test process")
}
if len(reasons) == 0 {
return
}
reason := "cannot read process state, so ps-based zombie detection is unverifiable here: " + strings.Join(reasons, "; ")
if requireWorkingPS() {
t.Fatal(reason + " — failing rather than skipping because CI (or ZERO_REQUIRE_PS) is set, where ps is expected to work")
}
t.Skip(reason)
}
// requireWorkingPS reports whether an unusable ps must fail the run instead of
// skipping it. ZERO_REQUIRE_PS lets a developer opt into the strict behavior
// locally to reproduce what CI does.
func requireWorkingPS() bool {
if strings.TrimSpace(os.Getenv("ZERO_REQUIRE_PS")) != "" {
return true
}
return strings.TrimSpace(os.Getenv("CI")) != ""
}
// awaitZombie blocks until pid is an unreaped zombie. The caller must not have
// waited on the child, so the zombie is a stable state rather than a race: it
// persists until something reaps it. Failing to reach it therefore means the
// child never exited or ps stopped reporting its state, and both are defects
// rather than reasons to skip.
func awaitZombie(t *testing.T, pid int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
zombie, ok := processIsZombie(pid)
if ok && zombie {
return
}
if time.Now().After(deadline) {
if !ok {
t.Fatalf("pid %d left the process table without being reaped; this test never waited on it, so its zombie row must persist", pid)
}
t.Fatalf("pid %d did not reach the zombie state within 5s", pid)
}
time.Sleep(10 * time.Millisecond)
}
}
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
// zombie, so a group whose only remaining member is waiting to be reaped used to
@@ -97,8 +17,6 @@ func awaitZombie(t *testing.T, pid int) {
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
// depended on when the reaper ran.
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "exit 0")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
@@ -109,59 +27,23 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
// is exactly the state a terminated tree passes through.
t.Cleanup(func() { _ = cmd.Wait() })
awaitZombie(t, pid)
// Whether a zombie-only group can be signalled is a KERNEL difference, not a
// ps one. On Linux the zombie still occupies the process table and
// kill(-pid, 0) succeeds, so signalTargetRunning goes on to consult ps —
// which is the branch this test was written for. Darwin returns EPERM for
// the same call, so signalTargetRunning short-circuits on the kill and never
// reaches the ps check.
//
// The OUTCOME is identical and is asserted on every platform below. Only the
// branch that produces it differs, so record that instead of skipping the
// whole test (which would drop Darwin's coverage of the outcome) or failing
// it (which would report a supported platform as broken). Any errno other
// than EPERM means the group is not in the state this test needs.
psPathReachable := true
if err := syscall.Kill(-pid, syscall.Signal(0)); err != nil {
if !errors.Is(err, syscall.EPERM) {
t.Fatalf("zombie group %d is unsignalable with an unexpected error (%v); the zombie case is not present, so nothing below would be meaningful", pid, err)
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
psPathReachable = false
t.Logf("kernel refuses to signal zombie-only group %d (EPERM); signalTargetRunning answers from kill(2) rather than ps here, so the ps-based zombie check is asserted directly on platforms that allow the signal", pid)
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
}
// Where the signal IS permitted, pin the ps-based check itself. This is the
// regression #774 was about, and asserting only signalTargetRunning would
// let a ps-side regression hide behind the kill result.
if psPathReachable {
running, ok := processGroupHasRunningMember(pid)
if !ok {
t.Fatalf("process group %d has no rows while its zombie is unreaped; ps stopped reporting the group rather than the group being gone", pid)
}
if running {
t.Fatal("the ps-based group check must not report a zombie-only group as running")
}
}
// Valid on every platform: whichever branch answers, a zombie-only group is
// not running. On a kernel that refuses the signal this is the whole of the
// coverage, and it is more than the previous skip provided.
if signalTargetRunning(-pid) {
t.Fatal("a group holding only a zombie must not count as running")
}
if !psPathReachable {
// terminateTarget's own kill(2) would hit the same EPERM and be returned
// as a cleanup error, so this assertion cannot be made where the kernel
// refuses the signal. That may be a real gap in TerminateProcessTree on
// such a platform rather than a test limitation — a zombie-only tree is
// already exited and should terminate cleanly — but confirming and
// changing that is a production fix, not this test's business.
t.Log("skipping the TerminateProcessTree assertion: its kill(2) would hit the same EPERM as the probe above")
return
}
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
}
@@ -171,8 +53,6 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
// a live member must still be seen as running and then actually stopped, so the
// zombie tolerance above cannot degrade into ignoring real processes.
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "sleep 30")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
@@ -187,15 +67,7 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree: %v", err)
}
// ok reports whether ANY row for this group was found. This test never waits
// on the child, so its zombie row must still be there — an unknown state
// means ps stopped answering, not that the group is gone. Treating that as a
// pass is what let this assertion succeed without checking anything.
running, ok := processGroupHasRunningMember(pid)
if !ok {
t.Fatalf("process group %d has no rows after termination; the unreaped child's zombie row must persist, so the group's state is undeterminable rather than clean", pid)
}
if running {
if running, ok := processGroupHasRunningMember(pid); ok && running {
t.Fatal("the group still has a running member after termination")
}
}
@@ -210,8 +82,6 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
// "unknown" resulted every time, and signalTargetRunning conservatively (and
// incorrectly) treated a zombie individual-PID target as still running.
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "exit 0")
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
// test process's group, so it is not its own leader and processSignalTarget
@@ -222,38 +92,40 @@ func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
pid := cmd.Process.Pid
t.Cleanup(func() { _ = cmd.Wait() })
awaitZombie(t, pid)
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
target, err := processSignalTarget(pid)
if err != nil {
t.Fatalf("processSignalTarget: %v", err)
}
// The child inherited this process's group, so its PGID is this process's
// PID and can never equal its own PID. A negative target here would mean
// processSignalTarget misread the group, which is the very thing the
// individual-PID fallback exists to handle.
if target != pid {
t.Fatalf("processSignalTarget(%d) = %d, want the individual PID: the child inherited this process's group, so it is not its own leader", pid, target)
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
}
if signalTargetRunning(target) {
t.Fatal("a zombie individual-PID target must not count as running")
}
}
// processIsZombie reports a process's zombie state via ps. It is deliberately a
// separate, per-PID ps invocation rather than a reuse of the production
// process-table parser: these tests assert what that parser concludes, so
// borrowing it to establish their own preconditions would let one regression
// silence both sides at once.
//
// ok is false when no state could be read, which after requireProcessState has
// passed means the process left the table rather than that ps is broken.
// processIsZombie reports a process's zombie state via ps. ok is false when the
// state could not be read.
func processIsZombie(pid int) (zombie bool, ok bool) {
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false, false
}
state := strings.TrimLeft(string(output), " \n\t")
state := string(output)
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
state = state[1:]
}
if state == "" {
return false, false
}
+63 -9
View File
@@ -5,39 +5,93 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning for the audit log. The lock is held only across a
// single read-then-append (milliseconds), so the timeout is generous relative
// to any real hold.
// single read-then-append (milliseconds), so the timeout is generous and the
// stale threshold sits far above any real hold.
const (
auditLockTimeout = 10 * time.Second
auditLockStaleAfter = 60 * time.Second
auditLockRetryDelay = 20 * time.Millisecond
)
// lockAudit takes a cross-process advisory lock on the audit log through a
// stable sibling "<auditPath>.lock" file. It makes
var auditLockSeq atomic.Uint64
// lockAudit takes a cross-process exclusive lock on the audit log by
// O_EXCL-creating a sibling "<auditPath>.lock" file (removed on release). It makes
// the lastSequence()+append in append() atomic across processes; the audit log is
// a shared global file, so without it two processes can read the same last
// sequence and write duplicate numbers. Kernel lock lifetime provides crash
// recovery without moving or deleting the lock path.
// sequence and write duplicate numbers. A stale lock from a crashed holder (older
// than auditLockStaleAfter) is reclaimed. Wall-clock time is used deliberately so
// lock timing never depends on the store's injectable clock and the stale check
// compares against real file mtimes. This mirrors internal/cron/lock.go and
// internal/oauth/lock.go. Uses internal/lockutil for lock restore and reclaim.
func (store *AuditStore) lockAudit() (func(), error) {
lockPath := store.auditPath + ".lock"
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), auditLockSeq.Add(1))
deadline := time.Now().Add(auditLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial write would leave a lock file without our token, so the
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: write audit lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: close audit lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat both
// as contention and retry.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("hooks: acquire audit lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > auditLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= auditLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("hooks: reclaim stale audit lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("hooks: timed out acquiring audit lock")
}
+48 -111
View File
@@ -1,127 +1,64 @@
// Package lockutil provides cross-process advisory file locks used by cron,
// daemon, hooks, oauth, and swarm. Locks are held by the kernel for the lifetime
// of an open file handle, so process exit releases them without a stale-file
// reclaim protocol. The lock file itself is stable: holders never rename or
// remove it while another process may still have its inode locked.
// Package lockutil provides the platform-specific file primitives behind the
// O_EXCL lock files in cron, daemon, hooks, oauth, and swarm: a no-overwrite
// restore for locks that were sidelined during a stale-reclaim attempt, and a
// lock file remover with one cross-platform contract (missing files are a
// no-op; Windows retries transient sharing violations).
package lockutil
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
)
var (
// ErrLockHeld reports that another process or goroutine currently holds the
// advisory lock. Callers may wait and retry until their own deadline.
ErrLockHeld = errors.New("lockutil: lock is held")
// ErrLockReleased reports an attempt to update metadata after Release.
ErrLockReleased = errors.New("lockutil: lock is released")
)
var holderSequence atomic.Uint64
// FileLock is an exclusive advisory lock held through an open file handle.
// Release is idempotent. The lock path remains on disk after release; deleting
// or replacing it would let different processes lock different inodes.
type FileLock struct {
mu sync.Mutex
file *os.File
state platformLockState
released bool
releaseErr error
}
// TryAcquireFileLock attempts to take an exclusive advisory lock without
// waiting. A contended lock returns ErrLockHeld. The kernel releases a lock if
// its process exits, so an old unlocked file never needs stale reclamation.
func TryAcquireFileLock(path string) (*FileLock, error) {
return TryAcquireFileLockAt(filepath.Dir(path), path)
}
// TryAcquireFileLockAt attempts to lock path while confining every component
// below root to a handle-relative traversal. root is the caller's trusted
// boundary and may itself be reached through a legitimate link; links and
// reparse points below it are rejected before metadata can be written.
func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
if root == "" {
return nil, errors.New("lockutil: lock root is empty")
}
relative, err := filepath.Rel(root, path)
// restoreByCopy restores reclaimed to path without overwriting an existing
// path, as a fallback for when the platform's primary no-replace primitive
// (hard link on POSIX, MoveFileEx on Windows) fails for a reason other than
// the destination existing. It stages a full copy under a private name next
// to reclaimed and publishes it to path with publish (the same no-replace
// primitive the caller's platform uses for the primary restore), so path
// never appears in a partially-copied state: a crash between staging and
// publish leaves path exactly as it was (missing or, if a competing holder
// created it, untouched), never a truncated file that a PID/content-based
// liveness check could mistake for dead. Leaving path missing here would let
// the next O_EXCL create succeed while the sidelined holder is still live,
// breaking mutual exclusion. publish keeps the no-overwrite guarantee: a new
// holder that appeared in the meantime wins and this returns os.ErrExist. The
// copy resets the lock's mtime to now, which only makes the restored lock
// look fresher; that is safe, since it is being handed back to a live holder.
func restoreByCopy(reclaimed, path string, publish func(from, to string) error) error {
staged := reclaimed + ".copy"
src, err := os.Open(reclaimed)
if err != nil {
return nil, fmt.Errorf("lockutil: resolve lock path relative to root: %w", err)
return err
}
relative = filepath.Clean(relative)
if relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("lockutil: lock path %q escapes root %q", path, root)
}
file, err := openLockFileAt(root, relative, path)
dst, err := os.OpenFile(staged, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
_ = src.Close()
return err
}
state, contended, err := tryLockFile(file)
_, err = io.Copy(dst, src)
// Close the source before removing anything below: Go opens files without
// FILE_SHARE_DELETE on Windows, so deleting reclaimed or staged while src
// is open would fail with a sharing violation.
_ = src.Close()
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("lockutil: acquire advisory lock: %w", err)
_ = dst.Close()
_ = os.Remove(staged)
return err
}
if contended {
_ = file.Close()
return nil, ErrLockHeld
if err := dst.Close(); err != nil {
_ = os.Remove(staged)
return err
}
lock := &FileLock{file: file, state: state}
metadata := []byte(fmt.Sprintf("%d-%d\n", os.Getpid(), holderSequence.Add(1)))
if err := lock.WriteMetadata(metadata); err != nil {
return nil, errors.Join(err, lock.Release())
}
return lock, 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 {
if lock == nil {
return ErrLockReleased
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released || lock.file == nil {
return ErrLockReleased
}
if err := lock.file.Truncate(0); err != nil {
return fmt.Errorf("lockutil: truncate lock metadata: %w", err)
}
if _, err := lock.file.Seek(0, 0); err != nil {
return fmt.Errorf("lockutil: seek lock metadata: %w", err)
}
if _, err := lock.file.Write(data); err != nil {
return fmt.Errorf("lockutil: write lock metadata: %w", err)
if err := publish(staged, path); err != nil {
_ = os.Remove(staged)
return err
}
// publish may leave staged behind (a hard-link publish keeps both names),
// so remove it explicitly; a move-based publish already made this a no-op.
_ = os.Remove(staged)
// The lock is back at path, so the restore has succeeded; failing to clean
// up the sidelined name must not be reported as a failed restore.
_ = RemoveLockFile(reclaimed)
return nil
}
// Release unlocks and closes the held file handle. It deliberately leaves the
// stable lock file in place. Repeated calls return the first release result.
func (lock *FileLock) Release() error {
if lock == nil {
return nil
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released {
return lock.releaseErr
}
lock.released = true
unlockErr := unlockFile(lock.file, &lock.state)
closeErr := lock.file.Close()
lock.file = nil
if err := errors.Join(unlockErr, closeErr); err != nil {
lock.releaseErr = fmt.Errorf("lockutil: release advisory lock: %w", err)
}
return lock.releaseErr
}
+33 -82
View File
@@ -4,92 +4,43 @@ package lockutil
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
type platformLockState struct{}
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
parentFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, fmt.Errorf("open lock root: %w", err)
}
defer func() {
if parentFD >= 0 {
resultErr = errors.Join(resultErr, unix.Close(parentFD))
}
}()
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
for _, component := range components[:len(components)-1] {
nextFD, openErr := unix.Openat(parentFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
if openErr != nil {
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
}
if closeErr := unix.Close(parentFD); closeErr != nil {
_ = unix.Close(nextFD)
parentFD = -1
return nil, fmt.Errorf("close lock path component: %w", closeErr)
}
parentFD = nextFD
}
name := components[len(components)-1]
fd := -1
// Darwin can transiently return ENOENT when many callers race to create the
// same O_NOFOLLOW entry. The bound parent handle cannot be redirected, so a
// bounded retry preserves first-use contention without weakening no-follow.
for attempt := 0; attempt < 10; attempt++ {
fd, err = unix.Openat(parentFD, name, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
if !errors.Is(err, unix.ENOENT) {
break
}
}
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(fd), displayPath)
if file == nil {
_ = unix.Close(fd)
return nil, errors.New("convert lock handle to file")
}
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil {
return nil, errors.Join(fmt.Errorf("inspect lock file: %w", err), file.Close())
}
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, errors.Join(errors.New("refusing non-regular lock file"), file.Close())
}
if stat.Nlink != 1 {
return nil, errors.Join(errors.New("refusing multiply-linked lock file"), file.Close())
}
if err := unix.Close(parentFD); err != nil {
parentFD = -1
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
}
parentFD = -1
return file, nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
// RestoreLockFile restores a sidelined lock file on non-Windows platforms. It
// uses os.Link so a competing lock created at path in the meantime wins: the
// link fails with os.ErrExist instead of overwriting it. If the link fails for
// any other reason (hardlink-incapable filesystems such as FAT or some
// FUSE/network mounts, ENOSPC, EPERM), it falls back to an O_EXCL copy rather
// than leaving path missing and the live holder's lock stranded in reclaimed.
// Once the lock is back at path the restore has succeeded, so a failed cleanup
// of the sidelined name is not reported as an error; the leftover file is
// invisible to the lock protocol.
func RestoreLockFile(reclaimed, path string) error {
err := os.Link(reclaimed, path)
if err == nil {
return platformLockState{}, false, nil
}
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return platformLockState{}, true, nil
}
return platformLockState{}, false, err
}
func unlockFile(file *os.File, _ *platformLockState) error {
if file == nil {
_ = RemoveLockFile(reclaimed)
return nil
}
return unix.Flock(int(file.Fd()), unix.LOCK_UN)
if errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, os.Link)
}
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means it was lost to a racer rather than a hard failure. POSIX
// rename has no contention errno (a lost race surfaces only as ENOENT, which
// ReclaimStaleLock already treats as benign), so nothing extra is benign here.
func isReclaimContended(error) bool { return false }
// RemoveLockFile removes a lock file on non-Windows platforms. Removing an
// already-missing file reports nil, matching the Windows implementation, so
// callers see one cross-platform contract.
func RemoveLockFile(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
+101 -205
View File
@@ -1,220 +1,116 @@
package lockutil
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestFileLockSerializesAndKeepsStablePath(t *testing.T) {
func TestRestoreLockFile(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Successful restore when target does not exist
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := RestoreLockFile(reclaimed, path); err != nil {
t.Fatalf("RestoreLockFile failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !os.IsNotExist(err) {
t.Fatalf("expected sidelined lock to be cleaned up/removed: %v", err)
}
// 2. Fail when target already exists (prevent overwrite)
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := RestoreLockFile(reclaimed, path)
if err == nil {
t.Fatal("expected RestoreLockFile to fail when target exists")
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist, got: %v", err)
}
// The sidelined lock must still exist on failure, and the competing lock
// must be untouched.
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
}
func TestRestoreByCopy(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Restores content and cleans up the sidelined file when the target is
// free (the fallback taken when the platform's no-replace primitive fails
// for a reason other than the destination existing).
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); err != nil {
t.Fatalf("restoreByCopy failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected sidelined lock to be cleaned up after copy: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up: %v", err)
}
// 2. Keeps the no-overwrite guarantee: a competing lock at the target wins
// and the sidelined lock survives for the caller's ErrExist cleanup.
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist when target exists, got: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up after a failed publish: %v", err)
}
}
func TestRemoveLockFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
first, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("first acquire: %v", err)
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
firstInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat first lock path: %v", err)
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile failed: %v", err)
}
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
t.Fatalf("contended acquire = %v, want ErrLockHeld", err)
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
if err := first.Release(); err != nil {
t.Fatalf("release first: %v", err)
}
releasedInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("release removed stable lock path: %v", err)
}
if !os.SameFile(firstInfo, releasedInfo) {
t.Fatal("release replaced the stable lock file")
}
second, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("acquire after release: %v", err)
}
secondInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat second lock path: %v", err)
}
if !os.SameFile(firstInfo, secondInfo) {
t.Fatal("reacquire used a different lock file")
}
if err := second.Release(); err != nil {
t.Fatalf("release second: %v", err)
}
if err := second.Release(); err != nil {
t.Fatalf("idempotent release: %v", err)
// Removing an already-missing lock file is a no-op on every platform.
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile on a missing file = %v, want nil", err)
}
}
func TestFileLockMetadata(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
lock, err := TryAcquireFileLock(path)
if err != nil {
t.Fatal(err)
}
if err := lock.WriteMetadata([]byte("holder\n")); err != nil {
t.Fatalf("write metadata: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "holder\n" {
t.Fatalf("metadata = %q, err %v", data, err)
}
if err := lock.Release(); err != nil {
t.Fatal(err)
}
if err := lock.WriteMetadata(nil); !errors.Is(err, ErrLockReleased) {
t.Fatalf("write after release = %v, want ErrLockReleased", err)
}
}
func TestFileLockConcurrentFirstAcquisition(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
const contenders = 200
start := make(chan struct{})
results := make(chan error, contenders)
locks := make(chan *FileLock, contenders)
var ready sync.WaitGroup
ready.Add(contenders)
for range contenders {
go func() {
ready.Done()
<-start
lock, err := TryAcquireFileLock(path)
if lock != nil {
locks <- lock
}
results <- err
}()
}
ready.Wait()
close(start)
acquired := 0
for range contenders {
err := <-results
switch {
case err == nil:
acquired++
case !errors.Is(err, ErrLockHeld):
t.Errorf("concurrent first acquire: %v", err)
}
}
close(locks)
for lock := range locks {
if err := lock.Release(); err != nil {
t.Error(err)
}
}
if acquired != 1 {
t.Fatalf("concurrent first acquire: got %d holders, want 1", acquired)
}
}
func TestFileLockRefusesMultiplyLinkedEntry(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
lockPath := filepath.Join(dir, "lock")
const sentinel = "do not overwrite"
if err := os.WriteFile(target, []byte(sentinel), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Link(target, lockPath); err != nil {
t.Fatalf("create redirected hard link: %v", err)
}
if lock, err := TryAcquireFileLock(lockPath); err == nil {
_ = lock.Release()
t.Fatal("TryAcquireFileLock accepted a multiply-linked entry")
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(data) != sentinel {
t.Fatalf("redirected target was modified: %q", data)
}
}
func TestFileLockAtRejectsEscapingPath(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(filepath.Dir(base), "outside.lock")
if lock, err := TryAcquireFileLockAt(base, outside); err == nil {
_ = lock.Release()
t.Fatal("TryAcquireFileLockAt accepted a path outside its root")
}
if _, err := os.Stat(outside); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("escaping path was created: %v", err)
}
}
// TestFileLockReleasedByProcessExit proves crash recovery is provided by the
// kernel lock lifetime. The child exits without calling Release; the next
// process can then lock the same persistent file without moving or deleting it.
func TestFileLockReleasedByProcessExit(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
cmd := exec.Command(os.Args[0], "-test.run=^TestFileLockHelperProcess$")
cmd.Env = append(os.Environ(), "ZERO_LOCKUTIL_HELPER=1", "ZERO_LOCKUTIL_PATH="+path)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "ready" {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("helper readiness = %q, err %v", ready, err)
}
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("acquire while helper holds lock = %v, want ErrLockHeld", err)
}
if err := stdin.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("helper exit: %v", err)
}
lock, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("acquire after helper exit: %v", err)
}
if err := lock.Release(); err != nil {
t.Fatal(err)
}
}
func TestFileLockHelperProcess(t *testing.T) {
if os.Getenv("ZERO_LOCKUTIL_HELPER") != "1" {
return
}
lock, err := TryAcquireFileLock(os.Getenv("ZERO_LOCKUTIL_PATH"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if _, err := fmt.Fprintln(os.Stdout, "ready"); err != nil {
os.Exit(3)
}
_, _ = io.Copy(io.Discard, os.Stdin)
runtime.KeepAlive(lock) // deliberately exit without Release
}
+54 -179
View File
@@ -4,202 +4,77 @@ package lockutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unsafe"
"syscall"
"time"
"golang.org/x/sys/windows"
)
type platformLockState struct {
overlapped windows.Overlapped
// RestoreLockFile restores a sidelined lock file on Windows. It moves the file
// with a no-replace MoveFileEx so a competing lock created at path in the
// meantime wins: the move fails with os.ErrExist instead of overwriting it. If
// the move fails for any other reason (ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED under concurrent access to the file), it falls back to
// an O_EXCL copy rather than leaving path missing and the live holder's lock
// stranded in reclaimed.
func RestoreLockFile(reclaimed, path string) error {
err := moveFileNoReplace(reclaimed, path)
if err == nil || errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, moveFileNoReplace)
}
const lockByteOffsetHigh = 1
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
rootPtr, err := windows.UTF16PtrFromString(root)
// moveFileNoReplace renames from to to, failing if to already exists. It calls
// MoveFileExW directly via golang.org/x/sys/windows (the standard library's
// syscall package does not export MoveFileEx on this platform) with no flags,
// so an existing destination fails with ERROR_ALREADY_EXISTS, which satisfies
// errors.Is(err, os.ErrExist) (pinned by
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist), instead of overwriting it
// the way os.Rename does on Windows (it passes MOVEFILE_REPLACE_EXISTING to
// match POSIX rename semantics cross-platform).
func moveFileNoReplace(from, to string) error {
fromPtr, err := windows.UTF16PtrFromString(from)
if err != nil {
return nil, err
return err
}
parent, err := windows.CreateFile(
rootPtr,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
toPtr, err := windows.UTF16PtrFromString(to)
if err != nil {
return nil, fmt.Errorf("open lock root: %w", err)
return err
}
defer func() {
if parent != 0 {
resultErr = errors.Join(resultErr, windows.CloseHandle(parent))
}
}()
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
for _, component := range components[:len(components)-1] {
next, openErr := openLockDirectoryAt(parent, component)
if openErr != nil {
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
}
if closeErr := windows.CloseHandle(parent); closeErr != nil {
_ = windows.CloseHandle(next)
parent = 0
return nil, fmt.Errorf("close lock path component: %w", closeErr)
}
parent = next
}
handle, err := openLockFileHandleAt(parent, components[len(components)-1])
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), displayPath)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, errors.New("convert lock handle to file")
}
if err := windows.CloseHandle(parent); err != nil {
parent = 0
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
}
parent = 0
return file, nil
return windows.MoveFileEx(fromPtr, toPtr, 0)
}
func openLockDirectoryAt(parent windows.Handle, name string) (windows.Handle, error) {
handle, err := openLockPathAt(
parent,
name,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_ATTRIBUTE_DIRECTORY,
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err != nil {
return 0, err
}
if err := validateWindowsLockHandle(handle, true); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means the file was concurrently open or pending deletion (which
// surfaces as ERROR_SHARING_VIOLATION or ERROR_ACCESS_DENIED on Windows)
// rather than a hard failure, so ReclaimStaleLock reports a lost race instead
// of an error. Neither errno satisfies errors.Is(err, os.ErrExist), and
// ERROR_SHARING_VIOLATION does not satisfy errors.Is(err, os.ErrPermission)
// either, so the classification compares raw errnos like RemoveLockFile below.
func isReclaimContended(err error) bool {
var errno syscall.Errno
return errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED)
}
func openLockFileHandleAt(parent windows.Handle, name string) (windows.Handle, error) {
for attempt := 0; attempt < 10; attempt++ {
handle, err := openLockPathAt(
parent,
name,
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err == nil {
if err := validateWindowsLockHandle(handle, false); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
// RemoveLockFile removes a lock file on Windows, retrying on the sharing
// violation or access denied errors that are common under heavy concurrent
// contention. Removing an already-missing file reports nil, matching the
// non-Windows implementation, so callers see one cross-platform contract.
func RemoveLockFile(path string) error {
var err error
for i := 0; i < 15; i++ {
err = os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
if !windowsLockPathMissing(err) {
return 0, err
}
handle, err = openLockPathAt(
parent,
name,
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
windows.FILE_CREATE,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err == windows.STATUS_OBJECT_NAME_COLLISION || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
var errno syscall.Errno
if errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED) {
time.Sleep(5 * time.Millisecond)
continue
}
if err != nil {
return 0, err
}
if err := validateWindowsLockHandle(handle, false); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
return err
}
return 0, errors.New("lock file creation did not stabilize")
}
func windowsLockPathMissing(err error) bool {
return err == windows.STATUS_NO_SUCH_FILE ||
err == windows.STATUS_OBJECT_NAME_NOT_FOUND ||
err == windows.STATUS_OBJECT_PATH_NOT_FOUND ||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND)
}
func openLockPathAt(parent windows.Handle, name string, access, disposition, attributes, options uint32) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, err
}
objectAttributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
}
var handle windows.Handle
err = windows.NtCreateFile(
&handle,
access,
objectAttributes,
&windows.IO_STATUS_BLOCK{},
nil,
attributes,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
disposition,
options,
0,
0,
)
return handle, err
}
func validateWindowsLockHandle(handle windows.Handle, wantDirectory bool) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect lock path handle: %w", err)
}
isDirectory := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || isDirectory != wantDirectory {
return errors.New("refusing non-file or reparse-point lock path")
}
if !wantDirectory && info.NumberOfLinks != 1 {
return errors.New("refusing multiply-linked lock file")
}
return nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
state := platformLockState{overlapped: windows.Overlapped{OffsetHigh: lockByteOffsetHigh}}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &state.overlapped)
if err == nil {
return state, false, nil
}
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
return platformLockState{}, true, nil
}
return platformLockState{}, false, err
}
func unlockFile(file *os.File, state *platformLockState) error {
if file == nil {
return nil
}
return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &state.overlapped)
return err
}
@@ -0,0 +1,83 @@
//go:build windows
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist pins the errno coupling
// every caller keys its cleanup on: with no flags, MoveFileEx fails with
// ERROR_ALREADY_EXISTS when the destination exists, and that errno must
// satisfy errors.Is(err, os.ErrExist).
func TestMoveFileNoReplaceMapsAlreadyExistsToErrExist(t *testing.T) {
dir := t.TempDir()
from := filepath.Join(dir, "from")
to := filepath.Join(dir, "to")
if err := os.WriteFile(from, []byte("sidelined"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(to, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := moveFileNoReplace(from, to)
if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
t.Fatalf("MoveFileEx with an existing destination = %v, want ERROR_ALREADY_EXISTS", err)
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("ERROR_ALREADY_EXISTS must satisfy errors.Is(err, os.ErrExist), got: %v", err)
}
if data, _ := os.ReadFile(to); string(data) != "competing" {
t.Fatalf("destination was overwritten: %q", data)
}
}
// TestRemoveLockFileRetriesSharingViolation holds the file open briefly (Go
// opens files without FILE_SHARE_DELETE on Windows, so a concurrent DeleteFile
// fails with ERROR_SHARING_VIOLATION while the handle is open); RemoveLockFile
// must ride its retry loop (15 x 5ms) past the window and succeed once the
// handle closes.
func TestRemoveLockFileRetriesSharingViolation(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(10 * time.Millisecond)
_ = f.Close()
}()
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile should succeed once the contending handle closes: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
}
// TestRemoveLockFileSharingViolationExhausts verifies the retry loop is
// bounded: with the contending handle held for the whole call, RemoveLockFile
// surfaces the sharing violation instead of retrying forever.
func TestRemoveLockFileSharingViolationExhausts(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := RemoveLockFile(path); !errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
t.Fatalf("RemoveLockFile with a held handle = %v, want ERROR_SHARING_VIOLATION", err)
}
}
+82
View File
@@ -0,0 +1,82 @@
package lockutil
import (
"errors"
"os"
)
// restoreLockFile is swappable so tests can force the fail-closed path of
// ReclaimStaleLock, which requires both the fast restore and its no-replace
// fallback (with its own copy fallback) to fail; that cannot be provoked
// portably on a healthy filesystem.
var restoreLockFile = restoreLiveLock
// restoreLiveLock puts a lock that turned out to be live back at path after
// ReclaimStaleLock moved it aside to inspect it. It first tries a fast,
// replacing rename straight from reclaimed to path: a single syscall, which
// keeps the window during which path does not exist (and so is open to a
// fourth process's unrelated O_EXCL create landing on it) as short as
// possible. RestoreLockFile's no-replace restore (with its slower copy
// fallback on some failures) is a correctness-preserving fallback for when
// the fast path itself fails (e.g. a cross-device sidelined name): it is a
// much longer version of the identical race, but still detects rather than
// silently clobbers a competing lock, which the fast path's replacing rename
// cannot do. Neither path makes the race impossible, only unlikely; see
// ReclaimStaleLock's doc comment.
func restoreLiveLock(reclaimed, path string) error {
if err := os.Rename(reclaimed, path); err == nil {
return nil
}
return RestoreLockFile(reclaimed, path)
}
// ReclaimStaleLock atomically reclaims a suspected-stale lock file. It renames
// lockPath aside to "<lockPath>.stale.<suffix>" (only one racer can win the
// rename of a given file, so two racers can never both reclaim the same lock),
// then consults isLive on the moved file; if the lock turns out to be live (a
// holder reacquired it in the gap between the caller's stale check and the
// rename) it is restored rather than stolen. The suffix must be unique per
// acquirer attempt. Returns true only when a genuinely stale lock was removed,
// so the caller knows it is safe to retry its exclusive create immediately; on
// a lost race it returns false. A non-nil error means either the rename aside
// failed for a reason that is not contention (so retrying cannot help and the
// caller should fail fast instead of spinning to its deadline), or a live
// holder's lock could not be restored (both restoreLiveLock's fast path and
// its no-replace fallback failed), so lockPath may be missing; callers must
// fail closed instead of re-acquiring. The sidelined file is removed on every
// restore failure: once the restore has failed it has no protocol function
// (release only consults the lock path), so keeping it would only leak files.
//
// The live-restore path has an inherent, unclosed race: between the rename
// aside above and restoreLiveLock putting the lock back, lockPath does not
// exist, so an unrelated caller's O_EXCL create can legitimately succeed
// there. restoreLiveLock's fast path then silently overwrites that new
// claimant's lock file, which does not corrupt release (it is
// ownership-aware, so the new claimant's later release safely no-ops against
// content it no longer owns) but does mean the new claimant can still run
// its critical section concurrently with the original live holder. Making
// this race actually impossible would need an OS-level advisory lock (flock
// / LockFileEx) held for a holder's whole critical section, checked
// non-destructively instead of by moving the file; restoreLiveLock only
// shrinks the window to roughly one syscall, it does not close it.
func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) bool) (bool, error) {
reclaimed := lockPath + ".stale." + suffix
if err := os.Rename(lockPath, reclaimed); err != nil {
if errors.Is(err, os.ErrNotExist) || isReclaimContended(err) {
return false, nil // another racer already moved/removed it, or it vanished
}
return false, err
}
if isLive(reclaimed) {
// Put the live lock back instead of stealing it, and let the caller wait.
if rerr := restoreLockFile(reclaimed, lockPath); rerr != nil {
_ = RemoveLockFile(reclaimed)
if !errors.Is(rerr, os.ErrExist) {
return false, rerr
}
}
return false, nil
}
_ = RemoveLockFile(reclaimed)
return true, nil
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside: a failure that is not a lost race must reach the caller.
// Mapping it to the benign lost-race result would leave acquirers spinning
// until their deadline and reporting a timeout instead of the real cause. A
// directory squatting on the sidelined name makes os.Rename fail with EISDIR,
// which matches neither os.ErrNotExist nor a contention errno.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(lockPath+".stale.tok", 0o755); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+112
View File
@@ -0,0 +1,112 @@
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestReclaimStaleLock(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
dead := func(string) bool { return false }
live := func(string) bool { return true }
// A lock the predicate reports dead is reclaimed and removed.
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-a", dead); err != nil || !ok {
t.Fatalf("a dead lock should be reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("reclaimed dead lock should be gone, stat err=%v", err)
}
// A LIVE lock (a holder reacquired in the gap) must be RESTORED intact.
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-b", live); err != nil || ok {
t.Fatalf("a live lock must not be reclaimed (ok=%v err=%v)", ok, err)
}
if data, err := os.ReadFile(lockPath); err != nil || string(data) != "live-holder" {
t.Fatalf("live lock must be left intact, got %q err %v", data, err)
}
// A missing lock reports no reclaim (nothing to steal).
_ = os.Remove(lockPath)
if ok, err := ReclaimStaleLock(lockPath, "tok-c", live); err != nil || ok {
t.Fatalf("a missing lock should not report a reclaim (ok=%v err=%v)", ok, err)
}
}
// restoreLiveLock's fast path is a single replacing rename, chosen to
// minimize (not eliminate) the window during which lockPath is absent and
// open to an unrelated O_EXCL create; see ReclaimStaleLock's doc comment for
// why that residual race exists. This test pins the resulting behavior: the
// fast path overwrites a competing file at path rather than detecting it,
// unlike RestoreLockFile's own no-replace contract.
func TestRestoreLiveLockFastPathOverwritesCompetingFile(t *testing.T) {
dir := t.TempDir()
reclaimed := filepath.Join(dir, "lock.stale.tok")
path := filepath.Join(dir, "lock")
if err := os.WriteFile(reclaimed, []byte("original-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("new-claimant"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreLiveLock(reclaimed, path); err != nil {
t.Fatalf("restoreLiveLock failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "original-holder" {
t.Fatalf("path = %q, err %v; want the original holder's content restored", data, err)
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected reclaimed to be consumed by the rename: %v", err)
}
}
func TestReclaimStaleLockFailsClosedOnRestoreError(t *testing.T) {
// When both the no-replace restore and its copy fallback fail (only
// provokable via the seam; a healthy filesystem cannot produce it), the
// caller must receive an error so it fails closed instead of re-acquiring a
// missing lock path, and the sidelined file must not leak.
restoreLockFile = func(reclaimed, path string) error { return errors.New("restore failed") }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err == nil || ok {
t.Fatalf("a failed restore must surface an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("a failed restore must not leak sidelined files: %v", matches)
}
}
func TestReclaimStaleLockDropsSidelinedWhenNewHolderWins(t *testing.T) {
// An os.ErrExist restore failure means a new holder recreated the lock
// path; that is not an error for the caller, and the sidelined file is
// dropped rather than leaked.
restoreLockFile = func(reclaimed, path string) error { return os.ErrExist }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err != nil || ok {
t.Fatalf("losing to a new holder is not an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("the sidelined file must be dropped when a new holder wins: %v", matches)
}
}
+50
View File
@@ -0,0 +1,50 @@
//go:build windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockTreatsSharingViolationAsLostRace pins the errno
// classification the Windows acquire loops depend on: a rename aside that
// fails because the lock file is concurrently open (Go opens files without
// FILE_SHARE_DELETE, so the rename fails with ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED) is contention, not a hard failure, and must report a
// benign lost race so the caller keeps waiting.
func TestReclaimStaleLockTreatsSharingViolationAsLostRace(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("held"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(lockPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if ok, rerr := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true }); rerr != nil || ok {
t.Fatalf("a sharing-violation rename must report a benign lost race (ok=%v err=%v)", ok, rerr)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place: %v", err)
}
}
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside on Windows: a failure that is not contention must reach
// the caller instead of masquerading as a lost race. A quote is an invalid
// filename character, so the sidelined name fails with ERROR_INVALID_NAME.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, `bad"name`, func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ type Server struct {
// credential lookup uses it to avoid reusing legacy user tokens by name.
ProjectConfigured bool
// UnconfiguredDefault is true when this server is one of Zero's built-in
// defaults (e.g. keyless Exa) that the user never touched in their
// defaults (e.g. keyless Firecrawl) that the user never touched in their
// config — no credentials, no overrides. Callers use it to avoid warning
// loudly when a server nobody configured fails to connect.
UnconfiguredDefault bool
+3 -118
View File
@@ -1,8 +1,6 @@
package mcp
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -92,7 +90,7 @@ func TestNormalizeConfigValidatesTransportBoundaries(t *testing.T) {
func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"exa": config.DefaultMCPServers()["exa"],
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"web": {
Type: "http",
URL: "https://example.com/mcp",
@@ -109,8 +107,8 @@ func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
byName[server.Name] = server
}
if !byName["exa"].UnconfiguredDefault {
t.Fatal("an untouched exa default should be flagged UnconfiguredDefault")
if !byName["firecrawl"].UnconfiguredDefault {
t.Fatal("an untouched firecrawl default should be flagged UnconfiguredDefault")
}
if byName["web"].UnconfiguredDefault {
t.Fatal("a server the user configured must not be flagged UnconfiguredDefault")
@@ -242,116 +240,3 @@ func TestCopyStringMapTrimsKeysAndPreservesValues(t *testing.T) {
t.Fatalf("copied[TOKEN] = %q, want value preserved verbatim", copied["TOKEN"])
}
}
// Startup behavior for the firecrawl -> exa default migration: a user who
// disabled the retired default must end up with no Exa server to connect to,
// not merely a disabled entry in the resolved config.
func TestNormalizeConfigSkipsExaAfterLegacyFirecrawlDisable(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("NormalizeConfig: %v", err)
}
for _, server := range servers {
if server.Name == "exa" {
t.Fatalf("exa must not be started after the user disabled the default it replaced: %#v", server)
}
}
}
// The same path with no legacy disable still starts Exa, so the migration
// cannot quietly switch off the default for everyone else.
func TestNormalizeConfigStartsExaWithoutLegacyDisable(t *testing.T) {
cfg, err := config.ResolveMCP(config.ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("NormalizeConfig: %v", err)
}
for _, server := range servers {
if server.Name == "exa" {
if !server.UnconfiguredDefault {
t.Fatalf("an untouched exa default should be flagged unconfigured: %#v", server)
}
return
}
}
t.Fatal("expected the exa default to be started out of the box")
}
// The failure path CodeRabbit flagged: a firecrawl entry that named only a
// header relied on the retired default for its transport. Resolving it must
// still produce a complete server — an incomplete one fails NormalizeConfig,
// which takes down every other server's startup with it, not just this one.
func TestNormalizeConfigResolvesPartialRetiredDefaultEntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("a header-only legacy entry must not break startup: %v", err)
}
var firecrawl *Server
var sawExa bool
for i := range servers {
switch servers[i].Name {
case "firecrawl":
firecrawl = &servers[i]
case "exa":
sawExa = true
}
}
if firecrawl == nil {
t.Fatalf("expected the customized firecrawl server to survive: %#v", servers)
}
if firecrawl.Type != ServerTypeHTTP || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("firecrawl lost the transport its entry relied on: %#v", *firecrawl)
}
if firecrawl.Headers["Authorization"] != "Bearer k" {
t.Fatalf("firecrawl lost the user's header: %#v", *firecrawl)
}
if !sawExa {
t.Fatal("the new exa default should still start alongside it")
}
}
// The mirror case: an entry that names its own stdio transport must not have
// the retired http default grafted onto it, which would be rejected outright.
func TestNormalizeConfigLeavesRetiredEntryWithOwnTransportAlone(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("a self-hosted stdio entry must keep working: %v", err)
}
for _, server := range servers {
if server.Name != "firecrawl" {
continue
}
if server.Type != ServerTypeStdio || server.Command != "firecrawl-mcp" {
t.Fatalf("the user's own stdio transport was corrupted: %#v", server)
}
return
}
t.Fatalf("expected the self-hosted firecrawl server to survive: %#v", servers)
}
+14 -100
View File
@@ -63,9 +63,8 @@ func tokenToStored(t oauth.Token) StoredToken {
}
// OAuthConfig describes how to authenticate to a remote MCP server using OAuth.
// Endpoints may be discovered through RFC 9728 protected-resource metadata and
// RFC 8414 authorization-server metadata; explicit values override or fill in
// anything discovery cannot provide.
// Endpoints may be discovered from the server's metadata document; explicit
// values here override or fill in anything discovery cannot provide.
type OAuthConfig struct {
ClientID string
ClientSecret string
@@ -86,11 +85,6 @@ type authServerMetadata struct {
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
ScopesSupported []string `json:"scopes_supported"`
protectedResourceURL string
protectAuthorizationEndpoint bool
protectTokenEndpoint bool
protectRegistrationEndpoint bool
}
// pkceParams holds a PKCE verifier/challenge pair.
@@ -126,7 +120,7 @@ type authorizationFlow struct {
// discoverAuthorizationServer fetches the RFC 8414 authorization server metadata
// at the well-known path under baseURL, via the shared engine.
func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (authServerMetadata, error) {
meta, err := oauth.DiscoverAuthorizationServer(ctx, withoutDiscoveryRedirects(client), baseURL)
meta, err := oauth.DiscoverAuthorizationServer(ctx, client, baseURL)
if err != nil {
return authServerMetadata{}, err
}
@@ -139,9 +133,10 @@ func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseU
}, nil
}
// validateResolvedEndpoints applies the shared https/loopback scheme rule to
// every non-empty endpoint in the resolved metadata. Protected-resource
// discovery adds its stricter provenance-aware network rule below.
// validateResolvedEndpoints applies the shared https/loopback endpoint rule to
// every non-empty endpoint in the resolved metadata — configured or discovered
// alike — so discovery metadata can never downgrade an MCP login to an insecure
// or attacker-controlled authorization, token, or registration endpoint.
func validateResolvedEndpoints(metadata authServerMetadata) error {
for _, ep := range []struct{ kind, url string }{
{"authorization", metadata.AuthorizationEndpoint},
@@ -158,43 +153,9 @@ func validateResolvedEndpoints(metadata authServerMetadata) error {
return nil
}
func validateProtectedDiscoveredEndpoints(ctx context.Context, metadata authServerMetadata) error {
if metadata.protectedResourceURL == "" {
return nil
}
policy, err := newOAuthDiscoveryNetworkPolicy(metadata.protectedResourceURL)
if err != nil {
return err
}
for _, endpoint := range []struct {
kind string
url string
protected bool
}{
{kind: "authorization", url: metadata.AuthorizationEndpoint, protected: metadata.protectAuthorizationEndpoint},
{kind: "token", url: metadata.TokenEndpoint, protected: metadata.protectTokenEndpoint},
{kind: "registration", url: metadata.RegistrationEndpoint, protected: metadata.protectRegistrationEndpoint},
} {
if !endpoint.protected || strings.TrimSpace(endpoint.url) == "" {
continue
}
parsed, parseErr := url.Parse(endpoint.url)
if parseErr != nil || parsed.Hostname() == "" {
return fmt.Errorf("mcp oauth: %s endpoint: %w", endpoint.kind, errUnsafeOAuthDiscoveryTarget)
}
if err := policy.validateLiteralHost(parsed.Hostname()); err != nil {
return fmt.Errorf("mcp oauth: %s endpoint: %w", endpoint.kind, err)
}
if _, err := policy.resolve(ctx, parsed.Hostname()); err != nil {
return fmt.Errorf("mcp oauth: %s endpoint: %w", endpoint.kind, err)
}
}
return nil
}
// resolveAuthorizationServer follows protected-resource metadata when the MCP
// endpoint advertises it, falls back to direct authorization-server discovery
// for legacy providers, then applies explicit config overrides.
// resolveAuthorizationServer discovers metadata and applies explicit config
// overrides. Configured endpoints take precedence over discovered ones, and act
// as a fallback when discovery fails or omits a value.
func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseURL string, cfg OAuthConfig) (authServerMetadata, error) {
// When the config supplies both the authorization and token endpoints
// directly, skip network discovery entirely: there is nothing to discover,
@@ -213,40 +174,17 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
return metadata, nil
}
discoveryClient := withoutDiscoveryRedirects(client)
discoveryBase := strings.TrimSpace(cfg.IssuerURL)
protectedResourceDiscovery := false
if discoveryBase == "" {
issuer, found, protectedErr := discoverProtectedResourceAuthorizationServer(ctx, discoveryClient, baseURL)
if protectedErr != nil {
return authServerMetadata{}, protectedErr
}
if found {
discoveryBase = issuer
protectedResourceDiscovery = true
discoveryClient, protectedErr = newAdvertisedOAuthDiscoveryClient(discoveryClient, baseURL)
if protectedErr != nil {
return authServerMetadata{}, protectedErr
}
} else {
discoveryBase = baseURL
}
discoveryBase = baseURL
}
metadata, err := discoverAuthorizationServer(ctx, discoveryClient, discoveryBase)
metadata, err := discoverAuthorizationServer(ctx, client, discoveryBase)
if err != nil {
// Discovery failures are non-fatal when the config supplies the endpoints
// directly; otherwise surface the discovery error.
metadata = authServerMetadata{}
}
if protectedResourceDiscovery {
if strings.TrimSpace(metadata.Issuer) == "" {
return authServerMetadata{}, errors.New("mcp oauth: authorization server metadata is missing its required issuer")
}
if metadata.Issuer != discoveryBase {
return authServerMetadata{}, errors.New("mcp oauth: authorization server metadata issuer does not match protected resource metadata")
}
}
if endpoint := strings.TrimSpace(cfg.AuthorizationEndpoint); endpoint != "" {
metadata.AuthorizationEndpoint = endpoint
@@ -257,12 +195,6 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
if endpoint := strings.TrimSpace(cfg.RegistrationEndpoint); endpoint != "" {
metadata.RegistrationEndpoint = endpoint
}
if protectedResourceDiscovery {
metadata.protectedResourceURL = strings.TrimSpace(baseURL)
metadata.protectAuthorizationEndpoint = strings.TrimSpace(cfg.AuthorizationEndpoint) == ""
metadata.protectTokenEndpoint = strings.TrimSpace(cfg.TokenEndpoint) == ""
metadata.protectRegistrationEndpoint = strings.TrimSpace(cfg.RegistrationEndpoint) == ""
}
if strings.TrimSpace(metadata.AuthorizationEndpoint) == "" {
return authServerMetadata{}, errors.New("no authorization endpoint discovered or configured")
@@ -273,9 +205,6 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
if err := validateResolvedEndpoints(metadata); err != nil {
return authServerMetadata{}, err
}
if err := validateProtectedDiscoveredEndpoints(ctx, metadata); err != nil {
return authServerMetadata{}, err
}
return metadata, nil
}
@@ -434,25 +363,10 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
}
defer listener.Close()
redirectURI := fmt.Sprintf("http://%s/callback", listener.Addr().String())
protectedEndpointClient := httpClient
if metadata.protectRegistrationEndpoint || metadata.protectTokenEndpoint {
protectedEndpointClient, err = newAdvertisedOAuthDiscoveryClient(httpClient, metadata.protectedResourceURL)
if err != nil {
return StoredToken{}, err
}
}
tokenClient := httpClient
if metadata.protectTokenEndpoint {
tokenClient = protectedEndpointClient
}
if strings.TrimSpace(cfg.ClientID) == "" {
if registration := strings.TrimSpace(metadata.RegistrationEndpoint); registration != "" {
registrationClient := httpClient
if metadata.protectRegistrationEndpoint {
registrationClient = protectedEndpointClient
}
clientID, clientSecret, regErr := registerClient(loginCtx, registrationClient, registration, redirectURI, cfg.Scopes)
clientID, clientSecret, regErr := registerClient(loginCtx, httpClient, registration, redirectURI, cfg.Scopes)
if regErr != nil {
return StoredToken{}, regErr
}
@@ -476,7 +390,7 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
}
flow := &authorizationFlow{
httpClient: tokenClient,
httpClient: httpClient,
metadata: metadata,
config: cfg,
pkce: pkce,
-290
View File
@@ -1,290 +0,0 @@
package mcp
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
)
var errUnsafeOAuthDiscoveryTarget = errors.New("mcp oauth: refused non-public server-advertised discovery target")
var nonPublicOAuthDiscoveryPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/4"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001:2::/48"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
}
type oauthDiscoveryNetworkPolicy struct {
allowLoopback bool
lookupNetIP func(context.Context, string, string) ([]netip.Addr, error)
}
type advertisedOAuthDiscoveryTransport struct {
base http.RoundTripper
policy oauthDiscoveryNetworkPolicy
}
// newAdvertisedOAuthDiscoveryClient binds server-advertised discovery to the
// public network. Loopback targets remain available only when the operator
// explicitly configured a loopback MCP resource.
func newAdvertisedOAuthDiscoveryClient(client *http.Client, resourceURL string) (*http.Client, error) {
policy, err := newOAuthDiscoveryNetworkPolicy(resourceURL)
if err != nil {
return nil, err
}
copied := withoutDiscoveryRedirects(client)
transport := copied.Transport
if transport == nil {
transport = http.DefaultTransport
}
copied.Transport = advertisedOAuthDiscoveryTransport{base: transport, policy: policy}
return copied, nil
}
func newOAuthDiscoveryNetworkPolicy(resourceURL string) (oauthDiscoveryNetworkPolicy, error) {
parsed, err := url.Parse(strings.TrimSpace(resourceURL))
if err != nil || parsed.Hostname() == "" {
return oauthDiscoveryNetworkPolicy{}, errors.New("mcp oauth: invalid MCP resource URL for discovery policy")
}
return oauthDiscoveryNetworkPolicy{allowLoopback: isExplicitOAuthLoopbackHost(parsed.Hostname())}, nil
}
func validateAdvertisedOAuthDiscoveryURL(resourceURL string, targetURL string) error {
policy, err := newOAuthDiscoveryNetworkPolicy(resourceURL)
if err != nil {
return err
}
parsed, err := url.Parse(targetURL)
if err != nil || parsed.Hostname() == "" {
return errUnsafeOAuthDiscoveryTarget
}
return policy.validateLiteralHost(parsed.Hostname())
}
func (transport advertisedOAuthDiscoveryTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if request == nil || request.URL == nil {
return nil, errors.New("mcp oauth: discovery request has no URL")
}
if err := transport.policy.validateLiteralHost(request.URL.Hostname()); err != nil {
return nil, err
}
baseTransport, ok := transport.base.(*http.Transport)
if !ok {
return nil, errors.New("mcp oauth: cannot enforce discovery network policy with a custom HTTP transport")
}
addresses, err := transport.policy.resolve(request.Context(), request.URL.Hostname())
if err != nil {
return nil, err
}
var lastErr error
for index, address := range addresses {
attempt := request
if index > 0 && request.Body != nil {
if request.GetBody == nil {
break
}
body, bodyErr := request.GetBody()
if bodyErr != nil {
return nil, bodyErr
}
attempt = request.Clone(request.Context())
attempt.Body = body
}
response, roundTripErr := roundTripPinnedOAuthDiscovery(attempt, baseTransport, address)
if roundTripErr == nil {
return response, nil
}
lastErr = roundTripErr
}
if lastErr == nil {
lastErr = errUnsafeOAuthDiscoveryTarget
}
return nil, lastErr
}
func roundTripPinnedOAuthDiscovery(request *http.Request, base *http.Transport, address netip.Addr) (*http.Response, error) {
pinnedRequest := request.Clone(request.Context())
pinnedURL := *request.URL
originalHost := request.URL.Hostname()
port := request.URL.Port()
if port == "" {
switch strings.ToLower(request.URL.Scheme) {
case "http":
port = "80"
case "https":
port = "443"
default:
return nil, errUnsafeOAuthDiscoveryTarget
}
}
pinnedURL.Host = net.JoinHostPort(address.String(), port)
pinnedRequest.URL = &pinnedURL
if pinnedRequest.Host == "" {
pinnedRequest.Host = request.URL.Host
}
pinnedTransport := base.Clone()
pinnedTransport.DisableKeepAlives = true
var proxyURL *url.URL
if base.Proxy != nil {
var err error
proxyURL, err = base.Proxy(request)
if err != nil {
return nil, fmt.Errorf("mcp oauth: resolve discovery proxy: %w", err)
}
if proxyURL == nil {
pinnedTransport.Proxy = nil
} else {
pinnedTransport.Proxy = http.ProxyURL(proxyURL)
}
}
if pinnedTransport.TLSClientConfig == nil {
pinnedTransport.TLSClientConfig = &tls.Config{ServerName: originalHost} //nolint:gosec // standard verification remains enabled
} else {
pinnedTransport.TLSClientConfig = pinnedTransport.TLSClientConfig.Clone()
pinnedTransport.TLSClientConfig.ServerName = originalHost
}
// A custom TLS dialer must not resolve the advertised hostname again after
// the policy has pinned it. For an HTTPS proxy, however, that proxy remains
// the TLS first hop and the transport sends CONNECT to the pinned target.
dialContext := pinnedTransport.DialContext
if dialContext == nil {
dialContext = (&net.Dialer{}).DialContext
}
pinnedAddress := net.JoinHostPort(address.String(), port)
targetTLSConfig := pinnedTransport.TLSClientConfig
pinnedTransport.DialTLSContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
if proxyURL != nil && strings.EqualFold(proxyURL.Scheme, "https") {
proxyTLSConfig := targetTLSConfig.Clone()
proxyTLSConfig.ServerName = proxyURL.Hostname()
return dialOAuthDiscoveryTLS(ctx, dialContext, network, address, proxyTLSConfig)
}
return dialOAuthDiscoveryTLS(ctx, dialContext, network, pinnedAddress, targetTLSConfig)
}
return pinnedTransport.RoundTrip(pinnedRequest)
}
func dialOAuthDiscoveryTLS(ctx context.Context, dialContext func(context.Context, string, string) (net.Conn, error), network string, address string, config *tls.Config) (net.Conn, error) {
connection, err := dialContext(ctx, network, address)
if err != nil {
return nil, err
}
tlsConnection := tls.Client(connection, config)
if err := tlsConnection.HandshakeContext(ctx); err != nil {
connection.Close()
return nil, err
}
return tlsConnection, nil
}
func (policy oauthDiscoveryNetworkPolicy) resolve(ctx context.Context, host string) ([]netip.Addr, error) {
if address, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
address = address.Unmap()
if err := policy.validateAddress(address); err != nil {
return nil, err
}
return []netip.Addr{address}, nil
}
lookupNetIP := policy.lookupNetIP
if lookupNetIP == nil {
lookupNetIP = net.DefaultResolver.LookupNetIP
}
addresses, err := lookupNetIP(ctx, "ip", host)
if err != nil {
return nil, errors.New("mcp oauth: resolve server-advertised discovery target failed")
}
if len(addresses) == 0 {
return nil, errors.New("mcp oauth: server-advertised discovery target resolved to no addresses")
}
explicitLoopback := isExplicitOAuthLoopbackHost(host)
resolved := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
address = address.Unmap()
if explicitLoopback && !address.IsLoopback() {
return nil, errUnsafeOAuthDiscoveryTarget
}
if err := policy.validateAddress(address); err != nil {
return nil, err
}
resolved = append(resolved, address)
}
return resolved, nil
}
func (policy oauthDiscoveryNetworkPolicy) validateLiteralHost(host string) error {
normalized := strings.ToLower(strings.TrimSuffix(strings.Trim(host, "[]"), "."))
if normalized == "" {
return errUnsafeOAuthDiscoveryTarget
}
if isCloudMetadataHostname(normalized) {
return errUnsafeOAuthDiscoveryTarget
}
if isExplicitOAuthLoopbackHost(normalized) && !policy.allowLoopback {
return errUnsafeOAuthDiscoveryTarget
}
if address, err := netip.ParseAddr(normalized); err == nil {
return policy.validateAddress(address.Unmap())
}
return nil
}
func (policy oauthDiscoveryNetworkPolicy) validateAddress(address netip.Addr) error {
if policy.allowLoopback && address.IsLoopback() {
return nil
}
if !address.IsValid() || !address.IsGlobalUnicast() || address.IsPrivate() {
return errUnsafeOAuthDiscoveryTarget
}
for _, prefix := range nonPublicOAuthDiscoveryPrefixes {
if prefix.Contains(address) {
return errUnsafeOAuthDiscoveryTarget
}
}
return nil
}
func isExplicitOAuthLoopbackHost(host string) bool {
normalized := strings.ToLower(strings.TrimSuffix(strings.Trim(host, "[]"), "."))
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
return true
}
address, err := netip.ParseAddr(normalized)
return err == nil && address.Unmap().IsLoopback()
}
func isCloudMetadataHostname(host string) bool {
switch strings.ToLower(strings.TrimSuffix(host, ".")) {
case "metadata", "metadata.google.internal", "metadata.azure.internal":
return true
default:
return false
}
}
@@ -1,180 +0,0 @@
package mcp
import (
"bufio"
"context"
"errors"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"sync/atomic"
"testing"
)
func TestOAuthDiscoveryNetworkPolicyRejectsNonPublicAddresses(t *testing.T) {
policy := oauthDiscoveryNetworkPolicy{}
for _, value := range []string{
"127.0.0.1",
"10.0.0.1",
"169.254.169.254",
"192.0.2.10",
"224.0.0.1",
"::1",
"fc00::1",
"fe80::1",
"2001:db8::1",
} {
t.Run(value, func(t *testing.T) {
if err := policy.validateAddress(netip.MustParseAddr(value)); err == nil {
t.Fatalf("address %s should be rejected", value)
}
})
}
for _, value := range []string{"8.8.8.8", "2606:4700:4700::1111"} {
if err := policy.validateAddress(netip.MustParseAddr(value)); err != nil {
t.Fatalf("public address %s rejected: %v", value, err)
}
}
loopbackPolicy := oauthDiscoveryNetworkPolicy{allowLoopback: true}
if err := loopbackPolicy.validateAddress(netip.MustParseAddr("127.0.0.1")); err != nil {
t.Fatalf("explicit loopback resource should allow loopback discovery: %v", err)
}
if err := loopbackPolicy.validateAddress(netip.MustParseAddr("10.0.0.1")); err == nil {
t.Fatal("loopback development must not permit arbitrary private targets")
}
}
func TestProtectedDiscoveryRejectsNonPublicOAuthEndpoints(t *testing.T) {
for _, endpoint := range []struct {
name string
metadata authServerMetadata
}{
{
name: "authorization",
metadata: authServerMetadata{
AuthorizationEndpoint: "http://127.0.0.1/authorize",
protectAuthorizationEndpoint: true,
},
},
{
name: "token",
metadata: authServerMetadata{
TokenEndpoint: "https://10.0.0.1/token",
protectTokenEndpoint: true,
},
},
{
name: "registration",
metadata: authServerMetadata{
RegistrationEndpoint: "https://169.254.169.254/register",
protectRegistrationEndpoint: true,
},
},
} {
t.Run(endpoint.name, func(t *testing.T) {
endpoint.metadata.protectedResourceURL = "https://mcp.example/mcp"
if err := validateProtectedDiscoveredEndpoints(context.Background(), endpoint.metadata); err == nil {
t.Fatalf("protected %s endpoint should be rejected", endpoint.name)
}
})
}
}
func TestAdvertisedOAuthDiscoveryRejectsHostnameResolvingPrivateAtUseTime(t *testing.T) {
var dialed atomic.Bool
policy := oauthDiscoveryNetworkPolicy{
lookupNetIP: func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("10.0.0.12")}, nil
},
}
client := &http.Client{Transport: advertisedOAuthDiscoveryTransport{
base: &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) {
dialed.Store(true)
return nil, io.EOF
}},
policy: policy,
}}
_, err := client.Get("https://auth.example/.well-known/oauth-authorization-server")
if err == nil || !strings.Contains(err.Error(), errUnsafeOAuthDiscoveryTarget.Error()) {
t.Fatalf("error = %v, want private-resolution rejection", err)
}
if dialed.Load() {
t.Fatal("private resolved address reached the dialer")
}
}
func TestAdvertisedOAuthDiscoveryPinsResolvedAddress(t *testing.T) {
const publicAddress = "93.184.216.34"
var dialedAddress string
policy := oauthDiscoveryNetworkPolicy{
lookupNetIP: func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr(publicAddress)}, nil
},
}
baseTransport := &http.Transport{DialContext: func(_ context.Context, _, address string) (net.Conn, error) {
dialedAddress = address
clientConn, serverConn := net.Pipe()
go func() {
defer serverConn.Close()
reader := bufio.NewReader(serverConn)
for {
line, err := reader.ReadString('\n')
if err != nil || line == "\r\n" {
break
}
}
_, _ = io.WriteString(serverConn, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
}()
return clientConn, nil
}}
client := &http.Client{Transport: advertisedOAuthDiscoveryTransport{base: baseTransport, policy: policy}}
response, err := client.Get("http://auth.example/metadata")
if err != nil {
t.Fatalf("GET pinned discovery target: %v", err)
}
response.Body.Close()
if dialedAddress != net.JoinHostPort(publicAddress, "80") {
t.Fatalf("dialed %q, want resolved address", dialedAddress)
}
}
func TestPinnedOAuthDiscoveryPreservesHTTPSProxyFirstHop(t *testing.T) {
for _, test := range []struct {
name string
proxy string
firstHop string
}{
{name: "HTTP", proxy: "http://proxy.example:8080", firstHop: "proxy.example:8080"},
{name: "HTTPS", proxy: "https://proxy.example:8443", firstHop: "proxy.example:8443"},
{name: "SOCKS", proxy: "socks5://proxy.example:1080", firstHop: "proxy.example:1080"},
} {
t.Run(test.name, func(t *testing.T) {
proxyURL, err := url.Parse(test.proxy)
if err != nil {
t.Fatalf("parse proxy URL: %v", err)
}
var dialedAddress string
baseTransport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DialContext: func(_ context.Context, _, address string) (net.Conn, error) {
dialedAddress = address
return nil, errors.New("stop after observing first hop")
},
}
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://auth.example/metadata", nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
_, _ = roundTripPinnedOAuthDiscovery(request, baseTransport, netip.MustParseAddr("93.184.216.34"))
if dialedAddress != test.firstHop {
t.Fatalf("dialed %q, want %s proxy first hop", dialedAddress, test.name)
}
})
}
}
-233
View File
@@ -1,233 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/Gitlawb/zero/internal/oauth"
)
const protectedResourceMetadataLimit = 1 << 20
type protectedResourceMetadata struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
}
// withoutDiscoveryRedirects returns a shallow client copy that exposes 3xx
// responses to the caller. Every discovered URL is validated before use, so a
// redirect must never replace that validated target with an unchecked one.
func withoutDiscoveryRedirects(client *http.Client) *http.Client {
if client == nil {
client = http.DefaultClient
}
copied := *client
// Discovery requests are deliberately unauthenticated. A caller-supplied
// jar must not turn a plain metadata GET into a credential-bearing request.
copied.Jar = nil
copied.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return &copied
}
// discoverProtectedResourceAuthorizationServer follows the RFC 9728 URL
// advertised by a protected MCP resource. found is false when the endpoint does
// not advertise protected-resource metadata, allowing legacy direct discovery.
func discoverProtectedResourceAuthorizationServer(ctx context.Context, client *http.Client, resourceURL string) (issuer string, found bool, err error) {
resourceURL = strings.TrimSpace(resourceURL)
client = withoutDiscoveryRedirects(client)
metadataURL, found, err := probeProtectedResourceMetadataURL(ctx, client, resourceURL)
if err != nil || !found {
return "", found, err
}
if err := validateResourceMetadataOrigin(resourceURL, metadataURL); err != nil {
return "", true, err
}
client, err = newAdvertisedOAuthDiscoveryClient(client, resourceURL)
if err != nil {
return "", true, err
}
metadata, err := fetchProtectedResourceMetadata(ctx, client, metadataURL)
if err != nil {
return "", true, err
}
if metadata.Resource != resourceURL {
return "", true, errors.New("mcp oauth: protected resource metadata resource does not match the MCP server URL")
}
if len(metadata.AuthorizationServers) == 0 {
return "", true, errors.New("mcp oauth: protected resource metadata has no authorization_servers")
}
issuer = metadata.AuthorizationServers[0]
if strings.TrimSpace(issuer) == "" {
return "", true, errors.New("mcp oauth: protected resource metadata has an empty authorization server")
}
if issuer != strings.TrimSpace(issuer) {
return "", true, errors.New("mcp oauth: protected resource authorization server must not contain surrounding whitespace")
}
if err := oauth.ValidateEndpointURL(issuer); err != nil {
return "", true, fmt.Errorf("mcp oauth: protected resource authorization server: %w", err)
}
if err := validateAdvertisedOAuthDiscoveryURL(resourceURL, issuer); err != nil {
return "", true, err
}
return issuer, true, nil
}
func validateResourceMetadataOrigin(resourceURL string, metadataURL string) error {
resource, resourceErr := url.Parse(resourceURL)
metadata, metadataErr := url.Parse(metadataURL)
if resourceErr != nil || metadataErr != nil || !sameMCPOrigin(resource, metadata) {
return errors.New("mcp oauth: protected resource metadata URL must be same-origin with the MCP server URL")
}
return nil
}
func probeProtectedResourceMetadataURL(ctx context.Context, client *http.Client, resourceURL string) (string, bool, error) {
if client == nil {
client = http.DefaultClient
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(resourceURL), nil)
if err != nil {
return "", false, errors.New("mcp oauth: invalid MCP resource URL")
}
request.Header.Set("Accept", "application/json, text/event-stream")
request.Header.Set("MCP-Protocol-Version", defaultProtocolVersion)
response, err := client.Do(request)
if err != nil {
// A resource that cannot be probed may still support the legacy direct
// authorization-server discovery flow.
return "", false, nil
}
defer response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
return "", false, nil
}
metadataURL, found, err := parseResourceMetadataURL(response.Header.Values("WWW-Authenticate"))
if err != nil {
return "", false, fmt.Errorf("mcp oauth: invalid WWW-Authenticate resource_metadata parameter: %w", err)
}
if !found {
return "", false, nil
}
if err := oauth.ValidateEndpointURL(metadataURL); err != nil {
return "", false, fmt.Errorf("mcp oauth: protected resource metadata URL: %w", err)
}
return metadataURL, true, nil
}
func fetchProtectedResourceMetadata(ctx context.Context, client *http.Client, metadataURL string) (protectedResourceMetadata, error) {
if client == nil {
client = http.DefaultClient
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
if err != nil {
return protectedResourceMetadata{}, errors.New("mcp oauth: invalid protected resource metadata URL")
}
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return protectedResourceMetadata{}, errors.New("mcp oauth: fetch protected resource metadata failed")
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return protectedResourceMetadata{}, fmt.Errorf("mcp oauth: protected resource metadata returned HTTP %d", response.StatusCode)
}
var metadata protectedResourceMetadata
if err := json.NewDecoder(io.LimitReader(response.Body, protectedResourceMetadataLimit)).Decode(&metadata); err != nil {
return protectedResourceMetadata{}, errors.New("mcp oauth: decode protected resource metadata failed")
}
return metadata, nil
}
func parseResourceMetadataURL(headerValues []string) (string, bool, error) {
var selected string
for _, headerValue := range headerValues {
for index := 0; index < len(headerValue); {
if headerValue[index] == '"' {
_, next, err := readAuthQuotedString(headerValue, index)
if err != nil {
// Malformed parameters for another authentication scheme do
// not suppress a valid challenge in a separate header value.
break
}
index = next
continue
}
if !isAuthTokenByte(headerValue[index]) {
index++
continue
}
start := index
for index < len(headerValue) && isAuthTokenByte(headerValue[index]) {
index++
}
name := headerValue[start:index]
for index < len(headerValue) && (headerValue[index] == ' ' || headerValue[index] == '\t') {
index++
}
if index >= len(headerValue) || headerValue[index] != '=' {
continue
}
index++
for index < len(headerValue) && (headerValue[index] == ' ' || headerValue[index] == '\t') {
index++
}
if !strings.EqualFold(name, "resource_metadata") {
continue
}
if index >= len(headerValue) || headerValue[index] != '"' {
return "", false, errors.New("resource_metadata must be a quoted URL")
}
value, next, err := readAuthQuotedString(headerValue, index)
if err != nil {
return "", false, err
}
value = strings.TrimSpace(value)
if value == "" {
return "", false, errors.New("resource_metadata URL is empty")
}
if selected != "" && selected != value {
return "", false, errors.New("conflicting resource_metadata URLs")
}
selected = value
index = next
}
}
return selected, selected != "", nil
}
func readAuthQuotedString(value string, quote int) (string, int, error) {
var decoded strings.Builder
for index := quote + 1; index < len(value); index++ {
switch value[index] {
case '"':
return decoded.String(), index + 1, nil
case '\\':
index++
if index >= len(value) {
return "", 0, errors.New("unterminated quoted resource_metadata URL")
}
decoded.WriteByte(value[index])
default:
if value[index] < 0x20 || value[index] == 0x7f {
return "", 0, errors.New("resource_metadata URL contains a control character")
}
decoded.WriteByte(value[index])
}
}
return "", 0, errors.New("unterminated quoted resource_metadata URL")
}
func isAuthTokenByte(value byte) bool {
if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' {
return true
}
return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(value))
}
@@ -1,742 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestParseResourceMetadataURL(t *testing.T) {
got, found, err := parseResourceMetadataURL([]string{
`Basic realm="resource_metadata=not-a-parameter"`,
`Bearer realm="OAuth", scope="read", Resource_Metadata="https://mcp.example/resource-metadata"`,
})
if err != nil || !found {
t.Fatalf("parseResourceMetadataURL() found=%v err=%v", found, err)
}
if got != "https://mcp.example/resource-metadata" {
t.Fatalf("resource metadata URL = %q", got)
}
got, found, err = parseResourceMetadataURL([]string{
`Basic realm="unterminated`,
`Bearer resource_metadata="https://mcp.example/fallback-metadata"`,
})
if err != nil || !found || got != "https://mcp.example/fallback-metadata" {
t.Fatalf("unrelated malformed challenge blocked discovery: got=%q found=%v err=%v", got, found, err)
}
for name, headers := range map[string][]string{
"unquoted": {`Bearer resource_metadata=https://mcp.example/metadata`},
"unterminated": {`Bearer resource_metadata="https://mcp.example/metadata`},
"conflicting": {
`Bearer resource_metadata="https://mcp.example/one"`,
`DPoP resource_metadata="https://mcp.example/two"`,
},
} {
t.Run(name, func(t *testing.T) {
if _, _, err := parseResourceMetadataURL(headers); err == nil {
t.Fatal("expected malformed resource_metadata rejection")
}
})
}
}
func TestProtectedResourceDiscoveryRejectsInvalidMetadata(t *testing.T) {
for _, test := range []struct {
name string
challenge func(base string) string
metadata func(base string) string
wantError string
}{
{
name: "malformed challenge",
challenge: func(string) string { return `Bearer resource_metadata="unterminated` },
wantError: "WWW-Authenticate",
},
{
name: "insecure metadata URL",
challenge: func(string) string { return `Bearer resource_metadata="http://metadata.example/resource"` },
wantError: "metadata URL",
},
{
name: "invalid JSON",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(string) string { return `{` },
wantError: "decode protected resource metadata",
},
{
name: "resource mismatch",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":"` + base + `/other","authorization_servers":["https://auth.example"]}`
},
wantError: "does not match",
},
{
name: "resource leading whitespace mismatch",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":" ` + base + `/mcp","authorization_servers":["https://auth.example"]}`
},
wantError: "does not match",
},
{
name: "resource trailing whitespace mismatch",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":"` + base + `/mcp ","authorization_servers":["https://auth.example"]}`
},
wantError: "does not match",
},
{
name: "authorization servers missing",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":"` + base + `/mcp"}`
},
wantError: "authorization_servers",
},
{
name: "insecure authorization server",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":"` + base + `/mcp","authorization_servers":["http://auth.example"]}`
},
wantError: "authorization server",
},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", test.challenge(base))
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
if test.metadata == nil {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(test.metadata(base)))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
_, _, err := discoverProtectedResourceAuthorizationServer(context.Background(), server.Client(), server.URL+"/mcp")
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("error = %v, want %q", err, test.wantError)
}
})
}
}
func TestProtectedResourceProbeSendsNoCredentials(t *testing.T) {
var authorization, cookie string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authorization = r.Header.Get("Authorization")
cookie = r.Header.Get("Cookie")
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{Name: "session", Value: "secret"}})
client := server.Client()
client.Jar = jar
_, found, err := discoverProtectedResourceAuthorizationServer(context.Background(), client, server.URL+"/mcp")
if err != nil || found {
t.Fatalf("discovery found=%v err=%v", found, err)
}
if authorization != "" || cookie != "" {
t.Fatalf("probe sent credentials: authorization=%q cookie=%q", authorization, cookie)
}
}
func TestDirectAuthorizationServerDiscoverySendsNoCookies(t *testing.T) {
var cookie string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie = r.Header.Get("Cookie")
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer server.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{Name: "session", Value: "secret"}})
client := server.Client()
client.Jar = jar
if _, err := resolveAuthorizationServer(context.Background(), client, server.URL, OAuthConfig{}); err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if cookie != "" {
t.Fatalf("direct discovery sent cookie %q", cookie)
}
}
func TestProtectedResourceDiscoveryRejectsAdvertisedLoopbackMetadata(t *testing.T) {
var metadataHits atomic.Int64
client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
response := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: request,
}
switch request.URL.String() {
case "https://mcp.example/mcp":
response.StatusCode = http.StatusUnauthorized
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="http://127.0.0.1/metadata"`)
case "http://127.0.0.1/metadata":
metadataHits.Add(1)
response.Body = io.NopCloser(strings.NewReader(`{"resource":"https://mcp.example/mcp","authorization_servers":["https://auth.example"]}`))
default:
response.StatusCode = http.StatusNotFound
}
return response, nil
})}
_, _, err := discoverProtectedResourceAuthorizationServer(context.Background(), client, "https://mcp.example/mcp")
if err == nil {
t.Fatal("advertised loopback metadata URL should be rejected")
}
if metadataHits.Load() != 0 {
t.Fatalf("loopback metadata target was fetched %d time(s)", metadataHits.Load())
}
}
func TestProtectedResourceDiscoveryRejectsAdvertisedLoopbackIssuer(t *testing.T) {
var issuerHits atomic.Int64
client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
response := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: request,
}
switch request.URL.String() {
case "https://mcp.example/mcp":
response.StatusCode = http.StatusUnauthorized
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="https://mcp.example/metadata"`)
case "https://mcp.example/metadata":
response.Body = io.NopCloser(strings.NewReader(`{"resource":"https://mcp.example/mcp","authorization_servers":["http://127.0.0.1"]}`))
case "http://127.0.0.1/.well-known/oauth-authorization-server":
issuerHits.Add(1)
response.Body = io.NopCloser(strings.NewReader(`{"issuer":"http://127.0.0.1","authorization_endpoint":"http://127.0.0.1/authorize","token_endpoint":"http://127.0.0.1/token"}`))
default:
response.StatusCode = http.StatusNotFound
}
return response, nil
})}
_, err := resolveAuthorizationServer(context.Background(), client, "https://mcp.example/mcp", OAuthConfig{})
if err == nil {
t.Fatal("advertised loopback issuer should be rejected")
}
if issuerHits.Load() != 0 {
t.Fatalf("loopback issuer target was fetched %d time(s)", issuerHits.Load())
}
}
func TestProtectedResourceDiscoveryRejectsAdvertisedPrivateTargets(t *testing.T) {
for _, target := range []string{
"http://10.0.0.8/metadata",
"https://169.254.169.254/metadata",
"https://metadata.google.internal/metadata",
} {
t.Run(target, func(t *testing.T) {
if err := validateAdvertisedOAuthDiscoveryURL("https://mcp.example/mcp", target); err == nil {
t.Fatalf("advertised target %q should be rejected", target)
}
})
}
}
func TestProtectedDiscoveryPathsSendNoCookies(t *testing.T) {
var probeCookie, resourceCookie, issuerCookie string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
probeCookie = r.Header.Get("Cookie")
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
resourceCookie = r.Header.Get("Cookie")
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{base},
})
case "/.well-known/oauth-authorization-server":
issuerCookie = r.Header.Get("Cookie")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{Name: "session", Value: "secret"}})
client := server.Client()
client.Jar = jar
if _, err := resolveAuthorizationServer(context.Background(), client, server.URL+"/mcp", OAuthConfig{}); err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if probeCookie != "" || resourceCookie != "" || issuerCookie != "" {
t.Fatalf("discovery sent cookies: probe=%q resource=%q issuer=%q", probeCookie, resourceCookie, issuerCookie)
}
}
func TestProtectedDiscoveryPreservesExplicitEndpointOverride(t *testing.T) {
fixture := newPublicProtectedOAuthFixture(t, "", "https://93.184.216.35/token", "", "")
explicitToken := "http://127.0.0.1:19432/token"
metadata, err := resolveAuthorizationServer(context.Background(), fixture.client, fixture.resourceURL, OAuthConfig{TokenEndpoint: explicitToken})
if err != nil {
t.Fatalf("resolve with explicit endpoint override: %v", err)
}
if metadata.TokenEndpoint != explicitToken || metadata.protectTokenEndpoint {
t.Fatalf("explicit token override lost provenance: endpoint=%q protected=%v", metadata.TokenEndpoint, metadata.protectTokenEndpoint)
}
}
func TestProtectedDiscoveryRejectsLoopbackRegistrationEndpointBeforeUse(t *testing.T) {
var registrationHits atomic.Int64
registration := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
registrationHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"client_id": "registered-client"})
}))
defer registration.Close()
fixture := newPublicProtectedOAuthFixture(t, registration.URL+"/register", "https://93.184.216.35/token", "", "")
_, err := Login(context.Background(), LoginOptions{
ServerName: "registration-ssrf",
ServerURL: fixture.resourceURL,
HTTPClient: fixture.client,
OpenBrowser: func(string) error {
return errors.New("authorization browser should not open")
},
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("protected discovery should reject a loopback registration endpoint")
}
if registrationHits.Load() != 0 {
t.Fatalf("loopback registration endpoint received %d request(s)", registrationHits.Load())
}
}
func TestProtectedDiscoveryRejectsLoopbackTokenEndpointBeforeUse(t *testing.T) {
var tokenHits atomic.Int64
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
tokenHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "unexpected",
"token_type": "Bearer",
})
}))
defer tokenServer.Close()
fixture := newPublicProtectedOAuthFixture(t, "", tokenServer.URL+"/token", "", "")
_, err := Login(context.Background(), LoginOptions{
ServerName: "token-ssrf",
ServerURL: fixture.resourceURL,
Config: OAuthConfig{ClientID: "configured-client"},
HTTPClient: fixture.client,
OpenBrowser: func(authURL string) error {
parsed, parseErr := url.Parse(authURL)
if parseErr != nil {
return parseErr
}
callbackURL := parsed.Query().Get("redirect_uri") + "?code=code&state=" + url.QueryEscape(parsed.Query().Get("state"))
go func() {
for attempt := 0; attempt < 20; attempt++ {
response, requestErr := http.Get(callbackURL)
if requestErr == nil {
response.Body.Close()
return
}
time.Sleep(25 * time.Millisecond)
}
}()
return nil
},
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("protected discovery should reject a loopback token endpoint")
}
if tokenHits.Load() != 0 {
t.Fatalf("loopback token endpoint received %d request(s)", tokenHits.Load())
}
}
func TestProtectedDiscoveryRegistrationRefusesPublicToPrivateRedirect(t *testing.T) {
var victimHits atomic.Int64
victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
victimHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"client_id": "redirected-client"})
}))
defer victim.Close()
fixture := newPublicProtectedOAuthFixture(t, "", "https://93.184.216.35/token", victim.URL+"/register", "")
_, err := Login(context.Background(), LoginOptions{
ServerName: "registration-redirect",
ServerURL: fixture.resourceURL,
HTTPClient: fixture.client,
OpenBrowser: func(string) error {
return errors.New("authorization browser should not open")
},
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("protected registration redirect should be refused")
}
if victimHits.Load() != 0 {
t.Fatalf("registration redirect reached private victim %d time(s)", victimHits.Load())
}
}
func TestProtectedDiscoveryTokenRefusesPublicToPrivateRedirect(t *testing.T) {
var victimHits atomic.Int64
victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
victimHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "redirected-token", "token_type": "Bearer"})
}))
defer victim.Close()
fixture := newPublicProtectedOAuthFixture(t, "", "", "", victim.URL+"/token")
_, err := Login(context.Background(), LoginOptions{
ServerName: "token-redirect",
ServerURL: fixture.resourceURL,
Config: OAuthConfig{ClientID: "configured-client"},
HTTPClient: fixture.client,
OpenBrowser: func(authURL string) error {
parsed, parseErr := url.Parse(authURL)
if parseErr != nil {
return parseErr
}
callbackURL := parsed.Query().Get("redirect_uri") + "?code=code&state=" + url.QueryEscape(parsed.Query().Get("state"))
go func() {
for attempt := 0; attempt < 20; attempt++ {
response, requestErr := http.Get(callbackURL)
if requestErr == nil {
response.Body.Close()
return
}
time.Sleep(25 * time.Millisecond)
}
}()
return nil
},
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("protected token redirect should be refused")
}
if victimHits.Load() != 0 {
t.Fatalf("token redirect reached private victim %d time(s)", victimHits.Load())
}
}
type publicProtectedOAuthFixture struct {
client *http.Client
resourceURL string
}
func newPublicProtectedOAuthFixture(t *testing.T, registrationEndpoint string, tokenEndpoint string, registrationRedirect string, tokenRedirect string) publicProtectedOAuthFixture {
t.Helper()
var resourceOrigin, issuerOrigin string
gateway := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resourceHost := strings.TrimPrefix(resourceOrigin, "https://")
issuerHost := strings.TrimPrefix(issuerOrigin, "https://")
switch {
case r.Host == resourceHost && r.URL.Path == "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+resourceOrigin+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case r.Host == resourceHost && r.URL.Path == "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": resourceOrigin + "/mcp",
"authorization_servers": []string{issuerOrigin},
})
case r.Host == issuerHost && r.URL.Path == "/.well-known/oauth-authorization-server":
metadataRegistration := registrationEndpoint
metadataToken := tokenEndpoint
if registrationRedirect != "" {
metadataRegistration = issuerOrigin + "/register"
}
if tokenRedirect != "" {
metadataToken = issuerOrigin + "/token"
}
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuerOrigin,
"authorization_endpoint": issuerOrigin + "/authorize",
"token_endpoint": metadataToken,
"registration_endpoint": metadataRegistration,
})
case r.Host == issuerHost && r.URL.Path == "/register" && registrationRedirect != "":
http.Redirect(w, r, registrationRedirect, http.StatusTemporaryRedirect)
case r.Host == issuerHost && r.URL.Path == "/token" && tokenRedirect != "":
http.Redirect(w, r, tokenRedirect, http.StatusTemporaryRedirect)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(gateway.Close)
port := gateway.Listener.Addr().(*net.TCPAddr).Port
resourceOrigin = fmt.Sprintf("https://93.184.216.34:%d", port)
issuerOrigin = fmt.Sprintf("https://93.184.216.35:%d", port)
transport := gateway.Client().Transport.(*http.Transport).Clone()
transport.TLSClientConfig = transport.TLSClientConfig.Clone()
transport.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // hermetic public-address routing fixture
dialer := &net.Dialer{}
gatewayAddress := gateway.Listener.Addr().String()
transport.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err == nil && (host == "93.184.216.34" || host == "93.184.216.35") {
return dialer.DialContext(ctx, network, gatewayAddress)
}
return dialer.DialContext(ctx, network, address)
}
return publicProtectedOAuthFixture{
client: &http.Client{Transport: transport},
resourceURL: resourceOrigin + "/mcp",
}
}
func TestProtectedResourceProbeDoesNotFollowRedirects(t *testing.T) {
var redirectedHits atomic.Int64
redirected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
redirectedHits.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer redirected.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirected.URL+"/mcp", http.StatusFound)
}))
defer source.Close()
_, found, err := discoverProtectedResourceAuthorizationServer(context.Background(), source.Client(), source.URL+"/mcp")
if err != nil || found {
t.Fatalf("redirected probe found=%v err=%v", found, err)
}
if redirectedHits.Load() != 0 {
t.Fatalf("probe followed redirect %d time(s)", redirectedHits.Load())
}
}
func TestProtectedResourceMetadataDoesNotFollowRedirects(t *testing.T) {
var redirectedHits atomic.Int64
redirected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
redirectedHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": "https://attacker.example/mcp",
"authorization_servers": []string{"https://attacker.example"},
})
}))
defer redirected.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
http.Redirect(w, r, redirected.URL+"/metadata", http.StatusFound)
}
}))
defer source.Close()
_, _, err := discoverProtectedResourceAuthorizationServer(context.Background(), source.Client(), source.URL+"/mcp")
if err == nil || !strings.Contains(err.Error(), "HTTP 302") {
t.Fatalf("error = %v, want redirect refusal", err)
}
if redirectedHits.Load() != 0 {
t.Fatalf("metadata fetch followed redirect %d time(s)", redirectedHits.Load())
}
}
func TestResolveAuthorizationServerRejectsProtectedIssuerMismatch(t *testing.T) {
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": "https://unexpected.example",
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer authorizationServer.Close()
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
}
}))
defer resourceServer.Close()
_, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err == nil || !strings.Contains(err.Error(), "issuer does not match") {
t.Fatalf("error = %v, want issuer mismatch", err)
}
}
func TestResolveAuthorizationServerRejectsMissingProtectedIssuer(t *testing.T) {
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer authorizationServer.Close()
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
}
}))
defer resourceServer.Close()
_, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err == nil || !strings.Contains(err.Error(), "issuer") {
t.Fatalf("error = %v, want missing issuer rejection", err)
}
}
func TestResolveAuthorizationServerRejectsWhitespaceAlteredProtectedIssuer(t *testing.T) {
for _, alter := range []struct {
name string
apply func(string) string
}{
{name: "leading", apply: func(value string) string { return " " + value }},
{name: "trailing", apply: func(value string) string { return value + " " }},
} {
t.Run(alter.name, func(t *testing.T) {
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": alter.apply(base),
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer authorizationServer.Close()
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
}
}))
defer resourceServer.Close()
_, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err == nil || !strings.Contains(err.Error(), "issuer does not match") {
t.Fatalf("error = %v, want whitespace issuer mismatch", err)
}
})
}
}
func TestAuthorizationServerMetadataDoesNotFollowRedirects(t *testing.T) {
var redirectedHits atomic.Int64
redirected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectedHits.Add(1)
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer redirected.Close()
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirected.URL+"/.well-known/oauth-authorization-server", http.StatusFound)
}))
defer authorizationServer.Close()
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
}
}))
defer resourceServer.Close()
_, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err == nil {
t.Fatal("authorization metadata redirect should fail")
}
if redirectedHits.Load() != 0 {
t.Fatalf("authorization metadata fetch followed redirect %d time(s)", redirectedHits.Load())
}
}
-220
View File
@@ -10,7 +10,6 @@ import (
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
@@ -146,130 +145,6 @@ func TestResolveEndpointsConfigOverridesDiscovery(t *testing.T) {
}
}
func TestResolveAuthorizationServerUsesProtectedResourceMetadata(t *testing.T) {
var authorizationMetadataHits atomic.Int64
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/oauth-authorization-server" {
http.NotFound(w, r)
return
}
authorizationMetadataHits.Add(1)
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
"registration_endpoint": base + "/register",
})
}))
defer authorizationServer.Close()
var resourceProbeHits, resourceMetadataHits atomic.Int64
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/mcp":
resourceProbeHits.Add(1)
metadataURL := "http://" + r.Host + "/resource-metadata"
w.Header().Set("WWW-Authenticate", `Bearer realm="OAuth", resource_metadata="`+metadataURL+`"`)
w.WriteHeader(http.StatusUnauthorized)
case "/resource-metadata":
resourceMetadataHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": "http://" + r.Host + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
default:
http.NotFound(w, r)
}
}))
defer resourceServer.Close()
metadata, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if metadata.Issuer != authorizationServer.URL {
t.Fatalf("issuer = %q, want %q", metadata.Issuer, authorizationServer.URL)
}
if metadata.AuthorizationEndpoint != authorizationServer.URL+"/authorize" || metadata.TokenEndpoint != authorizationServer.URL+"/token" {
t.Fatalf("resolved metadata = %#v", metadata)
}
if resourceProbeHits.Load() != 1 || resourceMetadataHits.Load() != 1 || authorizationMetadataHits.Load() != 1 {
t.Fatalf("discovery hits = probe:%d resource:%d authorization:%d", resourceProbeHits.Load(), resourceMetadataHits.Load(), authorizationMetadataHits.Load())
}
}
func TestResolveAuthorizationServerFallsBackToDirectDiscovery(t *testing.T) {
var resourceProbeHits, directDiscoveryHits atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/mcp":
resourceProbeHits.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
<-r.Context().Done()
case "/.well-known/oauth-authorization-server/mcp":
directDiscoveryHits.Add(1)
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base + "/mcp",
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
metadata, err := resolveAuthorizationServer(context.Background(), server.Client(), server.URL+"/mcp", OAuthConfig{})
if err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if metadata.AuthorizationEndpoint != server.URL+"/authorize" || metadata.TokenEndpoint != server.URL+"/token" {
t.Fatalf("resolved metadata = %#v", metadata)
}
if resourceProbeHits.Load() != 1 || directDiscoveryHits.Load() != 1 {
t.Fatalf("discovery hits = probe:%d direct:%d", resourceProbeHits.Load(), directDiscoveryHits.Load())
}
}
func TestResolveAuthorizationServerIssuerOverrideSkipsResourceProbe(t *testing.T) {
var resourceProbeHits, issuerDiscoveryHits atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
resourceProbeHits.Add(1)
w.WriteHeader(http.StatusUnauthorized)
case "/.well-known/oauth-authorization-server/issuer":
issuerDiscoveryHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base + "/issuer",
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
metadata, err := resolveAuthorizationServer(context.Background(), server.Client(), server.URL+"/mcp", OAuthConfig{IssuerURL: server.URL + "/issuer"})
if err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if metadata.AuthorizationEndpoint != server.URL+"/authorize" || metadata.TokenEndpoint != server.URL+"/token" {
t.Fatalf("resolved metadata = %#v", metadata)
}
if resourceProbeHits.Load() != 0 || issuerDiscoveryHits.Load() != 1 {
t.Fatalf("discovery hits = resource:%d issuer:%d", resourceProbeHits.Load(), issuerDiscoveryHits.Load())
}
}
func TestPKCEChallengeIsS256OfVerifier(t *testing.T) {
pkce, err := newPKCE()
if err != nil {
@@ -573,98 +448,3 @@ func TestFullFlowStoresTokens(t *testing.T) {
t.Fatalf("login token = %#v", result)
}
}
func TestFullFlowUsesProtectedResourceDiscovery(t *testing.T) {
var resourceProbeHits, resourceMetadataHits, authorizationMetadataHits, tokenHits atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
resourceProbeHits.Add(1)
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" {
t.Error("resource probe sent credentials")
}
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/resource-metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/resource-metadata":
resourceMetadataHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{base},
})
case "/.well-known/oauth-authorization-server":
authorizationMetadataHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
case "/token":
tokenHits.Add(1)
if err := r.ParseForm(); err != nil {
t.Errorf("parse token form: %v", err)
}
if r.Form.Get("code") != "authorization-code-secret" || r.Form.Get("code_verifier") == "" {
t.Error("token exchange omitted the authorization code or PKCE verifier")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-token-secret",
"refresh_token": "refresh-token-secret",
"token_type": "Bearer",
"expires_in": 3600,
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
opener := func(authURL string) error {
parsed, err := url.Parse(authURL)
if err != nil {
return err
}
if parsed.Path != "/authorize" || parsed.Query().Get("code_challenge_method") != "S256" || parsed.Query().Get("code_challenge") == "" {
return errors.New("authorization request omitted the discovered endpoint or PKCE challenge")
}
if parsed.Query().Get("code") != "" || parsed.Query().Get("code_verifier") != "" {
return errors.New("authorization request exposed an authorization code or PKCE verifier")
}
state := parsed.Query().Get("state")
redirect := parsed.Query().Get("redirect_uri")
go func() {
callbackURL := redirect + "?code=authorization-code-secret&state=" + url.QueryEscape(state)
for attempt := 0; attempt < 20; attempt++ {
response, requestErr := http.Get(callbackURL)
if requestErr == nil {
response.Body.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
}()
return nil
}
token, err := Login(context.Background(), LoginOptions{
ServerName: "notion-style",
ServerURL: server.URL + "/mcp",
Config: OAuthConfig{
ClientID: "client-123",
Scopes: []string{"read"},
},
HTTPClient: server.Client(),
OpenBrowser: opener,
Timeout: 5 * time.Second,
Now: time.Now,
})
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if token.AccessToken != "access-token-secret" || token.RefreshToken != "refresh-token-secret" {
t.Fatal("login did not return the issued tokens")
}
if resourceProbeHits.Load() != 1 || resourceMetadataHits.Load() != 1 || authorizationMetadataHits.Load() != 1 || tokenHits.Load() != 1 {
t.Fatalf("flow hits = probe:%d resource:%d authorization:%d token:%d", resourceProbeHits.Load(), resourceMetadataHits.Load(), authorizationMetadataHits.Load(), tokenHits.Load())
}
}
+2 -4
View File
@@ -176,11 +176,9 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
staged = append(staged, tool)
}
}
registered := make([]tools.Tool, 0, len(staged))
for index := range staged {
registered = append(registered, staged[index])
for _, tool := range staged {
registry.Register(tool)
}
registry.RegisterBatch(registered)
return runtime, nil
}
+9 -65
View File
@@ -70,62 +70,6 @@ func TestRegisterToolsAddsPromptGatedMCPTools(t *testing.T) {
}
}
func TestRegisterToolsPublishesServerToolsInOneGeneration(t *testing.T) {
registry := tools.NewRegistry()
before := registry.Snapshot().Generation
client := &fakeToolClient{listed: []RemoteTool{
{Name: "lookup", Description: "Lookup documentation"},
{Name: "search", Description: "Search documentation"},
}}
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) {
return client, nil
}})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
after := registry.Snapshot()
if after.Generation != before+1 {
t.Fatalf("registry generation = %d, want %d", after.Generation, before+1)
}
for _, name := range []string{"mcp_docs_lookup", "mcp_docs_search"} {
if _, ok := registry.Get(name); !ok {
t.Fatalf("expected %s to be published in the batch", name)
}
}
}
func TestRegisterToolsPublishesNoneWhenServerToolValidationFails(t *testing.T) {
registry := tools.NewRegistry()
before := registry.Snapshot().Generation
client := &fakeToolClient{listed: []RemoteTool{
{Name: "lookup", Description: "Lookup documentation"},
{Name: "", Description: "Invalid nameless tool"},
}}
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) {
return client, nil
}})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
if got := registry.Snapshot().Generation; got != before {
t.Fatalf("registry generation = %d, want unchanged %d", got, before)
}
if _, ok := registry.Get("mcp_docs_lookup"); ok {
t.Fatal("valid prefix of rejected server batch was published")
}
if skipped := runtime.Skipped(); len(skipped) != 1 || skipped[0].Name != "docs" || skipped[0].Err == nil {
t.Fatalf("Skipped() = %#v, want one validation failure for docs", skipped)
}
}
func TestRegisterToolsMarksPersistentlyApprovedToolsAllow(t *testing.T) {
store, err := NewPermissionStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "permissions.json"),
@@ -209,16 +153,16 @@ func TestRegisterToolsSkipsUnreachableServerAndKeepsOthers(t *testing.T) {
}
func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
// Exa is seeded by config.DefaultMCPServers with no credentials; when a
// user never configures it, a connect failure must be recorded as
// UnconfiguredDefault so callers can avoid
// firecrawl is seeded by config.DefaultMCPServers with no credentials; when a
// user never configures it, a connect failure (e.g. the real HTTP 401 from
// issue #552) must be recorded as UnconfiguredDefault so callers can avoid
// warning about a server the user never asked for. "custom" is configured by
// the user and must NOT be flagged even though it also fails to connect.
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"exa": config.DefaultMCPServers()["exa"],
"custom": {Type: "stdio", Command: "custom-mcp"},
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"custom": {Type: "stdio", Command: "custom-mcp"},
}}, RegisterOptions{
ClientFactory: func(_ context.Context, server Server) (ToolClient, error) {
return nil, errors.New(server.Name + " connect failed")
@@ -233,12 +177,12 @@ func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
for _, skipped := range runtime.Skipped() {
byName[skipped.Name] = skipped
}
exa, ok := byName["exa"]
firecrawl, ok := byName["firecrawl"]
if !ok {
t.Fatalf("Skipped() = %#v, want an entry for exa", runtime.Skipped())
t.Fatalf("Skipped() = %#v, want an entry for firecrawl", runtime.Skipped())
}
if !exa.UnconfiguredDefault {
t.Fatalf("Skipped()[exa] = %#v, want UnconfiguredDefault", exa)
if !firecrawl.UnconfiguredDefault {
t.Fatalf("Skipped()[firecrawl] = %#v, want UnconfiguredDefault", firecrawl)
}
custom, ok := byName["custom"]
if !ok {
-6
View File
@@ -231,12 +231,6 @@ func reasoningEffortsForModelName(name string) []ReasoningEffort {
// the gateway's translation concern — unknown fields are ignored, so the
// worst case is a silent no-op rather than a 400.
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
case strings.Contains(n, "deepseek"):
// DeepSeek V4 (deepseek-v4-flash / deepseek-v4-pro) exposes thinking-mode
// effort control through the OpenAI-compatible reasoning_effort parameter.
// The API accepts low/high/max and maps medium/xhigh onto high, so only
// the three distinct tiers are advertised.
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}
default:
return nil
}
@@ -20,10 +20,6 @@ func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
{"hy3", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"hunyuan-t1", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"openai/o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}}, // github-style vendor/ id
{"deepseek-v4-flash", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-v4-pro", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-reasoner", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-chat", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
{"gpt-4o-mini", nil},
{"ollama/llama3.1", nil},
+64 -7
View File
@@ -5,16 +5,24 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
const fileLockTimeout = 5 * time.Second
const (
fileLockTimeout = 5 * time.Second
fileLockStaleAfter = 30 * time.Second
)
// acquireFileLock takes a cross-process advisory lock through a stable lock
// file. It retries with a short backoff until a timeout. Kernel lock lifetime
// provides crash recovery, so the path is never renamed or removed.
var lockSeq atomic.Uint64
// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
// store). Release is ownership-aware: it removes the lock only if it still holds
// our token, so a stale-broken holder cannot delete a newer holder's lock.
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if now == nil {
now = time.Now
@@ -22,15 +30,64 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
deadline := now().Add(fileLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial/failed write would leave a lock file without our token, so
// the releaser below could never delete it — stranding the lock for
// other processes. Fail closed: remove the file and surface the error.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if now().After(deadline) {
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
}
+2 -22
View File
@@ -191,7 +191,6 @@ type TurnBenchResult struct {
type TurnBenchTotals struct {
InputTokens int64 `json:"inputTokens"`
CachedInputTokens int64 `json:"cachedInputTokens"`
CacheWriteTokens int64 `json:"cacheWriteTokens"`
OutputTokens int64 `json:"outputTokens"`
ModelRequests int64 `json:"modelRequests"`
ToolCalls int64 `json:"toolCalls"`
@@ -480,7 +479,6 @@ func topLatencySources(perSpan map[string]SpanStats, top int) []LatencySource {
func aggregateTotals(totals *TurnBenchTotals, tr *trace.TurnTrace) {
totals.InputTokens += tr.Counter(trace.CounterInputTokens)
totals.CachedInputTokens += tr.Counter(trace.CounterCachedInputTokens)
totals.CacheWriteTokens += tr.Counter(trace.CounterCacheWriteTokens)
totals.OutputTokens += tr.Counter(trace.CounterOutputTokens)
totals.ModelRequests += tr.Counter(trace.CounterModelRequests)
totals.ToolCalls += tr.Counter(trace.CounterToolCalls)
@@ -533,9 +531,8 @@ func FormatTurnBenchSummary(result TurnBenchResult) string {
lines = append(lines, fmt.Sprintf(" %-18s %10s %5.1f%%", src.Span, FormatMetric(src.TotalMs, "ms"), src.Share*100))
}
}
lines = append(lines, fmt.Sprintf("totals: in=%d (cache-read %d, cache-write %d, uncached %d) out=%d | requests=%d tools=%d retries=%d reconnects=%d compactions=%d",
result.Totals.InputTokens, result.Totals.CachedInputTokens, result.Totals.CacheWriteTokens,
uncachedInputTokens(result.Totals), result.Totals.OutputTokens,
lines = append(lines, fmt.Sprintf("totals: in=%d (cached %d) out=%d | requests=%d tools=%d retries=%d reconnects=%d compactions=%d",
result.Totals.InputTokens, result.Totals.CachedInputTokens, result.Totals.OutputTokens,
result.Totals.ModelRequests, result.Totals.ToolCalls, result.Totals.Retries,
result.Totals.Reconnects, result.Totals.Compactions))
for _, class := range sortedClasses(result.PerClass) {
@@ -548,23 +545,6 @@ func FormatTurnBenchSummary(result TurnBenchResult) string {
return strings.Join(lines, "\n")
}
func uncachedInputTokens(totals TurnBenchTotals) int64 {
remaining := totals.InputTokens
if remaining <= 0 {
return 0
}
for _, cached := range []int64{totals.CachedInputTokens, totals.CacheWriteTokens} {
if cached <= 0 {
continue
}
if cached >= remaining {
return 0
}
remaining -= cached
}
return remaining
}
// classTier returns the oracle tier label a class was classified into, so the
// per-class roll-up states explicitly which oracle (if any) its "passed" count
// is measured against.
-33
View File
@@ -74,7 +74,6 @@ func TestRunTurnBenchAggregation(t *testing.T) {
"t3": cannedTrace(150, 20, 1500),
"t4": cannedTrace(300, 10, 1000),
}
canned["t1"].Counters = append(canned["t1"].Counters, trace.Counter{Name: trace.CounterCacheWriteTokens, Value: 125})
cfg := TurnBenchConfig{
Model: "fake-model",
Iterations: 1,
@@ -156,9 +155,6 @@ func TestRunTurnBenchAggregation(t *testing.T) {
if result.Totals.InputTokens != 5500 {
t.Fatalf("inputTokens = %d, want 5500", result.Totals.InputTokens)
}
if result.Totals.CacheWriteTokens != 125 {
t.Fatalf("cacheWriteTokens = %d, want 125", result.Totals.CacheWriteTokens)
}
if result.Totals.OutputTokens != 2750 {
t.Fatalf("outputTokens = %d, want 2750", result.Totals.OutputTokens)
}
@@ -338,35 +334,6 @@ func TestFormatTurnBenchSummaryNamesTopSources(t *testing.T) {
}
}
func TestUncachedInputTokensClampsInconsistentTotals(t *testing.T) {
tests := []struct {
name string
totals TurnBenchTotals
want int64
}{
{name: "normal", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: 30, CacheWriteTokens: 20}, want: 50},
{name: "cached exceeds input", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: 120}, want: 0},
{name: "write exceeds remainder", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 90}, want: 0},
{name: "overflowing inconsistent counters", totals: TurnBenchTotals{CachedInputTokens: 1<<63 - 1, CacheWriteTokens: 1<<63 - 1}, want: 0},
{name: "overflowing counters with input", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: 1<<63 - 1, CacheWriteTokens: 1<<63 - 1}, want: 0},
{name: "negative cache counters ignored", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: -20, CacheWriteTokens: -10}, want: 100},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := uncachedInputTokens(test.totals); got != test.want {
t.Fatalf("uncachedInputTokens(%+v) = %d, want %d", test.totals, got, test.want)
}
})
}
summary := FormatTurnBenchSummary(TurnBenchResult{Totals: TurnBenchTotals{
CachedInputTokens: 1<<63 - 1,
CacheWriteTokens: 1<<63 - 1,
}})
if !strings.Contains(summary, "uncached 0") {
t.Fatalf("summary did not clamp inconsistent cache totals:\n%s", summary)
}
}
func TestLoadBaselineManifest(t *testing.T) {
path := filepath.Join("manifests", "baseline.json")
set, err := LoadTaskSet(path)
+1 -19
View File
@@ -19,7 +19,6 @@ import (
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/proxydial"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -519,15 +518,9 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
transport = &http.Transport{}
}
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
// WRAPPED, SO THE DIALER LEARNS THE ROUTE THIS TRANSPORT CHOSE. The wrapper
// records the proxy selected for each request on that request, and the
// dialer reads it back on the dial that request causes; installing the bare
// transport instead would leave a proxied dial looking like a loopback
// target and fail the probe (#569). The cloned default transport already
// reads HTTPS_PROXY and friends; nothing new is enabled.
return &http.Client{
Timeout: timeout,
Transport: proxydial.Wrap(transport),
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= maxConnectivityRedirects {
return fmt.Errorf("provider connectivity exceeded %d redirects", maxConnectivityRedirects)
@@ -590,17 +583,6 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
return func(ctx context.Context, network, address string) (net.Conn, error) {
// THE PROXY IS NOT THE TARGET. With HTTPS_PROXY set to a local forward
// proxy, the transport dials the proxy first and tunnels the request
// through it, and this dialer used to refuse that dial as a loopback
// address: "proxyconnect tcp: ... loopback hosts are blocked" for a
// request whose real target had already been validated (#569). The
// target validation is unchanged; only the dial the transport opens to
// the proxy it picked for this very request is let past the guard, and a
// direct dial is checked as it always was.
if proxydial.IsProxyDial(ctx) {
return dialer.DialContext(ctx, network, address)
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
-218
View File
@@ -1,218 +0,0 @@
package providerhealth
import (
"context"
"errors"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/proxydial"
)
// connectivityClientWithProxy builds the real connectivity client and swaps in
// a proxy function, which is how the transport learns its route. The
// environment is left alone deliberately: http.ProxyFromEnvironment reads it
// once per process, so an environment-driven test would depend on test order.
func connectivityClientWithProxy(t *testing.T, resolver Resolver, proxyFor func(*http.Request) (*url.URL, error)) *http.Client {
t.Helper()
client := newConnectivityClient(3*time.Second, resolver, nil, false)
wrapped, ok := client.Transport.(*proxydial.Transport)
if !ok {
t.Fatalf("transport = %T, want the route-recording wrapper; without it no dial can be recognised as the proxy dial", client.Transport)
}
wrapped.Proxy = proxyFor
return client
}
// recordingProxy is a listener standing in for a local forward proxy. It
// records the first line it receives and hangs up.
func recordingProxy(t *testing.T) (*url.URL, <-chan string) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
arrived := make(chan string, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
buffer := make([]byte, 256)
n, _ := conn.Read(buffer)
arrived <- string(buffer[:n])
}()
proxyURL, err := url.Parse("http://" + listener.Addr().String())
if err != nil {
t.Fatal(err)
}
return proxyURL, arrived
}
// THE PROXY IS NOT THE TARGET, AND THE GUARD HAS TO KNOW THE DIFFERENCE.
//
// With HTTPS_PROXY set to a local forward proxy, the transport dials the proxy
// first and tunnels the request through it. This dialer refused that dial as a
// loopback address, so `zero doctor --connectivity` failed with "proxyconnect
// tcp: ... loopback hosts are blocked" for a target that had already been
// validated and was public (#569). Driven through the real client, so the route
// is the transport's own decision.
func TestConnectivityClientTunnelsThroughTheConfiguredProxy(t *testing.T) {
proxyURL, arrived := recordingProxy(t)
client := connectivityClientWithProxy(t,
staticResolver{err: errors.New("resolver must not be called for a proxied request")},
func(*http.Request) (*url.URL, error) { return proxyURL, nil })
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.example.com/v1/models", nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Do(request)
select {
case first := <-arrived:
if line := strings.SplitN(first, "\r\n", 2)[0]; line != "CONNECT api.example.com:443 HTTP/1.1" {
t.Fatalf("proxy received %q, want a CONNECT for the validated target", line)
}
case <-time.After(5 * time.Second):
t.Fatalf("the proxy never received a connection; request error: %v", err)
}
}
// AND A DIRECT REQUEST STAYS GUARDED, EVEN WHEN ITS ORIGIN IS THE PROXY'S OWN
// ADDRESS.
//
// This is the NO_PROXY shape: the proxy function sends one host direct. An
// exemption derived from asking that function about a request of our own
// invention would answer "proxied" for the excluded host too, and this dial,
// whose address equals the configured proxy endpoint, would skip the resolver
// and reach whatever the name resolves to at connect time.
func TestDirectRequestToTheProxyEndpointIsStillGuarded(t *testing.T) {
proxyURL, err := url.Parse("http://rebind.example:80")
if err != nil {
t.Fatal(err)
}
lookups := 0
resolver := countingResolver{
onLookup: func() { lookups++ },
addr: netip.MustParseAddr("10.0.0.5"),
}
client := connectivityClientWithProxy(t, resolver, func(request *http.Request) (*url.URL, error) {
if request.URL.Hostname() == "rebind.example" {
return nil, nil
}
return proxyURL, nil
})
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://rebind.example/data", nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Do(request)
if lookups == 0 {
t.Fatal("the direct dial skipped the safety resolver because its address matched the configured proxy endpoint")
}
var safety endpointSafetyError
if !errors.As(err, &safety) {
t.Fatalf("err = %v, want the private answer to be refused", err)
}
}
// A proxy configured with a Unicode hostname is dialed by its IDNA-encoded
// name, and it is still the proxy: the request must reach it rather than be
// refused as an ordinary private address.
func TestUnicodeProxyHostnameIsStillTheProxy(t *testing.T) {
dialed := ""
client := connectivityClientWithProxy(t,
staticResolver{err: errors.New("resolver must not be called for a proxied request")},
func(*http.Request) (*url.URL, error) { return url.Parse("http://büro.example:3067") })
wrapped := client.Transport.(*proxydial.Transport)
guarded := wrapped.DialContext
wrapped.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = address
return guarded(ctx, network, address)
}
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.example.com/v1/models", nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Do(request)
if dialed != "xn--bro-hoa.example:3067" {
t.Fatalf("SETUP INVALID: dialed %q, want the IDNA-encoded proxy endpoint", dialed)
}
var safety endpointSafetyError
if errors.As(err, &safety) {
t.Fatalf("the configured proxy was refused by the guard: %v", err)
}
}
// Without a proxy the guard is exactly what it was: loopback is refused.
func TestSafeDialContextStillRefusesLoopbackWithoutAProxy(t *testing.T) {
dial := safeDialContext(staticResolver{err: errors.New("resolver must not be called")}, false)
conn, err := dial(context.Background(), "tcp", "127.0.0.1:3067")
if conn != nil {
_ = conn.Close()
t.Fatal("loopback was dialed with no proxy configured")
}
var safety endpointSafetyError
if !errors.As(err, &safety) {
t.Fatalf("err = %v, want endpointSafetyError", err)
}
}
// A sibling loopback port is not the proxy, and the guard is unchanged for it.
func TestSafeDialContextRefusesALoopbackPortThatIsNotTheProxy(t *testing.T) {
proxyURL, arrived := recordingProxy(t)
client := connectivityClientWithProxy(t,
staticResolver{err: errors.New("resolver must not be called")},
func(request *http.Request) (*url.URL, error) {
// Only the https probe is proxied; the http one below is direct and
// happens to point at the neighbouring port.
if request.URL.Scheme == "https" {
return proxyURL, nil
}
return nil, nil
})
_, port, _ := net.SplitHostPort(proxyURL.Host)
number, _ := strconv.Atoi(port)
sibling := "http://" + net.JoinHostPort("127.0.0.1", strconv.Itoa(number+1)) + "/"
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, sibling, nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Do(request)
var safety endpointSafetyError
if !errors.As(err, &safety) {
t.Fatalf("err = %v, want a loopback port beside the proxy to stay blocked", err)
}
select {
case first := <-arrived:
t.Fatalf("the proxy received a connection it should not have: %q", first)
default:
}
}
// countingResolver answers with one address and reports each lookup, so a test
// can tell a skipped guard from a guard that ran and allowed the address.
type countingResolver struct {
onLookup func()
addr netip.Addr
}
func (resolver countingResolver) LookupNetIP(context.Context, string, string) ([]netip.Addr, error) {
if resolver.onLookup != nil {
resolver.onLookup()
}
return []netip.Addr{resolver.addr}, nil
}
+1 -2
View File
@@ -539,8 +539,7 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
hasCatalog := len(byID) > 0
// Aggregators publish the live list as the source of truth. Keep live-only
// ids even when a remote catalog also loaded, instead of intersecting.
preferLive := provider.Local ||
providermodelcatalog.PublicLiveCatalog(provider.ID) ||
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
result := make([]Model, 0, len(liveModels))
for _, live := range liveModels {
@@ -226,29 +226,6 @@ func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
}
}
func TestMergeLocalModelsKeepsLiveOnlyEntries(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "lmstudio", Local: true},
[]Model{
{ID: "qwen3-coder-30b", ContextWindow: 32768},
{ID: "text-embedding-local"},
{ID: "qwen2.5-coder-32b-instruct", Description: "live description"},
},
[]Model{{ID: "qwen2.5-coder-32b-instruct", Description: "catalog description", ToolCall: true}},
)
if got, want := strings.Join(modelIDs(models), ","), "qwen3-coder-30b,qwen2.5-coder-32b-instruct"; got != want {
t.Fatalf("models = %q, want %q", got, want)
}
for _, model := range models {
if model.ID == "qwen2.5-coder-32b-instruct" {
if model.Description != "catalog description" || !model.ToolCall {
t.Fatalf("merged catalog metadata = %#v", model)
}
}
}
}
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
// Catalog and live endpoints return distinct payloads so the merge must keep
// live-only ids that are absent from the remote catalog response.
-108
View File
@@ -1,108 +0,0 @@
package providers
import (
"context"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction summarization model selection.
//
// Compaction summaries used to run on the session's main model — near a 200k
// window one summarization is a ~100k-token input call at full price, the
// single most expensive recurring event in a long run. A cheap model handles
// the "dense factual summary" task fine, so summarization is routed to one
// whenever the target is knowable.
//
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
// main model). The literal value "main" at any level forces the main model.
// A wrong choice is never fatal: the agent falls back to the main provider on
// the first summarizer failure for the rest of the run.
// CompactionModelEnv overrides the compaction summarization model.
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
// compactionMainSentinel disables the dedicated summarizer explicitly.
const compactionMainSentinel = "main"
// Curated cheap summarizer defaults, applied only for provider kinds whose
// model catalog is guaranteed (official endpoints). Custom / *-compatible
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
// failed call per run is worse than main-model prices.
const (
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
defaultOpenAICompactionModel = "gpt-4.1-mini"
)
// CompactionModelID returns the model that compaction summarization calls
// should use for the given profile, or "" to use the session's main model.
// preference is the resolved config value (preferences.compactionModel).
func CompactionModelID(profile config.ProviderProfile, preference string) string {
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
if value == "" {
value = strings.TrimSpace(preference)
}
if value != "" {
if strings.EqualFold(value, compactionMainSentinel) {
return ""
}
return value
}
return defaultCompactionModel(profile)
}
func defaultCompactionModel(profile config.ProviderProfile) string {
metadata, err := ResolveRuntimeMetadata(profile, Options{})
if err != nil {
return ""
}
var candidate string
switch metadata.ProviderKind {
case config.ProviderKindAnthropic:
candidate = defaultAnthropicCompactionModel
case config.ProviderKindGoogle:
candidate = defaultGoogleCompactionModel
case config.ProviderKindOpenAI:
candidate = defaultOpenAICompactionModel
default:
return ""
}
// Session already runs on the cheap model: a dedicated summarizer buys
// nothing and would only add a second provider instance.
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
return ""
}
return candidate
}
// CompactionSummarizerFactory returns the lazy constructor the agent loop
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
// The compactor passes the main model in force at that moment — on the first
// paid compaction and again after a mid-run model switch — so the choice
// tracks escalations: a run that starts on the cheap model gets no dedicated
// summarizer, and gains one once it escalates to a pricier main model.
// Returning (nil, nil) means "summarize on the main provider". The factory
// itself is nil only when no provider builder exists. Shared by the exec and
// TUI front ends so the selection rules cannot drift between them.
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
if newProvider == nil {
return nil
}
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
profile := base
if strings.TrimSpace(mainModelID) != "" {
profile.Model = mainModelID
}
modelID := CompactionModelID(profile, preference)
if modelID == "" {
return nil, nil
}
profile.Model = modelID
return newProvider(profile)
}
}
-113
View File
@@ -1,113 +0,0 @@
package providers
import (
"context"
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactionModelIDResolutionOrder(t *testing.T) {
anthropic := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
}
t.Setenv(CompactionModelEnv, "my-env-model")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-env-model" {
t.Fatalf("env must win, got %q", got)
}
t.Setenv(CompactionModelEnv, "main")
if got := CompactionModelID(anthropic, "my-config-model"); got != "" {
t.Fatalf("env 'main' must force the main model, got %q", got)
}
t.Setenv(CompactionModelEnv, "")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-config-model" {
t.Fatalf("config preference must apply when env unset, got %q", got)
}
if got := CompactionModelID(anthropic, "main"); got != "" {
t.Fatalf("config 'main' must force the main model, got %q", got)
}
}
func TestCompactionModelIDCuratedDefaults(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
anthropic := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: "claude-sonnet-4.5"}
if got := CompactionModelID(anthropic, ""); got != defaultAnthropicCompactionModel {
t.Fatalf("anthropic default = %q, want %q", got, defaultAnthropicCompactionModel)
}
google := config.ProviderProfile{Name: "google", ProviderKind: config.ProviderKindGoogle, Model: "gemini-2.5-pro"}
if got := CompactionModelID(google, ""); got != defaultGoogleCompactionModel {
t.Fatalf("google default = %q, want %q", got, defaultGoogleCompactionModel)
}
openai := config.ProviderProfile{Name: "openai", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
if got := CompactionModelID(openai, ""); got != defaultOpenAICompactionModel {
t.Fatalf("openai default = %q, want %q", got, defaultOpenAICompactionModel)
}
// Custom/compatible endpoints: catalog unknowable, no default.
compatible := config.ProviderProfile{
Name: "opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opengateway.gitlawb.com/v1",
Model: "some-model",
}
if got := CompactionModelID(compatible, ""); got != "" {
t.Fatalf("openai-compatible must have no default, got %q", got)
}
}
func TestCompactionModelIDSkipsWhenMainIsAlreadyCheap(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
haiku := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: defaultAnthropicCompactionModel,
}
if got := CompactionModelID(haiku, ""); got != "" {
t.Fatalf("already-cheap session must not get a dedicated summarizer, got %q", got)
}
}
func TestCompactionSummarizerFactoryTracksMainModel(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
base := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: defaultAnthropicCompactionModel}
var built []string
newProvider := func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = append(built, profile.Model)
return nil, errors.New("not needed")
}
factory := CompactionSummarizerFactory(base, "", newProvider)
if factory == nil {
t.Fatal("factory must exist when a provider builder exists")
}
// Main model is the cheap default: no dedicated summarizer, nothing built.
if provider, err := factory(context.Background(), defaultAnthropicCompactionModel); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("cheap main model must yield (nil, nil) without building, got %v %v built=%v", provider, err, built)
}
// After escalating to an expensive model the cheap default is built.
if _, err := factory(context.Background(), "claude-sonnet-4-5"); err == nil || len(built) != 1 || built[0] != defaultAnthropicCompactionModel {
t.Fatalf("escalated main model must build the cheap summarizer, built=%v err=%v", built, err)
}
// An empty main model falls back to the base profile's model — the cheap
// default here, so again nothing is built.
built = nil
if provider, err := factory(context.Background(), ""); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("empty main model must use the base profile, got %v %v built=%v", provider, err, built)
}
// The explicit "main" preference disables the dedicated summarizer.
if provider, err := CompactionSummarizerFactory(base, "main", newProvider)(context.Background(), "claude-sonnet-4-5"); provider != nil || err != nil {
t.Fatalf("preference main must disable the summarizer, got %v %v", provider, err)
}
if CompactionSummarizerFactory(base, "", nil) != nil {
t.Fatal("no provider builder means no factory")
}
}
+60 -238
View File
@@ -62,7 +62,6 @@ const (
responsesEventReasoningDelta = "response.reasoning_summary_text.delta"
responsesEventOutputTextDone = "response.output_text.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventCustomInputDelta = "response.custom_tool_call_input.delta"
responsesEventContentPartDone = "response.content_part.done"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventCompleted = "response.completed"
@@ -71,37 +70,17 @@ const (
responsesEventIncomplete = "response.incomplete"
)
const applyPatchLarkGrammar = `start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?
filename: /(.+)/
add_line: "+" /(.*)/ LF -> line
change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF
%import common.LF`
// responsesRequest is the wire shape POSTed to {baseURL}/responses.
type responsesRequest struct {
Type string `json:"type,omitempty"`
Model string `json:"model"`
Instructions string `json:"instructions"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
Store bool `json:"store"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
Model string `json:"model"`
Instructions string `json:"instructions"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
Store bool `json:"store"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
}
// responsesReasoning carries the reasoning controls for the Responses API. The
@@ -126,7 +105,6 @@ type inputItem struct {
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
Input string `json:"input,omitempty"`
// function_call_output fields
Output any `json:"output,omitempty"`
}
@@ -140,11 +118,10 @@ type contentItem struct {
}
type responsesTool struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
Format *zeroruntime.ToolDefinitionFormat `json:"format,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
}
// responsesEvent is the decoded form of one Responses SSE data payload.
@@ -155,7 +132,6 @@ type responsesEvent struct {
Delta string `json:"delta,omitempty"`
// item payloads (response.output_item.added / done)
ItemID string `json:"item_id,omitempty"`
CallID string `json:"call_id,omitempty"`
// OutputIndex is a *int so a real 0 (the first output) is distinguishable from
// "absent" — a plain int defaulting to 0 dropped a no-item_id call's args (M1).
OutputIndex *int `json:"output_index,omitempty"`
@@ -174,7 +150,6 @@ type itemPayload struct {
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
Input string `json:"input,omitempty"`
Status string `json:"status,omitempty"`
}
@@ -198,8 +173,7 @@ type outputDetails struct {
}
type inputDetails struct {
CachedTokens int `json:"cached_tokens"`
CacheWriteTokens int `json:"cache_write_tokens"`
CachedTokens int `json:"cached_tokens"`
}
type errorPayload struct {
@@ -213,10 +187,8 @@ type errorPayload struct {
// response.output_item.done.
type toolCallBuilder struct {
ID string
CallID string
Name string
Arguments strings.Builder
Freeform bool
started bool
ended bool
}
@@ -225,57 +197,15 @@ type toolCallBuilder struct {
// event has been emitted. It is the Responses-API equivalent of the
// chat-completions toolState in provider.go.
type responsesState struct {
toolCalls map[string]*toolCallBuilder
toolOrder []string
text strings.Builder
responseID string
usage *usagePayload
emitted bool
done bool
toolCalls map[string]*toolCallBuilder
usage *usagePayload
done bool
}
func newResponsesState() *responsesState {
return &responsesState{toolCalls: map[string]*toolCallBuilder{}}
}
func (state *responsesState) outputInputItems() []inputItem {
items := make([]inputItem, 0, len(state.toolOrder)+1)
if state.text.Len() > 0 {
items = append(items, inputItem{
Type: "message",
Role: "assistant",
Content: []contentItem{{Type: "output_text", Text: state.text.String()}},
})
}
for _, key := range state.toolOrder {
builder := state.toolCalls[key]
if builder == nil || builder.ID == "" || builder.Name == "" {
continue
}
callID := builder.CallID
if callID == "" {
callID = builder.ID
}
itemType := "function_call"
if builder.Freeform {
itemType = "custom_tool_call"
}
item := inputItem{
Type: itemType,
ID: builder.ID,
CallID: callID,
Name: builder.Name,
}
if builder.Freeform {
item.Input = builder.Arguments.String()
} else {
item.Arguments = builder.Arguments.String()
}
items = append(items, item)
}
return items
}
// buildResponsesRequest converts a runtime CompletionRequest into the
// Responses API wire format. System messages fold into a top-level
// `instructions` field; everything else (user/assistant turns, tool
@@ -285,14 +215,11 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
return nil, errors.New("codex provider: model is required")
}
req := &responsesRequest{
Model: p.inner.model,
Stream: true,
ParallelToolCalls: true,
MaxOutputTokens: p.inner.maxTokens,
PromptCacheKey: request.PromptCacheKey,
Model: p.inner.model,
Stream: true,
MaxOutputTokens: p.inner.maxTokens,
}
instructions := []string{}
customCallIDs := map[string]string{}
for _, msg := range request.Messages {
switch msg.Role {
case zeroruntime.MessageRoleSystem:
@@ -307,31 +234,13 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
return nil, err
}
req.Input = append(req.Input, items...)
for _, call := range msg.ToolCalls {
if call.Freeform && call.ID != "" {
callID := call.ProviderCallID
if callID == "" {
callID = call.ID
}
customCallIDs[call.ID] = callID
}
}
case zeroruntime.MessageRoleTool:
if msg.ToolCallID == "" {
return nil, fmt.Errorf("codex provider: tool message missing tool call id")
}
outputType := "function_call_output"
callID := msg.ToolCallProviderID
if callID == "" {
callID = msg.ToolCallID
}
if customID, custom := customCallIDs[msg.ToolCallID]; custom {
outputType = "custom_tool_call_output"
callID = customID
}
req.Input = append(req.Input, inputItem{
Type: outputType,
CallID: callID,
Type: "function_call_output",
CallID: msg.ToolCallID,
Output: msg.Content,
})
default:
@@ -340,25 +249,12 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
}
req.Instructions = strings.Join(instructions, "\n\n")
for _, tool := range request.Tools {
definition := tool
if definition.Name == "apply_patch" && definition.Type == zeroruntime.ToolDefinitionFreeform {
definition.Description = "The apply_patch tool edits files from a raw structured patch. Use absolute file paths in patch headers to target a granted extra write root. Do not wrap the patch in JSON."
definition.Format = &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: applyPatchLarkGrammar}
}
wireTool := responsesTool{
Type: string(definition.Type),
Name: definition.Name,
Description: definition.Description,
Parameters: definition.Parameters,
Format: definition.Format,
}
if wireTool.Type == "" {
wireTool.Type = string(zeroruntime.ToolDefinitionFunction)
}
if wireTool.Type == string(zeroruntime.ToolDefinitionFreeform) {
wireTool.Parameters = nil
}
req.Tools = append(req.Tools, wireTool)
req.Tools = append(req.Tools, responsesTool{
Type: "function",
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
})
}
// Codex / o-series reasoning models take the effort tier nested under
// `reasoning` on the Responses API (the chat-completions `reasoning_effort`
@@ -416,26 +312,13 @@ func (p *CodexProvider) assistantInputItems(msg zeroruntime.Message) ([]inputIte
if tc.Name == "" {
return nil, errors.New("codex provider: assistant tool call missing name")
}
callID := tc.ProviderCallID
if callID == "" {
callID = tc.ID
}
itemType := "function_call"
if tc.Freeform {
itemType = "custom_tool_call"
}
item := inputItem{
Type: itemType,
ID: tc.ID,
CallID: callID,
Name: tc.Name,
}
if tc.Freeform {
item.Input = tc.Arguments
} else {
item.Arguments = tc.Arguments
}
items = append(items, item)
items = append(items, inputItem{
Type: "function_call",
ID: tc.ID,
CallID: tc.ID,
Name: tc.Name,
Arguments: tc.Arguments,
})
}
return items, nil
}
@@ -596,18 +479,6 @@ func (p *CodexProvider) emitResponsesEvent(
state.done = true
return false
}
return p.emitParsedResponsesEvent(ctx, &event, state, events)
}
// emitParsedResponsesEvent converts one already-decoded Responses event into
// zero or more runtime events. WebSocket sessions use this path to avoid
// decoding each frame twice; the SSE path retains emitResponsesEvent above.
func (p *CodexProvider) emitParsedResponsesEvent(
ctx context.Context,
event *responsesEvent,
state *responsesState,
events chan<- zeroruntime.StreamEvent,
) bool {
if event.Type == "" {
// A payload that parses as JSON but carries no `type` field is a
// malformed Responses event — the type is the discriminator that
@@ -629,17 +500,12 @@ func (p *CodexProvider) emitParsedResponsesEvent(
// the runtime. response.incomplete is folded into a length finish at
// scan-end (state.done stays false so the wrapper emits the
// StreamEventDone with FinishReasonLength).
if event.Response != nil && event.Response.ID != "" {
state.responseID = event.Response.ID
}
return true
case responsesEventOutputItemAdded:
p.handleOutputItemAdded(ctx, event, state, events)
p.handleOutputItemAdded(ctx, &event, state, events)
return true
case responsesEventOutputTextDelta:
if event.Delta != "" {
state.emitted = true
state.text.WriteString(event.Delta)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventText,
Content: event.Delta,
@@ -651,7 +517,6 @@ func (p *CodexProvider) emitParsedResponsesEvent(
// phase shows progress (and keeps the activity clock fresh) instead of
// looking like a hang. Requested via reasoning.summary="auto".
if event.Delta != "" {
state.emitted = true
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: event.Delta,
@@ -659,16 +524,13 @@ func (p *CodexProvider) emitParsedResponsesEvent(
}
return true
case responsesEventFunctionArgsDelta:
p.handleToolInputDelta(ctx, event, state, events, false)
return true
case responsesEventCustomInputDelta:
p.handleToolInputDelta(ctx, event, state, events, true)
p.handleFunctionArgsDelta(ctx, &event, state, events)
return true
case responsesEventOutputItemDone:
p.handleOutputItemDone(ctx, event, state, events)
p.handleOutputItemDone(ctx, &event, state, events)
return true
case responsesEventCompleted, responsesEventFailed:
return p.handleTerminalResponse(ctx, event, state, events)
return p.handleTerminalResponse(ctx, &event, state, events)
case responsesEventError:
message := event.Message
if event.Code != "" {
@@ -708,38 +570,22 @@ func (p *CodexProvider) handleOutputItemAdded(
return
}
switch event.Item.Type {
case "function_call", "custom_tool_call":
case "function_call":
key := p.toolCallKey(event)
if key == "" {
return
}
builder, exists := state.toolCalls[key]
if !exists {
builder = &toolCallBuilder{ID: key, Freeform: event.Item.Type == "custom_tool_call"}
state.toolCalls[key] = builder
state.toolOrder = append(state.toolOrder, key)
}
if event.Item.CallID != "" {
builder.CallID = event.Item.CallID
}
if event.Item.Name != "" {
builder.Name = event.Item.Name
}
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
if builder.Freeform && event.Item.Input != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(event.Item.Input)
}
state.emitted = true
if !builder.started {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: key,
ToolCallProviderID: builder.CallID,
ToolName: builder.Name,
ToolCallFreeform: builder.Freeform,
})
builder.started = true
builder := &toolCallBuilder{
ID: key,
Name: event.Item.Name,
}
state.toolCalls[key] = builder
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: key,
ToolName: event.Item.Name,
})
builder.started = true
}
}
@@ -747,12 +593,11 @@ func (p *CodexProvider) handleOutputItemAdded(
// to the in-flight builder and emits StreamEventToolCallDelta. The
// Codex backend uses either `item_id` or `output_index` to attribute
// the delta; we honor both.
func (p *CodexProvider) handleToolInputDelta(
func (p *CodexProvider) handleFunctionArgsDelta(
ctx context.Context,
event *responsesEvent,
state *responsesState,
events chan<- zeroruntime.StreamEvent,
freeform bool,
) {
key := p.toolCallKey(event)
if key == "" {
@@ -765,16 +610,10 @@ func (p *CodexProvider) handleToolInputDelta(
// an intermediate proxy). Treat the delta as the start of a new
// tool call with no known name yet — the eventual
// response.output_item.done carries the final name.
builder = &toolCallBuilder{ID: key, Freeform: freeform}
builder = &toolCallBuilder{ID: key}
state.toolCalls[key] = builder
state.toolOrder = append(state.toolOrder, key)
}
builder.Freeform = builder.Freeform || freeform
if event.CallID != "" && builder.CallID == "" {
builder.CallID = event.CallID
}
builder.Arguments.WriteString(event.Delta)
state.emitted = true
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: key,
@@ -793,7 +632,7 @@ func (p *CodexProvider) handleOutputItemDone(
state *responsesState,
events chan<- zeroruntime.StreamEvent,
) {
if event.Item == nil || (event.Item.Type != "function_call" && event.Item.Type != "custom_tool_call") {
if event.Item == nil || event.Item.Type != "function_call" {
return
}
key := p.toolCallKey(event)
@@ -802,31 +641,20 @@ func (p *CodexProvider) handleOutputItemDone(
}
builder, ok := state.toolCalls[key]
if !ok {
builder = &toolCallBuilder{ID: key, Freeform: event.Item.Type == "custom_tool_call"}
builder = &toolCallBuilder{ID: key}
state.toolCalls[key] = builder
state.toolOrder = append(state.toolOrder, key)
}
if event.Item.Name != "" {
builder.Name = event.Item.Name
}
if event.Item.CallID != "" {
builder.CallID = event.Item.CallID
}
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
finalInput := event.Item.Arguments
if builder.Freeform {
finalInput = event.Item.Input
}
if finalInput != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(finalInput)
if event.Item.Arguments != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(event.Item.Arguments)
}
if !builder.started {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: key,
ToolCallProviderID: builder.CallID,
ToolName: builder.Name,
ToolCallFreeform: builder.Freeform,
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: key,
ToolName: builder.Name,
})
builder.started = true
}
@@ -836,7 +664,6 @@ func (p *CodexProvider) handleOutputItemDone(
ToolName: builder.Name,
})
builder.ended = true
state.emitted = true
}
// handleTerminalResponse emits the final usage + done event for a
@@ -866,7 +693,6 @@ func (p *CodexProvider) handleTerminalResponse(
return false
}
state.usage = event.Response.Usage
state.responseID = event.Response.ID
if event.Response.Usage != nil {
usage := zeroruntime.Usage{
InputTokens: event.Response.Usage.InputTokens,
@@ -874,7 +700,6 @@ func (p *CodexProvider) handleTerminalResponse(
}
if event.Response.Usage.InputTokensDetails != nil {
usage.CachedInputTokens = event.Response.Usage.InputTokensDetails.CachedTokens
usage.CacheWriteTokens = event.Response.Usage.InputTokensDetails.CacheWriteTokens
}
if event.Response.Usage.OutputTokensDetails != nil {
usage.ReasoningTokens = event.Response.Usage.OutputTokensDetails.ReasoningTokens
@@ -910,7 +735,7 @@ func (p *CodexProvider) handleTerminalResponse(
state.done = true
return false
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone, ResponseID: event.Response.ID})
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
state.done = true
return false
}
@@ -929,9 +754,6 @@ func (p *CodexProvider) toolCallKey(event *responsesEvent) string {
if event.Item != nil && event.Item.CallID != "" {
return event.Item.CallID
}
if event.CallID != "" {
return event.CallID
}
if event.OutputIndex != nil {
// output_index is present (a *int distinguishes a real 0 — the first output
// in the response — from "absent"), so key on it even when 0 instead of
-421
View File
@@ -1,421 +0,0 @@
package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"reflect"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
responsesWebSocketBetaHeader = "OpenAI-Beta"
responsesWebSocketBetaValue = "responses_websockets=2026-02-06"
responsesWebSocketRequest = "response.create"
previousResponseNotFoundCode = "previous_response_not_found"
webSocketConnectionLimitCode = "websocket_connection_limit_reached"
responsesWebSocketReadLimit = 16 << 20
)
var errCodexTurnSessionBusy = errors.New("codex turn session already has an active stream")
// NewCodexTurnSessionProvider enables one Responses WebSocket connection for
// each agent run. The session falls back to the existing HTTP/SSE provider when
// the endpoint cannot upgrade or loses its response chain.
func NewCodexTurnSessionProvider(provider *CodexProvider, caps zeroruntime.ProviderCapabilities) zeroruntime.TurnSessionProvider {
return &codexTurnSessionProvider{provider: provider, caps: caps}
}
type codexTurnSessionProvider struct {
provider *CodexProvider
caps zeroruntime.ProviderCapabilities
}
func (provider *codexTurnSessionProvider) OpenTurnSession(ctx context.Context) (zeroruntime.TurnSession, error) {
if provider == nil || provider.provider == nil || provider.provider.inner == nil {
return nil, errors.New("codex turn session requires a provider")
}
sessionCtx, cancel := context.WithCancel(ctx)
return &codexTurnSession{
ctx: sessionCtx,
cancel: cancel,
provider: provider.provider,
prewarmDone: make(chan struct{}),
}, nil
}
func (provider *codexTurnSessionProvider) Capabilities() zeroruntime.ProviderCapabilities {
return provider.caps
}
type codexTurnSession struct {
ctx context.Context
cancel context.CancelFunc
provider *CodexProvider
mu sync.Mutex
connection *websocket.Conn
webSocketOff bool
lastRequest *responsesRequest
lastResponseID string
lastOutput []inputItem
streamActive bool
prewarmOnce sync.Once
prewarmDone chan struct{}
closeOnce sync.Once
}
// Prewarm starts the WebSocket handshake without blocking prompt assembly. The
// first Stream waits for this bounded attempt; failure simply selects HTTP/SSE.
func (session *codexTurnSession) Prewarm(ctx context.Context) error {
session.prewarmOnce.Do(func() {
go func() {
defer close(session.prewarmDone)
prewarmCtx, cancel := context.WithTimeout(session.ctx, prewarmTimeout)
defer cancel()
recorder := trace.FromContext(ctx)
span := recorder.Span(trace.SpanProviderPrewarm)
defer span.End()
recorder.Counter(trace.CounterPrewarmAttempts, 1)
connection, err := session.provider.dialResponsesWebSocket(prewarmCtx)
session.mu.Lock()
defer session.mu.Unlock()
if err != nil {
session.webSocketOff = true
return
}
session.connection = connection
}()
})
return nil
}
func (session *codexTurnSession) Stream(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
_ = session.Prewarm(ctx)
select {
case <-session.prewarmDone:
case <-ctx.Done():
return nil, ctx.Err()
}
fullRequest, err := session.provider.buildResponsesRequest(request)
if err != nil {
return nil, err
}
session.mu.Lock()
if session.streamActive {
session.mu.Unlock()
return nil, errCodexTurnSessionBusy
}
if session.webSocketOff || session.connection == nil {
session.mu.Unlock()
trace.FromContext(ctx).Counter(trace.CounterResponsesHTTPFallback, 1)
return session.provider.StreamCompletion(ctx, request)
}
session.streamActive = true
connection := session.connection
wireRequest := *fullRequest
wireRequest.Type = responsesWebSocketRequest
if delta, ok := responseInputDelta(session.lastRequest, session.lastOutput, fullRequest); ok && session.lastResponseID != "" {
wireRequest.PreviousResponseID = session.lastResponseID
wireRequest.Input = delta
trace.FromContext(ctx).Counter(trace.CounterResponseChainReused, 1)
} else if session.lastRequest != nil {
trace.FromContext(ctx).Counter(trace.CounterResponseChainReset, 1)
session.lastRequest = nil
session.lastResponseID = ""
session.lastOutput = nil
}
session.mu.Unlock()
body, err := json.Marshal(wireRequest)
if err != nil {
session.releaseStream()
return nil, fmt.Errorf("encode codex websocket request: %w", err)
}
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
defer session.releaseStream()
session.streamWebSocket(ctx, connection, body, fullRequest, request, events)
}()
return events, nil
}
func (session *codexTurnSession) releaseStream() {
session.mu.Lock()
session.streamActive = false
session.mu.Unlock()
}
func (session *codexTurnSession) Compact(context.Context, zeroruntime.CompletionRequest) ([]zeroruntime.Message, error) {
return nil, zeroruntime.ErrCompactionUnsupported
}
func (session *codexTurnSession) Close() error {
session.closeOnce.Do(func() {
session.cancel()
session.prewarmOnce.Do(func() { close(session.prewarmDone) })
select {
case <-session.prewarmDone:
case <-time.After(prewarmTimeout):
}
session.mu.Lock()
connection := session.connection
session.connection = nil
session.webSocketOff = true
session.mu.Unlock()
if connection != nil {
_ = connection.CloseNow()
}
})
return nil
}
func responseInputDelta(previous *responsesRequest, output []inputItem, current *responsesRequest) ([]inputItem, bool) {
if previous == nil || current == nil || !responsesRequestPropertiesEqual(previous, current) {
return nil, false
}
prefix := make([]inputItem, 0, len(previous.Input)+len(output))
prefix = append(prefix, previous.Input...)
prefix = append(prefix, output...)
if len(current.Input) <= len(prefix) || !reflect.DeepEqual(current.Input[:len(prefix)], prefix) {
return nil, false
}
return append([]inputItem(nil), current.Input[len(prefix):]...), true
}
func responsesRequestPropertiesEqual(left, right *responsesRequest) bool {
return left.Model == right.Model &&
left.Instructions == right.Instructions &&
left.Stream == right.Stream &&
left.Store == right.Store &&
left.ParallelToolCalls == right.ParallelToolCalls &&
left.MaxOutputTokens == right.MaxOutputTokens &&
reflect.DeepEqual(left.Tools, right.Tools) &&
reflect.DeepEqual(left.Reasoning, right.Reasoning) &&
left.ServiceTier == right.ServiceTier &&
left.PromptCacheKey == right.PromptCacheKey
}
func (session *codexTurnSession) streamWebSocket(
ctx context.Context,
connection *websocket.Conn,
body []byte,
fullRequest *responsesRequest,
runtimeRequest zeroruntime.CompletionRequest,
events chan<- zeroruntime.StreamEvent,
) {
if err := connection.Write(ctx, websocket.MessageText, body); err != nil {
session.disableWebSocket(connection)
session.forwardHTTP(ctx, runtimeRequest, events)
return
}
state := newResponsesState()
for {
readCtx := ctx
cancel := func() {}
idleTimeout := session.provider.inner.streamIdleTimeout
if idleTimeout > 0 {
readCtx, cancel = context.WithTimeout(ctx, idleTimeout)
}
messageType, data, err := connection.Read(readCtx)
readTimedOut := errors.Is(readCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil
cancel()
if err != nil {
session.disableWebSocket(connection)
if ctx.Err() != nil {
return
}
if !state.emitted {
session.forwardHTTP(ctx, runtimeRequest, events)
return
}
errorMessage := err.Error()
if readTimedOut {
errorMessage = providerio.StreamTimeoutMessage(providerio.ErrStreamIdle, idleTimeout)
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: session.provider.redact("provider stream error: " + errorMessage),
})
return
}
if messageType != websocket.MessageText {
continue
}
var responseEvent responsesEvent
decodeErr := json.Unmarshal(data, &responseEvent)
if decodeErr == nil {
if (responseEvent.Code == previousResponseNotFoundCode || responseEvent.Code == webSocketConnectionLimitCode) &&
!state.emitted {
session.disableWebSocket(connection)
session.forwardHTTP(ctx, runtimeRequest, events)
return
}
if responseEvent.Type == responsesEventIncomplete {
session.resetResponseChain(connection)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventDone,
FinishReason: zeroruntime.FinishReasonLength,
})
return
}
}
keepReading := false
if decodeErr == nil {
keepReading = session.provider.emitParsedResponsesEvent(ctx, &responseEvent, state, events)
} else {
keepReading = session.provider.emitResponsesEvent(ctx, string(data), state, events)
}
if keepReading {
continue
}
if responseEvent.Type == responsesEventCompleted && responseEvent.Response != nil &&
responseEvent.Response.Error == nil && responseEvent.Response.Status != "failed" && state.responseID != "" {
session.mu.Lock()
if session.connection == connection && !session.webSocketOff {
requestCopy := *fullRequest
requestCopy.Input = append([]inputItem(nil), fullRequest.Input...)
session.lastRequest = &requestCopy
session.lastResponseID = state.responseID
session.lastOutput = state.outputInputItems()
}
session.mu.Unlock()
}
return
}
}
func (session *codexTurnSession) resetResponseChain(connection *websocket.Conn) {
session.mu.Lock()
defer session.mu.Unlock()
if session.connection != connection {
return
}
session.lastRequest = nil
session.lastResponseID = ""
session.lastOutput = nil
}
func (session *codexTurnSession) forwardHTTP(ctx context.Context, request zeroruntime.CompletionRequest, events chan<- zeroruntime.StreamEvent) {
trace.FromContext(ctx).Counter(trace.CounterResponsesHTTPFallback, 1)
stream, err := session.provider.StreamCompletion(ctx, request)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: session.provider.redact(err.Error()),
})
return
}
for event := range stream {
providerio.SendEvent(ctx, events, event)
}
}
func (session *codexTurnSession) disableWebSocket(connection *websocket.Conn) {
session.mu.Lock()
if session.connection == connection {
session.connection = nil
session.webSocketOff = true
session.lastRequest = nil
session.lastResponseID = ""
session.lastOutput = nil
}
session.mu.Unlock()
_ = connection.CloseNow()
}
func (provider *CodexProvider) dialResponsesWebSocket(ctx context.Context) (*websocket.Conn, error) {
endpoint, err := responsesWebSocketURL(provider.inner.endpoint)
if err != nil {
return nil, err
}
for authTry := 0; authTry < 2; authTry++ {
headers, err := provider.responsesWebSocketHeaders(ctx, authTry == 1)
if err != nil {
return nil, err
}
connection, response, err := websocket.Dial(ctx, endpoint, &websocket.DialOptions{
HTTPClient: provider.inner.httpClient,
HTTPHeader: headers,
CompressionMode: websocket.CompressionNoContextTakeover,
})
if err == nil {
connection.SetReadLimit(responsesWebSocketReadLimit)
return connection, nil
}
if response == nil || response.StatusCode != http.StatusUnauthorized || provider.inner.oauthResolver == nil || authTry == 1 {
return nil, err
}
}
return nil, errors.New("codex websocket authentication failed")
}
func (provider *CodexProvider) responsesWebSocketHeaders(ctx context.Context, forceRefresh bool) (http.Header, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.inner.endpoint, nil)
if err != nil {
return nil, err
}
base := providerio.AuthHeaders{
APIKey: provider.inner.apiKey,
DefaultAuthHeader: "Authorization",
DefaultAuthScheme: "Bearer",
AuthHeader: provider.inner.authHeader,
AuthScheme: provider.inner.authScheme,
AuthHeaderValue: provider.inner.authHeaderValue,
CustomHeaders: provider.inner.customHeaders,
}
if resolver := provider.inner.oauthResolver; resolver != nil {
header, value, ok, err := resolver(ctx, forceRefresh)
if err != nil {
return nil, err
}
if ok {
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{CustomHeaders: provider.inner.customHeaders})
if strings.TrimSpace(header) == "" {
header = "Authorization"
}
request.Header.Set(header, value)
} else {
providerio.ApplyAuthHeaders(request, base)
}
} else {
providerio.ApplyAuthHeaders(request, base)
}
provider.injectCodexHeaders(request)
request.Header.Set(responsesWebSocketBetaHeader, responsesWebSocketBetaValue)
return request.Header.Clone(), nil
}
func responsesWebSocketURL(endpoint string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
default:
return "", fmt.Errorf("unsupported Responses websocket URL scheme %q", parsed.Scheme)
}
return parsed.String(), nil
}
@@ -1,590 +0,0 @@
package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCodexTurnSessionChainsOnlyAppendOnlyRequests(t *testing.T) {
var mu sync.Mutex
requests := []map[string]any{}
betaHeader := ""
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
http.Error(writer, "websocket required", http.StatusUpgradeRequired)
return
}
mu.Lock()
betaHeader = request.Header.Get(responsesWebSocketBetaHeader)
mu.Unlock()
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
for index := 0; index < 3; index++ {
_, body, err := connection.Read(request.Context())
if err != nil {
return
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return
}
mu.Lock()
requests = append(requests, payload)
mu.Unlock()
switch index {
case 0:
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`,
`{"type":"response.output_item.added","item_id":"call-1","item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"read_file"}}`,
`{"type":"response.function_call_arguments.delta","item_id":"call-1","delta":"{}"}`,
`{"type":"response.output_item.done","item_id":"call-1","item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"read_file","arguments":"{}"}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
)
case 1:
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_text.delta","delta":"done"}`,
`{"type":"response.completed","response":{"id":"resp-2","status":"completed"}}`,
)
case 2:
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.completed","response":{"id":"resp-3","status":"completed"}}`,
)
}
}
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
// A disabled stream watchdog must use the parent context rather than creating
// an immediately-expired read deadline.
provider.inner.streamIdleTimeout = 0
session := openCodexSession(t, provider)
defer session.Close()
recorder := trace.NewRecorder("session-1", "run-1", "test")
recorder.Start()
streamContext := trace.WithContext(t.Context(), recorder)
base := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "Use tools carefully."},
{Role: zeroruntime.MessageRoleUser, Content: "Read the file."},
},
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
PromptCacheKey: "session-1",
}
first := collectCodexSessionEvents(t, session, base)
if !hasToolCallEnd(first, "call-1") {
t.Fatalf("first stream did not produce the tool call: %#v", first)
}
second := base
second.Messages = append(append([]zeroruntime.Message(nil), base.Messages...),
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: "call-1", Content: "contents"},
)
secondEvents := collectCodexSessionEventsContext(t, streamContext, session, second)
if got := joinedText(secondEvents); got != "done" {
t.Fatalf("second stream text = %q, want done", got)
}
third := second
third.ReasoningEffort = "high"
third.Messages = append(append([]zeroruntime.Message(nil), second.Messages...),
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Continue."},
)
collectCodexSessionEventsContext(t, streamContext, session, third)
finishedTrace := recorder.Finish()
if got := finishedTrace.Counter(trace.CounterResponseChainReused); got != 1 {
t.Fatalf("response-chain reuse count = %d, want 1", got)
}
if got := finishedTrace.Counter(trace.CounterResponseChainReset); got != 1 {
t.Fatalf("response-chain reset count = %d, want 1", got)
}
mu.Lock()
defer mu.Unlock()
if len(requests) != 3 {
t.Fatalf("websocket requests = %d, want 3", len(requests))
}
if betaHeader != responsesWebSocketBetaValue {
t.Fatalf("OpenAI-Beta = %q, want %q", betaHeader, responsesWebSocketBetaValue)
}
if requests[0]["type"] != responsesWebSocketRequest || requests[0]["previous_response_id"] != nil {
t.Fatalf("first request chaining fields = %#v", requests[0])
}
if requests[0]["parallel_tool_calls"] != true || requests[0]["prompt_cache_key"] != "session-1" {
t.Fatalf("first request stable fields = %#v", requests[0])
}
if requests[1]["previous_response_id"] != "resp-1" {
t.Fatalf("second previous_response_id = %#v", requests[1]["previous_response_id"])
}
secondInput, _ := requests[1]["input"].([]any)
if len(secondInput) != 1 || secondInput[0].(map[string]any)["type"] != "function_call_output" {
t.Fatalf("second incremental input = %#v", requests[1]["input"])
}
if requests[2]["previous_response_id"] != nil {
t.Fatalf("incompatible request reused response chain: %#v", requests[2])
}
thirdInput, _ := requests[2]["input"].([]any)
if len(thirdInput) <= 1 {
t.Fatalf("incompatible request did not send full input: %#v", thirdInput)
}
}
func TestCodexTurnSessionFallsBackWhenPreviousResponseIsMissing(t *testing.T) {
var mu sync.Mutex
webSocketRequests := 0
httpRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
for index := 0; index < 2; index++ {
if _, _, err := connection.Read(request.Context()); err != nil {
return
}
mu.Lock()
webSocketRequests++
mu.Unlock()
if index == 0 {
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_item.added","item_id":"call-1","item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"read_file"}}`,
`{"type":"response.output_item.done","item_id":"call-1","item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"read_file","arguments":"{}"}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
)
continue
}
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.error","code":"previous_response_not_found","message":"missing"}`,
)
}
return
}
mu.Lock()
httpRequests++
mu.Unlock()
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"fallback\"}\n\n")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-http\",\"status\":\"completed\"}}\n\n")
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
base := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Read it."}},
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
}
collectCodexSessionEvents(t, session, base)
second := base
second.Messages = append(append([]zeroruntime.Message(nil), base.Messages...),
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: "call-1", Content: "contents"},
)
events := collectCodexSessionEvents(t, session, second)
if got := joinedText(events); got != "fallback" {
t.Fatalf("fallback stream text = %q, want fallback; events=%#v", got, events)
}
for _, event := range events {
if event.Type == zeroruntime.StreamEventError {
t.Fatalf("response-chain recovery leaked an error: %#v", event)
}
}
mu.Lock()
defer mu.Unlock()
if webSocketRequests != 2 || httpRequests != 1 {
t.Fatalf("requests = websocket:%d http:%d, want 2/1", webSocketRequests, httpRequests)
}
}
func TestCodexTurnSessionFallsBackWhenSocketClosesBeforeOutput(t *testing.T) {
var mu sync.Mutex
httpRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
if _, _, err := connection.Read(request.Context()); err == nil {
_ = connection.CloseNow()
}
return
}
mu.Lock()
httpRequests++
mu.Unlock()
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"recovered\"}\n\n")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-http\",\"status\":\"completed\"}}\n\n")
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
recorder := trace.NewRecorder("session-fallback", "run-fallback", "test")
recorder.Start()
events := collectCodexSessionEventsContext(t, trace.WithContext(t.Context(), recorder), session, zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Hello"}},
})
if got := joinedText(events); got != "recovered" {
t.Fatalf("fallback text = %q, want recovered", got)
}
mu.Lock()
defer mu.Unlock()
if httpRequests != 1 {
t.Fatalf("HTTP fallback requests = %d, want 1", httpRequests)
}
if got := recorder.Finish().Counter(trace.CounterResponsesHTTPFallback); got != 1 {
t.Fatalf("responses HTTP fallback counter = %d, want 1", got)
}
}
func TestCodexTurnSessionKeepsWebSocketAfterIncompleteResponse(t *testing.T) {
var mu sync.Mutex
webSocketRequests := 0
httpRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
for index := 0; index < 2; index++ {
if _, _, err := connection.Read(request.Context()); err != nil {
return
}
mu.Lock()
webSocketRequests++
mu.Unlock()
if index == 0 {
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.incomplete","response":{"id":"resp-1","status":"incomplete"}}`,
)
continue
}
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_text.delta","delta":"websocket"}`,
`{"type":"response.completed","response":{"id":"resp-2","status":"completed"}}`,
)
}
return
}
mu.Lock()
httpRequests++
mu.Unlock()
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"fallback\"}\n\n")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-http\",\"status\":\"completed\"}}\n\n")
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Continue."}},
}
first := collectCodexSessionEvents(t, session, request)
if len(first) != 1 || first[0].Type != zeroruntime.StreamEventDone || first[0].FinishReason != zeroruntime.FinishReasonLength {
t.Fatalf("incomplete response events = %#v, want length completion", first)
}
second := collectCodexSessionEvents(t, session, request)
if got := joinedText(second); got != "websocket" {
t.Fatalf("second turn text = %q, want websocket; events=%#v", got, second)
}
mu.Lock()
defer mu.Unlock()
if webSocketRequests != 2 || httpRequests != 0 {
t.Fatalf("requests = websocket:%d http:%d, want 2/0", webSocketRequests, httpRequests)
}
}
func TestCodexTurnSessionDoesNotReplayAfterVisibleOutput(t *testing.T) {
var mu sync.Mutex
httpRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
if _, _, err := connection.Read(request.Context()); err == nil {
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_text.delta","delta":"visible"}`,
)
}
_ = connection.Close(websocket.StatusInternalError, "interrupted")
return
}
mu.Lock()
httpRequests++
mu.Unlock()
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
stream, err := session.Stream(t.Context(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Hello"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
sawText, sawError := false, false
for event := range stream {
sawText = sawText || event.Type == zeroruntime.StreamEventText
sawError = sawError || event.Type == zeroruntime.StreamEventError
}
if !sawText || !sawError {
t.Fatalf("visible interrupted stream = text:%v error:%v", sawText, sawError)
}
mu.Lock()
defer mu.Unlock()
if httpRequests != 0 {
t.Fatalf("visible interrupted stream was replayed over HTTP %d time(s)", httpRequests)
}
}
func TestCodexTurnSessionReportsWebSocketIdleTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
if _, _, err := connection.Read(request.Context()); err != nil {
return
}
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_text.delta","delta":"visible"}`,
)
<-request.Context().Done()
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
provider.inner.streamIdleTimeout = 200 * time.Millisecond
session := openCodexSession(t, provider)
defer session.Close()
stream, err := session.Stream(t.Context(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Hello"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
var streamError string
for event := range stream {
if event.Type == zeroruntime.StreamEventError {
streamError = event.Error
break
}
}
if !strings.Contains(streamError, "idle timeout after 200ms") {
t.Fatalf("stream error = %q, want idle-timeout detail", streamError)
}
}
func TestCodexTurnSessionRejectsOverlappingStreams(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
releaseStream := func() { releaseOnce.Do(func() { close(release) }) }
t.Cleanup(releaseStream)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
if _, _, err := connection.Read(request.Context()); err != nil {
return
}
close(started)
<-release
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
)
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Hello"}},
}
first, err := session.Stream(t.Context(), request)
if err != nil {
t.Fatalf("first Stream: %v", err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("first WebSocket stream did not start")
}
if _, err := session.Stream(t.Context(), request); !errors.Is(err, errCodexTurnSessionBusy) {
t.Fatalf("overlapping Stream error = %v, want %v", err, errCodexTurnSessionBusy)
}
releaseStream()
for range first {
}
}
func TestCodexTurnSessionHTTPFallbackRedactsSetupError(t *testing.T) {
const secret = "sk-secret-fallback"
provider, err := NewCodexProvider(CodexOptions{Options: Options{
APIKey: secret,
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: "gpt-test",
}})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
session := &codexTurnSession{provider: provider}
events := make(chan zeroruntime.StreamEvent, 1)
session.forwardHTTP(t.Context(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRole(secret), Content: "invalid"}},
}, events)
select {
case event := <-events:
if event.Type != zeroruntime.StreamEventError {
t.Fatalf("fallback event type = %s, want error", event.Type)
}
if strings.Contains(event.Error, secret) {
t.Fatalf("fallback error leaked credential: %q", event.Error)
}
case <-time.After(time.Second):
t.Fatal("fallback did not emit setup error")
}
}
func TestCodexTurnSessionCloseDoesNotStartPrewarm(t *testing.T) {
provider, err := NewCodexProvider(CodexOptions{Options: Options{
APIKey: "test-token",
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: "gpt-test",
}})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
session, err := NewCodexTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{}).OpenTurnSession(t.Context())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
done := make(chan error, 1)
go func() { done <- session.Close() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("Close blocked without a prewarm attempt")
}
}
func newCodexSessionTestProvider(t *testing.T, server *httptest.Server) *CodexProvider {
t.Helper()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{
APIKey: "test-token",
BaseURL: server.URL,
Model: "gpt-test",
HTTPClient: server.Client(),
},
AccountID: "account-test",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
return provider
}
func openCodexSession(t *testing.T, provider *CodexProvider) zeroruntime.TurnSession {
t.Helper()
session, err := NewCodexTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{}).OpenTurnSession(t.Context())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
if err := session.Prewarm(t.Context()); err != nil {
t.Fatalf("Prewarm: %v", err)
}
return session
}
func collectCodexSessionEvents(t *testing.T, session zeroruntime.TurnSession, request zeroruntime.CompletionRequest) []zeroruntime.StreamEvent {
t.Helper()
return collectCodexSessionEventsContext(t, t.Context(), session, request)
}
func collectCodexSessionEventsContext(t *testing.T, ctx context.Context, session zeroruntime.TurnSession, request zeroruntime.CompletionRequest) []zeroruntime.StreamEvent {
t.Helper()
stream, err := session.Stream(ctx, request)
if err != nil {
t.Fatalf("Stream: %v", err)
}
events := []zeroruntime.StreamEvent{}
for event := range stream {
events = append(events, event)
if event.Type == zeroruntime.StreamEventError {
t.Fatalf("stream error: %s", event.Error)
}
}
return events
}
func writeWebSocketEvents(ctx context.Context, connection *websocket.Conn, events ...string) {
for _, event := range events {
if err := connection.Write(ctx, websocket.MessageText, []byte(event)); err != nil {
return
}
}
}
func hasToolCallEnd(events []zeroruntime.StreamEvent, id string) bool {
for _, event := range events {
if event.Type == zeroruntime.StreamEventToolCallEnd && event.ToolCallID == id {
return true
}
}
return false
}
func joinedText(events []zeroruntime.StreamEvent) string {
var builder strings.Builder
for _, event := range events {
if event.Type == zeroruntime.StreamEventText {
builder.WriteString(event.Content)
}
}
return builder.String()
}
@@ -2,8 +2,6 @@ package openai
import (
"context"
"reflect"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -27,126 +25,6 @@ func TestToolCallKeyOutputIndexZero(t *testing.T) {
}
}
func TestResponsesStatePreservesDistinctCallID(t *testing.T) {
provider := &CodexProvider{}
state := newResponsesState()
events := make(chan zeroruntime.StreamEvent, 4)
provider.handleOutputItemAdded(context.Background(), &responsesEvent{
ItemID: "item-1",
Item: &itemPayload{
Type: "function_call",
ID: "item-1",
CallID: "call-1",
Name: "read_file",
},
}, state, events)
items := state.outputInputItems()
if len(items) != 1 || items[0].ID != "item-1" || items[0].CallID != "call-1" {
t.Fatalf("captured function call = %#v, want item id item-1 and call id call-1", items)
}
}
func TestCodexCustomApplyPatchStreamProducesFreeformCall(t *testing.T) {
provider := &CodexProvider{}
state := newResponsesState()
events := make(chan zeroruntime.StreamEvent, 16)
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
for _, payload := range []string{
`{"type":"response.output_item.added","item_id":"item-1","item":{"type":"custom_tool_call","call_id":"call-1","name":"apply_patch"}}`,
`{"type":"response.custom_tool_call_input.delta","item_id":"item-1","call_id":"call-1","delta":"*** Begin Patch\n*** Add File: hello.txt\n"}`,
`{"type":"response.custom_tool_call_input.delta","item_id":"item-1","call_id":"call-1","delta":"+hello\n*** End Patch\n"}`,
`{"type":"response.output_item.done","item_id":"item-1","item":{"type":"custom_tool_call","call_id":"call-1","name":"apply_patch","input":"*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
} {
if keepReading := provider.emitResponsesEvent(context.Background(), payload, state, events); !keepReading && !state.done {
t.Fatalf("stream stopped before terminal event for %s", payload)
}
}
close(events)
collected := zeroruntime.CollectStream(context.Background(), events)
if len(collected.ToolCalls) != 1 {
t.Fatalf("tool calls = %#v, want one", collected.ToolCalls)
}
call := collected.ToolCalls[0]
if call.ID != "item-1" || call.ProviderCallID != "call-1" || call.Name != "apply_patch" || !call.Freeform || call.Arguments != patch {
t.Fatalf("custom apply_patch call = %#v", call)
}
}
func TestCodexApplyPatchUsesFreeformWireContract(t *testing.T) {
provider, err := NewCodexProvider(CodexOptions{Options: Options{
APIKey: "test-token",
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: "gpt-test",
}})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
request, err := provider.buildResponsesRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "Add the file."},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{
ID: "item-1", ProviderCallID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "item-1", ToolCallProviderID: "call-1", Content: "Done!"},
},
Tools: []zeroruntime.ToolDefinition{{
Name: "apply_patch", Description: "Apply a patch.", Type: zeroruntime.ToolDefinitionFreeform,
}},
})
if err != nil {
t.Fatalf("buildResponsesRequest: %v", err)
}
if len(request.Tools) != 1 || request.Tools[0].Type != string(zeroruntime.ToolDefinitionFreeform) ||
request.Tools[0].Format == nil || request.Tools[0].Format.Syntax != "lark" || request.Tools[0].Parameters != nil {
t.Fatalf("apply_patch tool wire shape = %#v", request.Tools)
}
if !strings.Contains(request.Tools[0].Format.Definition, "*** Begin Patch") {
t.Fatalf("apply_patch grammar = %q", request.Tools[0].Format.Definition)
}
if !strings.Contains(request.Tools[0].Description, "absolute file paths") || !strings.Contains(request.Tools[0].Description, "granted extra write root") {
t.Fatalf("apply_patch description does not explain extra-root paths: %q", request.Tools[0].Description)
}
if len(request.Input) != 3 || request.Input[1].Type != "custom_tool_call" || request.Input[1].ID != "item-1" ||
request.Input[1].CallID != "call-1" || request.Input[1].Input != patch ||
request.Input[2].Type != "custom_tool_call_output" || request.Input[2].CallID != "call-1" {
t.Fatalf("custom apply_patch replay input = %#v", request.Input)
}
}
func TestCodexPreservesCustomApplyPatchFunction(t *testing.T) {
provider, err := NewCodexProvider(CodexOptions{Options: Options{
APIKey: "test-token",
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: "gpt-test",
}})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
parameters := map[string]any{
"type": "object",
"properties": map[string]any{"value": map[string]any{"type": "string"}},
}
request, err := provider.buildResponsesRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Call the custom tool."}},
Tools: []zeroruntime.ToolDefinition{{
Name: "apply_patch", Description: "Custom integration tool.", Parameters: parameters,
}},
})
if err != nil {
t.Fatalf("buildResponsesRequest: %v", err)
}
if len(request.Tools) != 1 {
t.Fatalf("tools = %#v, want one", request.Tools)
}
tool := request.Tools[0]
if tool.Type != string(zeroruntime.ToolDefinitionFunction) || tool.Format != nil ||
tool.Description != "Custom integration tool." || !reflect.DeepEqual(tool.Parameters, parameters) {
t.Fatalf("custom apply_patch function was rewritten: %#v", tool)
}
}
func TestHandleTerminalResponseNilPayload(t *testing.T) {
p := &CodexProvider{}
@@ -212,14 +90,11 @@ func TestHandleTerminalResponseFailedPayloadWithoutError(t *testing.T) {
// A response.completed with a payload and no error remains a clean done.
ok := make(chan zeroruntime.StreamEvent, 8)
p.handleTerminalResponse(context.Background(),
&responsesEvent{Type: responsesEventCompleted, Response: &responsePayload{ID: "resp-1", Status: "completed"}}, &responsesState{}, ok)
&responsesEvent{Type: responsesEventCompleted, Response: &responsePayload{Status: "completed"}}, &responsesState{}, ok)
close(ok)
for ev := range ok {
if ev.Type == zeroruntime.StreamEventError {
t.Error("response.completed with a non-error payload must NOT emit an error")
}
if ev.Type == zeroruntime.StreamEventDone && ev.ResponseID != "resp-1" {
t.Errorf("done response id = %q, want resp-1", ev.ResponseID)
}
}
}
+3 -3
View File
@@ -771,7 +771,7 @@ func TestCodexProviderParsesResponsesTextDeltas(t *testing.T) {
`{"type":"response.output_text.delta","delta":"hello "}`,
`{"type":"response.output_text.delta","delta":"world"}`,
`{"type":"response.output_text.delta","delta":"!"}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":7,"output_tokens":3,"output_tokens_details":{"reasoning_tokens":1},"input_tokens_details":{"cached_tokens":2,"cache_write_tokens":1}}}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":7,"output_tokens":3,"output_tokens_details":{"reasoning_tokens":1},"input_tokens_details":{"cached_tokens":2}}}}`,
)
defer srv.Close()
@@ -809,8 +809,8 @@ func TestCodexProviderParsesResponsesTextDeltas(t *testing.T) {
if usage == nil {
t.Fatal("expected a usage event, got none")
}
if usage.InputTokens != 7 || usage.OutputTokens != 3 || usage.CachedInputTokens != 2 || usage.CacheWriteTokens != 1 || usage.ReasoningTokens != 1 {
t.Fatalf("usage = %+v, want input=7 output=3 cached=2 cache-write=1 reasoning=1", *usage)
if usage.InputTokens != 7 || usage.OutputTokens != 3 || usage.CachedInputTokens != 2 || usage.ReasoningTokens != 1 {
t.Fatalf("usage = %+v, want input=7 output=3 cached=2 reasoning=1", *usage)
}
if !gotDone {
t.Fatal("expected a done event, got none")
+1 -17
View File
@@ -357,7 +357,6 @@ func (provider *Provider) emitChunk(
PromptTokens: chunk.Usage.PromptTokens,
CompletionTokens: chunk.Usage.CompletionTokens,
CachedInputTokens: chunk.Usage.PromptTokensDetails.CachedTokens,
CacheWriteTokens: chunk.Usage.PromptTokensDetails.CacheWriteTokens,
ReasoningTokens: chunk.Usage.CompletionTokensDetails.ReasoningTokens,
},
})
@@ -487,7 +486,7 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
Function: toolFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: normalizeToolParameters(tool.Parameters),
Parameters: tool.Parameters,
},
})
}
@@ -495,21 +494,6 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
return mapped
}
// normalizeToolParameters keeps schemas accepted by strict OpenAI-compatible
// servers such as LM Studio. No-argument tools still need an object-valued
// parameters.properties field instead of an omitted field.
func normalizeToolParameters(parameters map[string]any) map[string]any {
normalized := make(map[string]any, len(parameters)+1)
for key, value := range parameters {
normalized[key] = value
}
properties, ok := normalized["properties"].(map[string]any)
if !ok || properties == nil {
normalized["properties"] = map[string]any{}
}
return normalized
}
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
// kill switch is set to a truthy value. "0" and "false" (any case) are
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
+2 -132
View File
@@ -3,7 +3,6 @@ package openai
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
@@ -106,72 +105,6 @@ func TestStreamCompletionPostsChatCompletionRequest(t *testing.T) {
if tool["type"] != "function" {
t.Fatalf("unexpected tool wrapper: %#v", tool)
}
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
// TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject locks in the
// normalizeToolParameters fix: a tool whose parameters carry a typed-nil
// properties map must serialize "properties":{} on the wire, never
// "properties":null, because strict OpenAI-compatible servers (LM Studio)
// reject the null form.
func TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject(t *testing.T) {
var gotBody map[string]any
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotRaw = raw
if err := json.Unmarshal(raw, &gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "no_args",
Description: "Takes no arguments",
Parameters: map[string]any{"type": "object", "properties": map[string]any(nil)},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if !strings.Contains(string(gotRaw), `"properties":{}`) {
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":null`) {
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
func TestNewRequiresModelButNotAPIKey(t *testing.T) {
@@ -260,14 +193,14 @@ func TestStreamCompletionAppliesCustomAuthAndHeaders(t *testing.T) {
func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"content":"hello "}}]}`)
writeSSE(w, `{"choices":[{"delta":{"content":"zero"}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":3,"cache_write_tokens":2}}}`)
writeSSE(w, `{"choices":[{"delta":{"content":"zero"}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":3}}}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
assertEvent(t, events[0], zeroruntime.StreamEventText, "hello ")
assertEvent(t, events[1], zeroruntime.StreamEventText, "zero")
if events[2].Type != zeroruntime.StreamEventUsage || events[2].Usage.PromptTokens != 12 || events[2].Usage.CompletionTokens != 5 || events[2].Usage.CachedInputTokens != 3 || events[2].Usage.CacheWriteTokens != 2 {
if events[2].Type != zeroruntime.StreamEventUsage || events[2].Usage.PromptTokens != 12 || events[2].Usage.CompletionTokens != 5 || events[2].Usage.CachedInputTokens != 3 {
t.Fatalf("unexpected usage event: %#v", events[2])
}
if events[3].Type != zeroruntime.StreamEventDone {
@@ -1546,66 +1479,3 @@ func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) {
t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"])
}
}
// TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject locks in the
// normalizeToolParameters type-assert branch: when a tool's parameters carry a
// `properties` field whose value is a non-map (e.g., an empty slice), it must
// be normalized to "properties":{} on the wire, never "properties":[] or
// "properties":null.
func TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject(t *testing.T) {
var gotBody map[string]any
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotRaw = raw
if err := json.Unmarshal(raw, &gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "no_args",
Description: "Takes no arguments",
Parameters: map[string]any{"type": "object", "properties": []any{}},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if !strings.Contains(string(gotRaw), `"properties":{}`) {
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":null`) {
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":[]`) {
t.Fatalf("request body must not serialize properties as an empty array, got: %s", gotRaw)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
+1 -6
View File
@@ -195,12 +195,7 @@ func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest)
func wireToolsDigest(tools []zeroruntime.ToolDefinition) string {
rendered := make([]string, 0, len(tools))
for _, tool := range tools {
wireShape := struct {
Type zeroruntime.ToolDefinitionType `json:"type,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
Format *zeroruntime.ToolDefinitionFormat `json:"format,omitempty"`
}{Type: tool.Type, Parameters: tool.Parameters, Format: tool.Format}
schema, err := json.Marshal(wireShape)
schema, err := json.Marshal(tool.Parameters)
if err != nil {
rendered = append(rendered, tool.Name+"\n"+tool.Description+"\n__non_json:"+tool.Name)
continue
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more