mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72440a85bf | ||
|
|
ed38f57fab | ||
|
|
7e6f5e2a4a | ||
|
|
27b319ca88 | ||
|
|
1fde941803 | ||
|
|
7acac7acee | ||
|
|
1649008506 | ||
|
|
6fe0d1edcd | ||
|
|
e59bf78cab | ||
|
|
6645f9744b | ||
|
|
2749447cdc | ||
|
|
ad34dc8d81 | ||
|
|
6edf9a8b78 | ||
|
|
90dcfd127e | ||
|
|
01c3498205 | ||
|
|
1ec7219aee | ||
|
|
1a81292d3f | ||
|
|
a78c6c357e | ||
|
|
8dd33ec39e | ||
|
|
d7ac85cbf9 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.7.0"}
|
||||
{".":"0.8.0"}
|
||||
@@ -111,3 +111,42 @@ 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.
|
||||
@@ -6,6 +6,27 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -371,9 +371,13 @@ go run ./cmd/zero-perf-bench
|
||||
|
||||
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
||||
session (background connection prewarm + request-prefix telemetry) for headless
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
|
||||
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
|
||||
variable unset and set.
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
|
||||
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
|
||||
with the variable unset and set.
|
||||
|
||||
Native ChatGPT Responses sessions are enabled by default. Set
|
||||
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
|
||||
transport.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
|
||||
+1
-1
@@ -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 the documented Firecrawl endpoint — see the README).
|
||||
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
|
||||
@@ -4,7 +4,7 @@ go 1.26.6
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
charm.land/bubbletea/v2 v2.0.8
|
||||
charm.land/bubbletea/v2 v2.0.9
|
||||
charm.land/lipgloss/v2 v2.0.5
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
||||
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
||||
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
|
||||
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -64,8 +64,6 @@ 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=
|
||||
|
||||
+16
-2
@@ -296,6 +296,16 @@ 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" {
|
||||
@@ -311,10 +321,14 @@ 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: buildPermissionOptions(req),
|
||||
Options: options,
|
||||
}
|
||||
var result RequestPermissionResult
|
||||
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
|
||||
@@ -323,7 +337,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, req.AvailableDecisions), nil
|
||||
return decisionFromOutcome(result.Outcome, options), nil
|
||||
}
|
||||
|
||||
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+57
-18
@@ -15,14 +15,7 @@ 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 := req.AvailableDecisions
|
||||
if len(actions) == 0 {
|
||||
// Sensible default if ZERO didn't enumerate: allow once / reject.
|
||||
actions = []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
}
|
||||
actions := offeredDecisions(req)
|
||||
options := make([]PermissionOption, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
|
||||
@@ -38,6 +31,32 @@ 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).
|
||||
@@ -49,8 +68,16 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
// disclosure cannot be TUI-only.
|
||||
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
|
||||
switch action {
|
||||
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
||||
case agent.PermissionDecisionAllow:
|
||||
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:
|
||||
@@ -70,19 +97,31 @@ 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 offered); anything unrecognized
|
||||
// fails closed to deny.
|
||||
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
|
||||
// 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 {
|
||||
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(action, offered) {
|
||||
return agent.PermissionDecision{Action: action}
|
||||
if actionOffered(outcome.OptionID, offered) {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
|
||||
}
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
||||
default:
|
||||
@@ -90,9 +129,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.Permi
|
||||
}
|
||||
}
|
||||
|
||||
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
|
||||
for _, a := range offered {
|
||||
if a == action {
|
||||
func actionOffered(optionID string, offered []PermissionOption) bool {
|
||||
for _, option := range offered {
|
||||
if option.OptionID == optionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +56,11 @@ 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)
|
||||
for _, option := range buildPermissionOptions(request) {
|
||||
options := buildPermissionOptions(request)
|
||||
for _, option := range options {
|
||||
decision := decisionFromOutcome(
|
||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
|
||||
request.AvailableDecisions,
|
||||
options,
|
||||
)
|
||||
if string(decision.Action) != option.OptionID {
|
||||
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
|
||||
|
||||
@@ -39,11 +39,14 @@ func TestBuildPermissionOptionsDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecisionFromOutcome(t *testing.T) {
|
||||
offered := []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
offered := buildPermissionOptions(agent.PermissionRequest{
|
||||
ToolName: "bash",
|
||||
AvailableDecisions: []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)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,43 @@ 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",
|
||||
|
||||
+178
-11
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -144,11 +146,17 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
for _, tool := range tools {
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
total += ApproxTextTokens(string(tool.Type))
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
if tool.Format != nil {
|
||||
total += ApproxTextTokens(tool.Format.Type)
|
||||
total += ApproxTextTokens(tool.Format.Syntax)
|
||||
total += ApproxTextTokens(tool.Format.Definition)
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
}
|
||||
return total
|
||||
@@ -390,6 +398,27 @@ type compactionState struct {
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
|
||||
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
|
||||
// (Options.Summarizer) for the current main model. summarizerProvider
|
||||
// memoizes the built provider and summarizerResolved records that the
|
||||
// factory ran (it may legitimately return no provider: the main model is
|
||||
// already the cheap one). summarizerBroken is the sticky fallback: once a
|
||||
// build or call fails, the run stays on the main provider until the next
|
||||
// model switch — a misconfigured cheap model must never break (or
|
||||
// repeatedly delay) compaction.
|
||||
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
summarizerProvider Provider
|
||||
summarizerResolved bool
|
||||
summarizerBroken bool
|
||||
// window is the context window the threshold was derived from. The
|
||||
// reactive free-prune stage compares against it (the limit the provider
|
||||
// enforces), not against the 70% proactive threshold — a pruned history
|
||||
// between the two would still succeed on retry.
|
||||
window int
|
||||
// mainModel is the model the run currently streams on; the summarizer
|
||||
// choice is made against it and remade after a mid-run switch.
|
||||
mainModel string
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
@@ -424,17 +453,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
window: options.ContextWindow,
|
||||
mainModel: options.Model,
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
summarizerFactory: options.Summarizer,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// updateWindow re-derives the compaction threshold after a mid-run model
|
||||
// switch. The low-water mark is reset: it was measured against the old
|
||||
// threshold, and keeping it could suppress a compaction the smaller window
|
||||
// now requires. <= 0 (unknown model) leaves everything unchanged.
|
||||
func (state *compactionState) updateWindow(window int) {
|
||||
if window <= 0 {
|
||||
return
|
||||
}
|
||||
state.enabled = true
|
||||
state.window = window
|
||||
state.threshold = compactionThreshold(window)
|
||||
state.lowWaterMark = 0
|
||||
if state.planner != nil {
|
||||
state.planner.config.contextWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// switchModel records a mid-run model switch: the window is re-derived
|
||||
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
|
||||
// the next compaction re-resolves it against the new main model. A run that
|
||||
// started on the cheap model (no dedicated summarizer) and escalated to an
|
||||
// expensive one therefore regains cheap summaries — the exact workflow a
|
||||
// dedicated summarizer exists for.
|
||||
func (state *compactionState) switchModel(modelID string, window int) {
|
||||
state.mainModel = modelID
|
||||
state.updateWindow(window)
|
||||
state.summarizerProvider = nil
|
||||
state.summarizerResolved = false
|
||||
state.summarizerBroken = false
|
||||
}
|
||||
|
||||
// summarizeProvider returns the provider summarization calls should use and
|
||||
// whether it is the dedicated summarizer: that one when it applies to the
|
||||
// current main model and is healthy, else the main provider. The factory runs
|
||||
// at most once per main model; a build error marks the summarizer broken for
|
||||
// that model. Callers branch on the boolean rather than comparing Provider
|
||||
// values — interface comparison panics on a non-comparable dynamic type.
|
||||
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
|
||||
if state.summarizerFactory == nil || state.summarizerBroken {
|
||||
return main, false
|
||||
}
|
||||
if !state.summarizerResolved {
|
||||
state.summarizerResolved = true
|
||||
built, err := state.summarizerFactory(ctx, state.mainModel)
|
||||
if err != nil {
|
||||
state.summarizerBroken = true
|
||||
return main, false
|
||||
}
|
||||
state.summarizerProvider = built
|
||||
}
|
||||
if state.summarizerProvider == nil {
|
||||
return main, false
|
||||
}
|
||||
return state.summarizerProvider, true
|
||||
}
|
||||
|
||||
// fitsWindow reports whether a calibrated size is safely under the limit the
|
||||
// provider enforces. The proactive threshold (70% of the window) is a trigger
|
||||
// for the paid summarizer, not the hard limit; the reactive path compares
|
||||
// against the window itself, with a small margin for estimate error, so a
|
||||
// history pruned to, say, 85% of the window takes the free retry.
|
||||
func (state *compactionState) fitsWindow(size int) bool {
|
||||
if state.window <= 0 {
|
||||
return size <= state.threshold
|
||||
}
|
||||
return size <= state.window-state.window/20
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
|
||||
// when the history is under threshold, or when the history has not grown past
|
||||
@@ -528,10 +628,34 @@ func (state *compactionState) recover(
|
||||
// context-limit error never triggers an unexpected summarization call.
|
||||
return messages, false, nil
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
if !isContextLimitError(errorMessage) {
|
||||
return messages, false, nil
|
||||
}
|
||||
if !isContextLimitError(errorMessage) {
|
||||
|
||||
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
|
||||
// often be fixed for free by dropping superseded reads and pruning old
|
||||
// tool-result bodies. It runs regardless of the one-shot reactive budget
|
||||
// (it costs nothing and cannot loop: a second pass finds nothing left to
|
||||
// prune). The free retry is taken only when the pruned history fits the
|
||||
// window the provider enforces — otherwise a retry would fail the same
|
||||
// way, so fall through to the paid summarizer in this same call.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
prunedAny := false
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if prunedAny {
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if state.fitsWindow(size) {
|
||||
state.lowWaterMark = size
|
||||
return messages, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if state.reactiveAttempted {
|
||||
return messages, false, nil
|
||||
}
|
||||
|
||||
@@ -571,11 +695,21 @@ func (state *compactionState) recover(
|
||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||
// The call goes to the dedicated (cheap) summarizer when one applies; if that
|
||||
// call fails the summarizer is marked broken for the current model and the
|
||||
// same slice is retried once on the main provider, so the caller sees at most
|
||||
// one (main-provider-quality) error.
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
chosen, dedicated := state.summarizeProvider(ctx, provider)
|
||||
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
if err == nil || !dedicated {
|
||||
return summary, err
|
||||
}
|
||||
state.summarizerBroken = true
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
}
|
||||
}
|
||||
@@ -659,8 +793,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
|
||||
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
|
||||
// search results), yet the summary instructions only need their gist — the
|
||||
// model that ACTED on the full output already turned it into decisions the
|
||||
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
|
||||
// head+tail of a result while cutting the summarizer input by 5-10x on
|
||||
// tool-heavy sessions; the summarizer's own cost is the price being cut.
|
||||
const summaryToolResultClamp = 2000
|
||||
|
||||
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
|
||||
// a write_file call carries the whole file body in its arguments.
|
||||
const summaryToolArgsClamp = 500
|
||||
|
||||
// clampForSummary head+tail-truncates text for the summarizer transcript,
|
||||
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
|
||||
func clampForSummary(text string, max int) string {
|
||||
if max <= 0 || len(text) <= max {
|
||||
return text
|
||||
}
|
||||
head := max * 3 / 4
|
||||
tail := max - head
|
||||
for head > 0 && !utf8.RuneStart(text[head]) {
|
||||
head--
|
||||
}
|
||||
start := len(text) - tail
|
||||
for start < len(text) && !utf8.RuneStart(text[start]) {
|
||||
start++
|
||||
}
|
||||
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
|
||||
}
|
||||
|
||||
// renderTranscript flattens messages into a plain-text transcript for the
|
||||
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
||||
// Tool-result bodies and tool-call arguments are clamped (see the constants
|
||||
// above): the summary needs what was done and decided, not the raw payloads.
|
||||
func renderTranscript(messages []zeroruntime.Message) string {
|
||||
lines := make([]string, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
@@ -670,13 +837,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
|
||||
if len(message.ToolCalls) > 0 {
|
||||
calls := make([]string, 0, len(message.ToolCalls))
|
||||
for _, call := range message.ToolCalls {
|
||||
calls = append(calls, call.Name+"("+call.Arguments+")")
|
||||
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
||||
}
|
||||
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case zeroruntime.MessageRoleTool:
|
||||
lines = append(lines, "tool result: "+message.Content)
|
||||
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
||||
default:
|
||||
lines = append(lines, string(message.Role)+": "+message.Content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// compactableHistory returns a history whose middle is large enough to trip
|
||||
// the proactive threshold for a 1000-token window.
|
||||
func compactableHistory() []zeroruntime.Message {
|
||||
return []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
|
||||
}
|
||||
}
|
||||
|
||||
// Summarization must run on the dedicated (cheap) summarizer when one is
|
||||
// configured — the main provider sees no summarize traffic at all.
|
||||
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
|
||||
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
main := &mockProvider{}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
|
||||
|
||||
if len(cheap.requests) == 0 {
|
||||
t.Fatal("dedicated summarizer never received the summarize call")
|
||||
}
|
||||
if len(main.requests) != 0 {
|
||||
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
|
||||
t.Fatal("compaction did not shrink the history")
|
||||
}
|
||||
found := false
|
||||
for _, message := range compacted {
|
||||
if strings.Contains(message.Content, "CHEAP SUMMARY") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cheap summarizer's summary missing from the compacted history")
|
||||
}
|
||||
}
|
||||
|
||||
// A failing summarizer falls back to the main provider for the SAME slice and
|
||||
// stays broken for the rest of the run — no repeated failed cheap calls.
|
||||
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
|
||||
|
||||
summary, err := summarize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback must succeed via the main provider: %v", err)
|
||||
}
|
||||
if summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, want the main provider's", summary)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
|
||||
}
|
||||
if !state.summarizerBroken {
|
||||
t.Fatal("a failed summarizer must be marked broken for the run")
|
||||
}
|
||||
|
||||
// Second summarization: straight to main, no cheap retry.
|
||||
if _, err := summarize(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
|
||||
}
|
||||
if len(main.requests) != 2 {
|
||||
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A factory that errors (misconfigured model) is equivalent to no summarizer:
|
||||
// main provider used, factory not retried.
|
||||
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return nil, errors.New("unknown model")
|
||||
},
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
|
||||
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("after a factory failure the run must stay on the main provider")
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool payloads are clamped in the summarizer transcript: results to
|
||||
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
|
||||
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
|
||||
bigResult := strings.Repeat("r", 50_000)
|
||||
bigArgs := strings.Repeat("w", 20_000)
|
||||
transcript := renderTranscript([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "c1", Name: "write_file", Arguments: bigArgs},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
|
||||
})
|
||||
|
||||
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
|
||||
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
|
||||
}
|
||||
if !strings.Contains(transcript, "chars omitted for summarization") {
|
||||
t.Fatal("clamped transcript must carry the omission marker")
|
||||
}
|
||||
if !strings.Contains(transcript, "keep going") {
|
||||
t.Fatal("non-tool content must survive unclamped")
|
||||
}
|
||||
// Head AND tail of the clamped payloads survive.
|
||||
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
|
||||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
|
||||
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// bigToolHistory returns a history with n large tool results (~40k estimated
|
||||
// tokens each): the trailing protection window keeps the newest verbatim,
|
||||
// the older ones are prunable.
|
||||
func bigToolHistory(n int) []zeroruntime.Message {
|
||||
bigBody := strings.Repeat("x", 160_000)
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
|
||||
for i := 0; i < n; i++ {
|
||||
id := string(rune('a' + i))
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
|
||||
)
|
||||
}
|
||||
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
|
||||
}
|
||||
|
||||
func summaryProvider(text string) *mockProvider {
|
||||
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
}
|
||||
|
||||
// A context-limit error is first answered with the free prune stage; when the
|
||||
// pruned history fits the window the retry is free (no summarizer call) and
|
||||
// the one-shot reactive budget is untouched. The paid summarizer only runs
|
||||
// when a later error finds nothing left to prune.
|
||||
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// The protected trailing tool output (~40k tokens) must fit the window.
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("prunable history that fits must produce a free retry")
|
||||
}
|
||||
if len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
t.Fatal("a free prune retry must not consume the reactive budget")
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
|
||||
// Second context-limit error on the pruned history: nothing meaningfully
|
||||
// prunable remains, so the paid summarizer fires and consumes the budget.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("second recover must compact and retry")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("second recover must reach the summarizer")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("a paid recover must consume the reactive budget")
|
||||
}
|
||||
}
|
||||
|
||||
// When pruning reclaims something but the pruned history still exceeds the
|
||||
// window, recover falls through to the paid summarizer in the SAME call
|
||||
// instead of spending a retry that would fail the same way.
|
||||
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// A 1,000-token window: even the protected trailing output overflows it.
|
||||
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("insufficient prune must still end in a retry via summarization")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("the paid summarizer consumes the reactive budget")
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(messages) {
|
||||
t.Fatal("recover did not shrink the history")
|
||||
}
|
||||
}
|
||||
|
||||
// The free prune stage is not gated behind the one-shot budget: after a paid
|
||||
// reactive compaction, a later context-limit error still gets a free retry
|
||||
// when pruning makes the history fit.
|
||||
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
state.reactiveAttempted = true
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
// With nothing left to prune and the budget spent, the caller keeps its
|
||||
// original error — recover neither loops nor summarizes.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil || retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
|
||||
}
|
||||
}
|
||||
|
||||
// The free-retry gate compares against the window the provider enforces, not
|
||||
// the 70% proactive threshold: a history pruned to ~80% of the window still
|
||||
// takes the free retry.
|
||||
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
messages := bigToolHistory(5)
|
||||
pruned, _ := pruneStaleToolOutput(messages, 2)
|
||||
prunedTokens := estimateTokens(pruned)
|
||||
// Choose a window so the pruned size sits between 70% and 95% of it.
|
||||
window := prunedTokens * 100 / 80
|
||||
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
|
||||
if prunedTokens <= state.threshold {
|
||||
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
|
||||
}
|
||||
|
||||
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// After a mid-run model switch the summarizer is re-resolved against the new
|
||||
// main model: a run that started on the cheap model (no dedicated summarizer)
|
||||
// and escalated gets a cheap summarizer, and a previously broken one is
|
||||
// retried for the new model.
|
||||
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
|
||||
cheap := summaryProvider("CHEAP SUMMARY")
|
||||
main := summaryProvider("MAIN SUMMARY")
|
||||
var factoryModels []string
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Model: "cheap-model",
|
||||
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
|
||||
factoryModels = append(factoryModels, mainModelID)
|
||||
if mainModelID == "cheap-model" {
|
||||
return nil, nil // main already is the cheap model
|
||||
}
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("on the cheap model the main provider must summarize")
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
|
||||
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
state.switchModel("big-model", 400_000)
|
||||
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
|
||||
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
|
||||
}
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
|
||||
}
|
||||
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
|
||||
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
// A broken summarizer is retried after the next switch.
|
||||
state.summarizerBroken = true
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("a broken summarizer must fall back to main")
|
||||
}
|
||||
state.switchModel("other-model", 0)
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionUpdateWindow(t *testing.T) {
|
||||
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
|
||||
state.lowWaterMark = 50_000
|
||||
before := state.threshold
|
||||
|
||||
state.updateWindow(0) // unknown model: unchanged
|
||||
if state.threshold != before || state.lowWaterMark != 50_000 {
|
||||
t.Fatal("updateWindow(0) must be a no-op")
|
||||
}
|
||||
|
||||
state.updateWindow(10_000)
|
||||
if state.threshold >= before {
|
||||
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
|
||||
}
|
||||
if state.lowWaterMark != 0 {
|
||||
t.Fatal("low-water mark must reset on a window change")
|
||||
}
|
||||
if !state.enabled {
|
||||
t.Fatal("a positive window must keep compaction enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: after escalate_model switches providers, the loop re-derives the
|
||||
// context window via ContextWindowFor, visible in the OnContext report.
|
||||
func TestRunEscalationRederivesContextWindow(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "big-model"})
|
||||
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
|
||||
var windows []int
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
ContextWindow: 100_000,
|
||||
Model: "small-model",
|
||||
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
if modelID == "big-model" {
|
||||
return 400_000
|
||||
}
|
||||
return 0
|
||||
},
|
||||
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(windows) < 2 {
|
||||
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
|
||||
}
|
||||
if windows[0] != 100_000 {
|
||||
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
|
||||
}
|
||||
if windows[len(windows)-1] != 400_000 {
|
||||
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// nonComparableProvider has a slice field, so comparing two Provider interface
|
||||
// values holding it would panic; the summarizer path must never compare them.
|
||||
type nonComparableProvider struct {
|
||||
inner *mockProvider
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
return p.inner.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
|
||||
}, nil)
|
||||
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
|
||||
}
|
||||
// And with no dedicated summarizer at all the closure still works.
|
||||
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -126,8 +126,11 @@ func capCompactionBrief(brief string) (string, bool) {
|
||||
|
||||
func compactionToolCallLine(call zeroruntime.ToolCall) string {
|
||||
detail := ""
|
||||
if call.Freeform && call.Name == "apply_patch" {
|
||||
detail = strings.Join(patchPaths(call.Arguments), ", ")
|
||||
}
|
||||
var arguments map[string]any
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
if detail == "" && json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
|
||||
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
detail = value
|
||||
|
||||
@@ -73,6 +73,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
|
||||
{ID: "raw-patch", Name: "apply_patch", Freeform: true, Arguments: "*** Begin Patch\n*** Update File: internal/api.go\n*** Add File: internal/config.go\n*** End Patch\n"},
|
||||
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
|
||||
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
|
||||
}},
|
||||
@@ -80,7 +81,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
|
||||
}
|
||||
|
||||
content := projectCompactionInput(messages).messages[0].Content
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "internal/api.go", "internal/config.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
|
||||
@@ -275,6 +275,27 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
|
||||
if estimateToolDefTokens(one) <= 0 {
|
||||
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
|
||||
}
|
||||
typed := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
typed[0].Type = zeroruntime.ToolDefinitionFreeform
|
||||
if estimateToolDefTokens(typed) <= estimateToolDefTokens(one) {
|
||||
t.Fatal("a non-empty tool type must increase the estimated request cost")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: WORD"}},
|
||||
} {
|
||||
t.Run("format "+test.name, func(t *testing.T) {
|
||||
formatted := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
formatted[0].Format = &test.format
|
||||
if estimateToolDefTokens(formatted) <= estimateToolDefTokens(one) {
|
||||
t.Fatalf("a non-empty format %s must increase the estimated request cost", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
|
||||
Name: "write_file",
|
||||
Description: "Write contents to a file in the workspace.",
|
||||
|
||||
@@ -102,6 +102,10 @@ func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.To
|
||||
for index, definition := range toolDefs {
|
||||
copied[index] = definition
|
||||
copied[index].Parameters = copySchemaMap(definition.Parameters)
|
||||
if definition.Format != nil {
|
||||
format := *definition.Format
|
||||
copied[index].Format = &format
|
||||
}
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
}
|
||||
toolDefs := []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file", Description: "Read a file",
|
||||
Format: &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: "start: PATH"},
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"path"},
|
||||
@@ -53,10 +54,12 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
toolDefs[0].Parameters["required"].([]string)[0] = "other"
|
||||
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
|
||||
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
|
||||
toolDefs[0].Format.Definition = "changed"
|
||||
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
|
||||
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" ||
|
||||
plan.Request.Tools[0].Format == nil || plan.Request.Tools[0].Format.Definition != "start: PATH" ||
|
||||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
|
||||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
|
||||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type customApplyPatchTool struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
type fixedPathScope struct {
|
||||
roots []string
|
||||
}
|
||||
|
||||
func (scope fixedPathScope) Roots() []string {
|
||||
return append([]string(nil), scope.roots...)
|
||||
}
|
||||
|
||||
func newFixedPathScope(t *testing.T, roots ...string) fixedPathScope {
|
||||
t.Helper()
|
||||
resolved := make([]string, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
absolute, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", root, err)
|
||||
}
|
||||
if physical, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
absolute = physical
|
||||
}
|
||||
resolved = append(resolved, absolute)
|
||||
}
|
||||
return fixedPathScope{roots: resolved}
|
||||
}
|
||||
|
||||
func (*customApplyPatchTool) Name() string { return "apply_patch" }
|
||||
func (*customApplyPatchTool) Description() string { return "custom JSON tool" }
|
||||
func (*customApplyPatchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"value": {Type: "string"}}}
|
||||
}
|
||||
func (*customApplyPatchTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow, SideEffect: tools.SideEffectRead}
|
||||
}
|
||||
func (tool *customApplyPatchTool) Run(context.Context, map[string]any) tools.Result {
|
||||
tool.calls++
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchUsesExistingToolExecutionPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchSupportsGrantedExtraRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(root, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(extra, "hello.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform extra-root apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("read extra-root patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsMixedWorkspaceAndExtraRoots(t *testing.T) {
|
||||
for _, extraHasMatchingPath := range []bool{false, true} {
|
||||
name := "extra target missing"
|
||||
if extraHasMatchingPath {
|
||||
name = "extra target exists"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspaceTarget := filepath.Join(workspace, "src", "a.go")
|
||||
if err := os.WriteFile(workspaceTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extraTarget := filepath.Join(extra, "src", "a.go")
|
||||
if extraHasMatchingPath {
|
||||
if err := os.MkdirAll(filepath.Dir(extraTarget), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(extraTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
extraCreated := filepath.Join(extra, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/a.go",
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + extraCreated,
|
||||
"+extra",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "mixes workspace-relative paths with an extra write root") {
|
||||
t.Fatalf("mixed-root patch result = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(workspaceTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("workspace target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
if extraHasMatchingPath {
|
||||
if content, err := os.ReadFile(extraTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("extra-root target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(extraCreated); !os.IsNotExist(err) {
|
||||
t.Fatalf("mixed-root patch created extra file: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchAcceptsOneSemanticRoot(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
useExtraRoot bool
|
||||
}{
|
||||
{name: "workspace"},
|
||||
{name: "extra root", useExtraRoot: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := workspace
|
||||
if test.useExtraRoot {
|
||||
root = extra
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(root, "src", "a.go")
|
||||
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatePath := target
|
||||
if !test.useExtraRoot {
|
||||
updatePath = filepath.Join("src", "a.go")
|
||||
}
|
||||
created := filepath.Join(root, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: " + updatePath,
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + created,
|
||||
"+created",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("single-root patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(target); err != nil || string(content) != "new\n" {
|
||||
t.Fatalf("updated content=%q err=%v", content, err)
|
||||
}
|
||||
if content, err := os.ReadFile(created); err != nil || string(content) != "created\n" {
|
||||
t.Fatalf("created content=%q err=%v", content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsAbsolutePathOutsideGrantedRoots(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
granted := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
scope := newFixedPathScope(t, root, granted)
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(outside, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("ungranted absolute patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("ungranted absolute patch created target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedFreeformPatchRejectsIntermediateSymlinkSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating directory symlinks requires privileges on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
intermediate := filepath.Join(extra, "nested")
|
||||
if err := os.Mkdir(intermediate, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
scope := newFixedPathScope(t, root, extra)
|
||||
tool := tools.NewScopedApplyPatchTool(root, scope)
|
||||
target := filepath.Join(intermediate, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
args, err := tools.PrepareFreeformApplyPatchArguments(tool, patch)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareFreeformApplyPatchArguments: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(intermediate); err != nil {
|
||||
t.Fatalf("Remove intermediate: %v", err)
|
||||
}
|
||||
if err := os.Symlink(outside, intermediate); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("symlink-swapped patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "blocked.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("symlink-swapped patch wrote outside root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFreeformToolFailsClosed(t *testing.T) {
|
||||
result, err := executeToolCall(context.Background(), tools.NewRegistry(), zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "unknown", Arguments: "raw", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("unknown freeform status = %s, want error", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomApplyPatchCannotUseFreeformContract(t *testing.T) {
|
||||
custom := &customApplyPatchTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(custom)
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: "raw patch", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "Unsupported freeform tool call") {
|
||||
t.Fatalf("custom freeform apply_patch result = %#v, want unsupported error", result)
|
||||
}
|
||||
if custom.calls != 0 {
|
||||
t.Fatalf("custom apply_patch executed %d times", custom.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedFreeformApplyPatchDoesNotWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: denied.txt\n+must not exist\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeAsk, Options{
|
||||
Cwd: root,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "test denial"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("denied freeform status = %s, want error", result.Status)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "denied.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("denied freeform patch changed the workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySafeToolCallsPreservesRawFreeformArguments(t *testing.T) {
|
||||
raw := "*** Begin Patch\n*** Update File: internal/main.go\n@@\n-old\n+new\n*** End Patch\n"
|
||||
safe := historySafeToolCalls([]ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "apply_patch", Arguments: raw, Freeform: true,
|
||||
}})
|
||||
if len(safe) != 1 || safe[0].Arguments != raw || safe[0].ProviderCallID != "provider-1" || !safe[0].Freeform {
|
||||
t.Fatalf("freeform history changed raw call: %#v", safe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortedToolResultPreservesProviderCallID(t *testing.T) {
|
||||
messages := appendAbortedToolResults(nil, []ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "read_file",
|
||||
}})
|
||||
if len(messages) != 1 || messages[0].ToolCallID != "call-1" || messages[0].ToolCallProviderID != "provider-1" || !messages[0].IsError {
|
||||
t.Fatalf("aborted tool result lost provider identity: %#v", messages)
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ const (
|
||||
// knows before spending more tool turns.
|
||||
toolOnlyProgressReminderAt = 6
|
||||
|
||||
// planReminderTurn is the turn (1-based) by the end of which a multi-step
|
||||
// task should have called update_plan; if it hasn't, a one-time reminder is
|
||||
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
|
||||
// without a spurious planning nag.
|
||||
planReminderTurn = 3
|
||||
// planReminderTurn and planReminderToolThreshold keep bounded implementation
|
||||
// work plan-free while still nudging a genuinely sustained run that has not
|
||||
// established any structured progress. Both thresholds must be met.
|
||||
planReminderTurn = 7
|
||||
planReminderToolThreshold = 7
|
||||
|
||||
// planToolName is the planning tool the loop watches for by name.
|
||||
planToolName = "update_plan"
|
||||
@@ -591,13 +591,12 @@ func (state *guardState) planReminder(turn int) string {
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
|
||||
// called update_plan if it's doing a multi-step task (>=1 other tool call).
|
||||
// One-shot for the whole run.
|
||||
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
|
||||
// sustained work should have called update_plan. One-shot for the whole run.
|
||||
if !state.notCalledReminderSent &&
|
||||
!state.planEverCalled &&
|
||||
turn >= planReminderTurn &&
|
||||
state.totalToolCalls >= 1 {
|
||||
state.totalToolCalls >= planReminderToolThreshold {
|
||||
state.notCalledReminderSent = true
|
||||
return planNotCalledReminder()
|
||||
}
|
||||
|
||||
@@ -257,19 +257,16 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
if planReminderToolThreshold != expectedThreshold {
|
||||
t.Fatalf("plan reminder tool threshold = %d, want %d", planReminderToolThreshold, expectedThreshold)
|
||||
}
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // turn 1: other tool call
|
||||
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`), // turn 2: still no update_plan
|
||||
toolTurn("call-3", "read_file", `{"path":"notes.txt"}`), // turn 3: reminder fires here
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
@@ -287,6 +284,44 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForBoundedToolSequence(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold-1), textTurn("done"))}
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry, MaxTurns: 12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if count := countUserMessagesContaining(result.Messages, planNotCalledReminderMarker); count != 0 {
|
||||
t.Fatalf("bounded tool sequence must not draw a plan reminder, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanReminderWaitsForTurnThresholdAfterEnoughToolCalls(t *testing.T) {
|
||||
state := guardState{totalToolCalls: 7}
|
||||
if got := state.planReminder(6); got != "" {
|
||||
t.Fatalf("planReminder(6) = %q, want no reminder before turn threshold", got)
|
||||
}
|
||||
if state.notCalledReminderSent {
|
||||
t.Fatal("early turn marked the not-called reminder as sent")
|
||||
}
|
||||
}
|
||||
|
||||
func repeatedReadTurns(count int) [][]zeroruntime.StreamEvent {
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, count)
|
||||
for range count {
|
||||
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
|
||||
+75
-24
@@ -40,7 +40,15 @@ const (
|
||||
toolResultControlSpecReview = "spec_review_required"
|
||||
)
|
||||
|
||||
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
|
||||
// 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")
|
||||
|
||||
// isImageRejectionError reports whether err is a provider 400 that rejects
|
||||
// image/multimodal content. This is checked BEFORE the compaction-retry path
|
||||
@@ -133,6 +141,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterInputTokens, int64(usage.InputTokens))
|
||||
options.Trace.Counter(trace.CounterCachedInputTokens, int64(usage.CachedInputTokens))
|
||||
options.Trace.Counter(trace.CounterCacheWriteTokens, int64(usage.CacheWriteTokens))
|
||||
options.Trace.Counter(trace.CounterOutputTokens, int64(usage.OutputTokens))
|
||||
}
|
||||
}
|
||||
@@ -689,11 +698,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
@@ -836,11 +846,21 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
// here, so a switch to a model with a different window keeps compacting
|
||||
// against the original budget. Fixing it needs the switcher to also
|
||||
// report the new window — out of scope for this change.
|
||||
// Re-derive the context window for the new model so compaction and
|
||||
// the OnContext budget report track the model actually in force —
|
||||
// without this a switch to a smaller-window model can overflow the
|
||||
// target, and a switch to a larger one over-compacts. An unknown
|
||||
// model (<= 0) keeps the original window. The compactor also
|
||||
// re-resolves its dedicated summarizer against the new model.
|
||||
window := 0
|
||||
if options.ContextWindowFor != nil {
|
||||
window = options.ContextWindowFor(turnRequestedModel)
|
||||
}
|
||||
if window > 0 {
|
||||
options.ContextWindow = window
|
||||
planner.config.contextWindow = window
|
||||
}
|
||||
compactor.switchModel(turnRequestedModel, window)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1027,6 +1047,9 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
|
||||
safe := make([]ToolCall, len(calls))
|
||||
for i, call := range calls {
|
||||
safe[i] = call
|
||||
if call.Freeform {
|
||||
continue
|
||||
}
|
||||
args := strings.TrimSpace(call.Arguments)
|
||||
switch {
|
||||
case args == "":
|
||||
@@ -1110,8 +1133,28 @@ func decodeToolArguments(arguments string, v any) error {
|
||||
}
|
||||
|
||||
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if call.Freeform {
|
||||
if !toolFound || !tools.IsBuiltInApplyPatch(tool) {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Unsupported freeform tool call for " + call.Name + ".",
|
||||
}, nil
|
||||
}
|
||||
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid freeform apply_patch input: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
args = prepared
|
||||
} else if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
@@ -1139,7 +1182,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
|
||||
modeName := string(permissionMode)
|
||||
return ToolResult{
|
||||
@@ -1364,7 +1406,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
|
||||
@@ -1548,7 +1590,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)
|
||||
@@ -1596,7 +1638,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)
|
||||
@@ -3087,13 +3129,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
// Deferral may activate only when tool_search is actually runnable; otherwise
|
||||
// the loop would hide deferred tools behind a loader the dispatch gate rejects
|
||||
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
|
||||
// registered, not in DisabledTools, and advertised in the current permission
|
||||
// mode (e.g. not spec-draft, where tool_search is not advertised). The
|
||||
// registered, model-visible, not in DisabledTools, and advertised in the
|
||||
// current permission mode (e.g. not spec-draft, where tool_search is not
|
||||
// advertised). The
|
||||
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
|
||||
// exempt from the allowlist at dispatch, so an allowlist that omits it must
|
||||
// not disable deferral.
|
||||
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
|
||||
loaderUsable := loaderFound &&
|
||||
tools.ModelVisible(loader) &&
|
||||
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
|
||||
ToolAdvertised(loader, permissionMode)
|
||||
|
||||
@@ -3200,15 +3244,21 @@ func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition,
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
definition := zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
if tools.IsBuiltInApplyPatch(tool) {
|
||||
definition.Type = zeroruntime.ToolDefinitionFreeform
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
return tools.ModelVisible(tool) &&
|
||||
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
|
||||
ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
|
||||
@@ -3357,10 +3407,11 @@ func loadedToolsFromResult(meta map[string]string) []string {
|
||||
func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Message {
|
||||
for _, call := range remaining {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
IsError: true,
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
|
||||
@@ -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{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 10, CompletionTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
@@ -4135,6 +4135,9 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
|
||||
t.Fatalf("cached input token counter = %d, want 20", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterCacheWriteTokens); got != 10 {
|
||||
t.Fatalf("cache-write token counter = %d, want 10", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
|
||||
t.Fatalf("output token counter = %d, want 40", got)
|
||||
}
|
||||
@@ -4340,3 +4343,95 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,19 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestRuntimeToolDefinitionMarksOnlyBuiltInApplyPatchFreeform(t *testing.T) {
|
||||
builtIn := runtimeToolDefinition(tools.NewScopedApplyPatchTool(t.TempDir(), nil))
|
||||
if builtIn.Type != zeroruntime.ToolDefinitionFreeform {
|
||||
t.Fatalf("built-in apply_patch type = %q, want freeform", builtIn.Type)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
custom := runtimeToolDefinition(countingSchemaTool{name: "apply_patch", calls: &calls})
|
||||
if custom.Type != "" {
|
||||
t.Fatalf("custom apply_patch type = %q, want legacy function shape", custom.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||
// output is identical to the uncached path.
|
||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||
|
||||
@@ -67,3 +67,21 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAdvertisesBothEditTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
|
||||
names := make(map[string]bool, len(exposed))
|
||||
for _, definition := range exposed {
|
||||
names[definition.Name] = true
|
||||
}
|
||||
if !names["apply_patch"] {
|
||||
t.Fatal("agent must retain apply_patch for multi-hunk changes")
|
||||
}
|
||||
if !names["edit_file"] {
|
||||
t.Fatal("agent must receive edit_file for targeted exact replacements")
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,18 @@ func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSu
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
writeLengthPrefixed(&toolsSB, string(def.Type))
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
if def.Format != nil {
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Type)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Definition)
|
||||
}
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,38 @@ func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintIncludesToolTypeAndFormat(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
base := zeroruntime.ToolDefinition{Name: "apply_patch", Description: "Patch files"}
|
||||
baseFingerprint := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{base})
|
||||
|
||||
typed := base
|
||||
typed.Type = zeroruntime.ToolDefinitionFreeform
|
||||
if got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{typed}); got.ToolsHash == baseFingerprint.ToolsHash {
|
||||
t.Fatal("ToolsHash must change when tool Type changes")
|
||||
}
|
||||
|
||||
formatCases := []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: PATCH"}},
|
||||
{name: "empty", format: zeroruntime.ToolDefinitionFormat{}},
|
||||
}
|
||||
for _, test := range formatCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withFormat := base
|
||||
withFormat.Format = &test.format
|
||||
got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{withFormat})
|
||||
if got.SchemaHash == baseFingerprint.SchemaHash {
|
||||
t.Fatalf("SchemaHash must change for %s format input", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||
// model addendum change) must move the base-instructions hash, which moves
|
||||
|
||||
@@ -30,13 +30,10 @@ work.
|
||||
read-before-edit discipline: inspect the target file and nearby callers,
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** Use update_plan for implementation or investigation that has at
|
||||
least three meaningful dependent steps, then keep it current at milestones.
|
||||
Mark completed work and the next in-progress step together when practical;
|
||||
do not spend separate turns updating it after every file or command. Keep at
|
||||
most one item in_progress. Skip plans for simple lookups, explanations, code
|
||||
navigation, and other short tasks. Never create a plan after the work is
|
||||
already complete merely to describe what you did.
|
||||
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
|
||||
work. Skip it for bounded changes in one component. Update only when the
|
||||
overall phase changes, keep at most one item in_progress, and never create a
|
||||
plan after the work is complete.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
@@ -52,37 +49,48 @@ work.
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
write_file, edit_file, apply_patch - over shelling out to
|
||||
edit_file, apply_patch, write_file - over shelling out to
|
||||
cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Prefer read_minified_file when initially exploring source code; it preserves
|
||||
code while removing comments and redundant whitespace. Use read_file when
|
||||
exact text, comments, or line numbers are needed.
|
||||
- Use read_minified_file only to explore large or unfamiliar source. Use
|
||||
read_file for small or likely edit targets and exact text, comments, or line
|
||||
numbers. Do not read both unless a new need for exact content appears.
|
||||
- Keep edits focused and reviewable. A single patch may update several related
|
||||
files when they form one coherent change; do not hide unrelated edits in a
|
||||
bulk shell or script rewrite.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. 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.
|
||||
- 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
|
||||
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.
|
||||
over-built; if a 200-line solution could be 50, rewrite it. Once the required
|
||||
behavior passes validation, stop instead of adding optional cleanup.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
## Testing gate (mandatory)
|
||||
|
||||
- After any change to code, verify after edits by running the project's
|
||||
validators before you summarize or commit: tests, type-checks, linters, and/or
|
||||
the build, as appropriate. Scope them to the change while iterating; reserve
|
||||
full-suite runs for milestones.
|
||||
- If you are unsure which validators apply, search the repo (Makefile, package
|
||||
manifests, CI config) to find them.
|
||||
- After code changes, run the project's documented appropriate validators.
|
||||
Scope iteration checks, combine compatible commands when diagnostics stay
|
||||
useful, and run the final set once after the last edit. Rerun only after a
|
||||
change or failure.
|
||||
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
|
||||
do not invent a parallel flow.
|
||||
- Never claim a task is done, and never commit, while validators are failing. If
|
||||
they fail, fix the cause and rerun; do not paper over it. If you could not run
|
||||
a validator, say so explicitly rather than implying success.
|
||||
- A successful native patch confirms the edit. Reread only when the next change
|
||||
needs exact content or the result was ambiguous.
|
||||
|
||||
## Tool use
|
||||
|
||||
@@ -104,7 +112,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 write_file/edit_file/apply_patch for file changes.
|
||||
with exec_command, or use edit_file/apply_patch/write_file 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
|
||||
@@ -140,6 +148,7 @@ work.
|
||||
- Default to concise, skimmable output. Lead with the answer or the result.
|
||||
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
||||
code blocks for code, and `inline code` for file paths, commands, symbols, and
|
||||
short snippets. Reference code as `file:line` so it is clickable.
|
||||
short snippets. Reference code with file paths and include line numbers when
|
||||
already known; do not look them up solely for the final summary.
|
||||
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
|
||||
say so; when something is done and verified, state it plainly without hedging.
|
||||
@@ -60,8 +60,11 @@ const openAIPromptAddendum = `<model_guidance>
|
||||
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||
commands, and symbols.
|
||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
||||
write_file, edit_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.
|
||||
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
|
||||
file work. Use them directly; never run apply_patch through exec_command.
|
||||
read_file, edit_file and write_file take one file per call, while one coherent
|
||||
apply_patch may span several files. Keep independent native calls in the same
|
||||
turn, and do not batch file writes into a script.
|
||||
- Persist until the task is fully handled this turn: gather context, implement,
|
||||
run the validators, and report — do not stop at a partial result.
|
||||
</model_guidance>`
|
||||
|
||||
@@ -29,6 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
if got := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{Model: "gpt-5"}))), " "); !strings.Contains(got, "never run apply_patch through exec_command") {
|
||||
t.Fatalf("OpenAI prompt must route edits through the native patch tool:\n%s", got)
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
|
||||
@@ -29,14 +29,17 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
|
||||
if strings.Contains(prompt, "clickable file paths") {
|
||||
t.Fatalf("system prompt must not claim inline-code file paths are clickable: %s", prompt)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"read-before-edit",
|
||||
"inspect the target file",
|
||||
"plan then act",
|
||||
"choose the narrowest tool",
|
||||
"prefer edit_file or apply_patch",
|
||||
"for edits to existing files, use edit_file",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
@@ -44,6 +47,13 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"do not recognize",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
"skip it for bounded changes in one component",
|
||||
"do not read both unless a new need for exact content appears",
|
||||
"combine compatible commands when diagnostics stay useful",
|
||||
"do not invent a parallel flow",
|
||||
"a successful native patch confirms the edit",
|
||||
"once the required behavior passes validation, stop",
|
||||
"do not look them up solely for the final summary",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
|
||||
+20
-4
@@ -348,10 +348,26 @@ type Options struct {
|
||||
// CompactionPreserveLast is how many trailing messages compaction keeps
|
||||
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
||||
CompactionPreserveLast int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// Summarizer, when set, lazily builds the provider used for compaction
|
||||
// summarization calls — typically a cheap/fast model, since summaries at
|
||||
// main-model prices are the single most expensive recurring event in a
|
||||
// long run. It receives the main model currently in force and may return
|
||||
// (nil, nil) when no dedicated summarizer applies to it (the main model is
|
||||
// already the cheap one). Built on the first paid compaction and again
|
||||
// after a mid-run model switch. Any failure (build or call) falls back to
|
||||
// the run's main provider until the next switch, so a misconfigured
|
||||
// summarizer can never break compaction. nil keeps today's behavior.
|
||||
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
// ContextWindowFor, when set, resolves a model ID to its context window so
|
||||
// the compactor can re-derive its threshold after a mid-run model switch
|
||||
// (escalate_model). Without it a switch keeps compacting against the
|
||||
// original model's window — overflowing a smaller target or over-compacting
|
||||
// a larger one. Return <= 0 when the model is unknown (window unchanged).
|
||||
ContextWindowFor func(modelID string) int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// FileTracker records per-session file read/write versions so the write tools
|
||||
// can detect a file changed on disk outside Zero since it was last read. nil
|
||||
// disables the check. Created once per session and threaded into every tool run.
|
||||
|
||||
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
|
||||
}
|
||||
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
var migrated bool
|
||||
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
|
||||
if !migrated {
|
||||
return Task{}, false, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if outputFile != task.OutputFile {
|
||||
changed = true
|
||||
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// migrateLegacyOutputFile recovers task metadata written under a previous home
|
||||
// or XDG data root. It accepts only the canonical per-task output
|
||||
// name in the historic zero/background layout, and only when the corresponding
|
||||
// regular file already exists inside the active manager root.
|
||||
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if !filepath.IsAbs(requested) {
|
||||
return "", false
|
||||
}
|
||||
requested = filepath.Clean(requested)
|
||||
expectedName := taskID + ".ndjson"
|
||||
if filepath.Base(requested) != expectedName {
|
||||
return "", false
|
||||
}
|
||||
legacyRoot := filepath.Dir(requested)
|
||||
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
|
||||
return "", false
|
||||
}
|
||||
canonical := filepath.Join(manager.rootDir, expectedName)
|
||||
info, err := os.Lstat(canonical)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return "", false
|
||||
}
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
func validTaskID(taskID string) bool {
|
||||
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -191,6 +192,88 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const taskID = "legacy_task"
|
||||
canonicalOutput := filepath.Join(root, taskID+".ndjson")
|
||||
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
|
||||
t.Fatalf("write canonical output: %v", err)
|
||||
}
|
||||
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
|
||||
metadata, err := json.Marshal(Task{
|
||||
ID: taskID,
|
||||
Type: "specialist",
|
||||
Status: StatusCompleted,
|
||||
OutputFile: legacyOutput,
|
||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal legacy metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write legacy metadata: %v", err)
|
||||
}
|
||||
|
||||
manager, err := NewManager(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
task, ok := manager.Get(taskID)
|
||||
if !ok {
|
||||
t.Fatal("manager did not load migrated task")
|
||||
}
|
||||
if task.OutputFile != canonicalOutput {
|
||||
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
|
||||
}
|
||||
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
|
||||
t.Fatalf("migration produced load warnings: %#v", warnings)
|
||||
}
|
||||
|
||||
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated metadata: %v", err)
|
||||
}
|
||||
var saved Task
|
||||
if err := json.Unmarshal(persisted, &saved); err != nil {
|
||||
t.Fatalf("decode migrated metadata: %v", err)
|
||||
}
|
||||
if saved.OutputFile != canonicalOutput {
|
||||
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const taskID = "legacy_task"
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
|
||||
t.Fatalf("write canonical output: %v", err)
|
||||
}
|
||||
metadata, err := json.Marshal(Task{
|
||||
ID: taskID,
|
||||
Type: "specialist",
|
||||
Status: StatusCompleted,
|
||||
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
|
||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write metadata: %v", err)
|
||||
}
|
||||
|
||||
manager, err := NewManager(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
if _, ok := manager.Get(taskID); ok {
|
||||
t.Fatal("manager loaded task with an unrelated absolute output path")
|
||||
}
|
||||
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
|
||||
t.Fatalf("load warnings = %q, want containment rejection", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager, err := NewManager(root)
|
||||
|
||||
+50
-13
@@ -839,24 +839,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
mcpTokenStore = nil
|
||||
err = nil
|
||||
}
|
||||
criticalMCPConfig, optionalMCPConfig := splitMCPStartupConfig(mcpConfig)
|
||||
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
|
||||
if len(mcpConfig.Servers) > 0 {
|
||||
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
||||
if len(criticalMCPConfig.Servers) > 0 {
|
||||
runtime, registerErr := deps.registerMCPTools(context.Background(), registry, criticalMCPConfig, mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
closeMCPRuntime(stderr, mcpRuntime)
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
if registerErr != nil {
|
||||
closeMCPRuntime(stderr, runtime)
|
||||
return writeAppError(stderr, redaction.ErrorMessage(registerErr, redaction.Options{}), 1)
|
||||
}
|
||||
mcpRuntime = runtime
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// A server that could not be reached or validated is skipped, not fatal (one
|
||||
// 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 Firecrawl with no credentials) is the exception: it
|
||||
// configured (e.g. keyless Exa 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() {
|
||||
@@ -909,11 +911,35 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
// Activate deferred MCP-tool loading for the interactive run only when the
|
||||
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
||||
// The registry is complete (core + specialist + MCP + plugins) here, so the
|
||||
// count is accurate; below threshold this is a no-op and the surface is
|
||||
// unchanged. The interactive surface applies no operator tool filters, so
|
||||
// enabled/disabled are nil — matching the AgentOptions below.
|
||||
// This first pass covers every prompt-critical tool. Optional built-in MCP
|
||||
// defaults re-run the same gate after publishing their atomic batch, so a
|
||||
// later catalog generation cannot miss its loader. The interactive surface
|
||||
// applies no operator tool filters, so enabled/disabled are nil — matching the
|
||||
// AgentOptions below.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
var optionalMCPRuntime *optionalMCPStartup
|
||||
var awaitToolReadiness func(context.Context)
|
||||
if len(optionalMCPConfig.Servers) > 0 {
|
||||
optionalMCPRuntime = startOptionalMCP(
|
||||
context.Background(),
|
||||
registry,
|
||||
optionalMCPConfig,
|
||||
mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
},
|
||||
deps.registerMCPTools,
|
||||
func() {
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
},
|
||||
)
|
||||
defer closeMCPRuntime(stderr, optionalMCPRuntime)
|
||||
awaitToolReadiness = func(ctx context.Context) {
|
||||
optionalMCPRuntime.Await(ctx, optionalMCPPromptGrace)
|
||||
}
|
||||
}
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
var scratchBaseline scratchFileBaseline
|
||||
@@ -948,10 +974,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecentModels: resolved.Preferences.RecentModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
CompactionModel: resolved.Preferences.CompactionModel,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
|
||||
return optimized
|
||||
}
|
||||
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
|
||||
},
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
PrepareRunCompletionWarning: func() {
|
||||
scratchBaseline = scratchFileSnapshot(workspaceRoot)
|
||||
},
|
||||
@@ -959,6 +995,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return scratchFileWarning(workspaceRoot, scratchBaseline)
|
||||
},
|
||||
Registry: registry,
|
||||
AwaitToolReadiness: awaitToolReadiness,
|
||||
SessionStore: sessionStore,
|
||||
PeerService: peerService,
|
||||
SandboxStore: sandboxStore,
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
@@ -211,12 +212,12 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"firecrawl": config.DefaultMCPServers()["firecrawl"],
|
||||
"exa": config.DefaultMCPServers()["exa"],
|
||||
}}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
|
||||
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
|
||||
{Name: "exa", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
|
||||
}}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
@@ -227,7 +228,7 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "firecrawl") {
|
||||
if strings.Contains(stderr.String(), "exa") {
|
||||
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -486,6 +487,74 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsPaintsBeforeOptionalDefaultMCPIsReady(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPermissionStore() error = %v", err)
|
||||
}
|
||||
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
configResult := make(chan error, 1)
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
userConfigPath: func() (string, error) { return userConfigPath, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: config.DefaultMCPServers()}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return permissionStore, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return tokenStore, nil },
|
||||
registerMCPTools: func(_ context.Context, registry *tools.Registry, cfg config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
var configErr error
|
||||
if _, ok := cfg.Servers["exa"]; !ok || len(cfg.Servers) != 1 {
|
||||
configErr = fmt.Errorf("optional MCP config = %#v, want only exa", cfg.Servers)
|
||||
}
|
||||
configResult <- configErr
|
||||
close(started)
|
||||
<-release
|
||||
registry.Register(cliFakeMCPRegistryTool{})
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("optional MCP initialization did not start")
|
||||
}
|
||||
if options.AwaitToolReadiness == nil {
|
||||
t.Fatal("TUI did not receive optional tool readiness barrier")
|
||||
}
|
||||
if _, ok := options.Registry.Get("mcp_docs_lookup"); ok {
|
||||
t.Fatal("optional MCP tool was published before registration completed")
|
||||
}
|
||||
close(release)
|
||||
options.AwaitToolReadiness(ctx)
|
||||
if _, ok := options.Registry.Get("mcp_docs_lookup"); !ok {
|
||||
t.Fatal("optional MCP tool was not visible after readiness completed")
|
||||
}
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if configErr := <-configResult; configErr != nil {
|
||||
t.Fatal(configErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -565,6 +634,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
runtimeClosed := false
|
||||
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
@@ -591,7 +661,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
return closeFunc(func() error {
|
||||
runtimeClosed = true
|
||||
return nil
|
||||
}), errors.New("register mcp tools failed")
|
||||
}), fmt.Errorf("register mcp tools failed: %s", secret)
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
t.Fatal("TUI should not launch after MCP registration fails")
|
||||
@@ -605,6 +675,12 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "register mcp tools failed") {
|
||||
t.Fatalf("stderr missing registration error: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), secret) {
|
||||
t.Fatalf("stderr leaked MCP registration secret: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), redaction.RedactedSecret) {
|
||||
t.Fatalf("stderr missing redaction marker: %s", stderr.String())
|
||||
}
|
||||
if !runtimeClosed {
|
||||
t.Fatal("partial MCP runtime was not closed after registration error")
|
||||
}
|
||||
|
||||
+17
-10
@@ -682,16 +682,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ModelSwitcher: modelSwitcher,
|
||||
TurnSessionProvider: turnSessions,
|
||||
ModelSessionSwitcher: modelSessionSwitcher,
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
Summarizer: summarizerFactory(resolved, deps.newProvider),
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
|
||||
},
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
@@ -939,6 +943,9 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
|
||||
if deferThreshold <= 0 {
|
||||
return
|
||||
}
|
||||
if _, registered := registry.Get(tools.ToolSearchToolName); registered {
|
||||
return
|
||||
}
|
||||
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -267,23 +267,29 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
|
||||
func (writer *execEventWriter) usage(usage agent.Usage) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{
|
||||
"type": "usage",
|
||||
"prompt_tokens": usage.PromptTokens,
|
||||
"completion_tokens": usage.CompletionTokens,
|
||||
"total_tokens": usage.TotalTokens(),
|
||||
"type": "usage",
|
||||
"prompt_tokens": usage.PromptTokens,
|
||||
"completion_tokens": usage.CompletionTokens,
|
||||
"cached_input_tokens": usage.CachedInputTokens,
|
||||
"cache_write_tokens": usage.CacheWriteTokens,
|
||||
"total_tokens": usage.TotalTokens(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
promptTokens := usage.EffectiveInputTokens()
|
||||
completionTokens := usage.EffectiveOutputTokens()
|
||||
cachedInputTokens := usage.CachedInputTokens
|
||||
cacheWriteTokens := usage.CacheWriteTokens
|
||||
totalTokens := usage.TotalTokens()
|
||||
writer.writeStreamJSON(streamjson.Event{
|
||||
Type: streamjson.EventUsage,
|
||||
RunID: writer.runID,
|
||||
PromptTokens: &promptTokens,
|
||||
CompletionTokens: &completionTokens,
|
||||
TotalTokens: &totalTokens,
|
||||
Type: streamjson.EventUsage,
|
||||
RunID: writer.runID,
|
||||
PromptTokens: &promptTokens,
|
||||
CompletionTokens: &completionTokens,
|
||||
CachedInputTokens: &cachedInputTokens,
|
||||
CacheWriteTokens: &cacheWriteTokens,
|
||||
TotalTokens: &totalTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +52,39 @@ func TestExecWriterPropagatesToolResultTruncation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecWriterPropagatesCacheUsage(t *testing.T) {
|
||||
for _, format := range []execOutputFormat{execOutputJSON, execOutputStreamJSON} {
|
||||
t.Run(string(format), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
writer := execEventWriter{
|
||||
stdout: &stdout,
|
||||
format: format,
|
||||
runID: "run_usage",
|
||||
streamedText: &strings.Builder{},
|
||||
}
|
||||
writer.usage(agent.Usage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 20,
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 20,
|
||||
CachedInputTokens: 60,
|
||||
CacheWriteTokens: 10,
|
||||
})
|
||||
if writer.err != nil {
|
||||
t.Fatalf("usage: %v", writer.err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil {
|
||||
t.Fatalf("decode output %q: %v", stdout.String(), err)
|
||||
}
|
||||
cachedKey, writeKey := "cached_input_tokens", "cache_write_tokens"
|
||||
if format == execOutputStreamJSON {
|
||||
cachedKey, writeKey = "cachedInputTokens", "cacheWriteTokens"
|
||||
}
|
||||
if payload[cachedKey] != float64(60) || payload[writeKey] != float64(10) {
|
||||
t.Fatalf("cache usage missing from payload: %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
|
||||
// `zero mcp disable exa` must work even though exa is a built-in
|
||||
// default that is not written to the user's config file until overridden.
|
||||
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
|
||||
func TestRunMCPDisableSeededExaDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
// A config with no firecrawl entry — the default lives in code, not the file.
|
||||
// A config with no Exa entry — the default lives in code, not the file.
|
||||
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
|
||||
code := runWithDeps([]string{"mcp", "disable", "exa", "--json"}, &out, &errBuf, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
})
|
||||
if code != exitSuccess {
|
||||
@@ -31,8 +31,8 @@ func TestRunMCPDisableSeededFirecrawlDefault(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 != "firecrawl" || !payload.Disabled || !payload.Changed {
|
||||
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
|
||||
if payload.ServerName != "exa" || !payload.Disabled || !payload.Changed {
|
||||
t.Fatalf("disable payload = %#v, want exa disabled+changed", payload)
|
||||
}
|
||||
|
||||
// End-to-end: resolving that config now turns the default off.
|
||||
@@ -40,7 +40,7 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
|
||||
if !cfg.Servers["exa"].Disabled {
|
||||
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
optionalMCPPromptGrace = time.Second
|
||||
optionalMCPCloseGrace = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// splitMCPStartupConfig keeps explicitly configured servers on the
|
||||
// prompt-critical path. Only unchanged built-in defaults may initialize after
|
||||
// first paint; their failure was already intentionally silent.
|
||||
func splitMCPStartupConfig(cfg config.MCPConfig) (critical config.MCPConfig, optional config.MCPConfig) {
|
||||
critical.Servers = map[string]config.MCPServerConfig{}
|
||||
optional.Servers = map[string]config.MCPServerConfig{}
|
||||
for name, server := range cfg.Servers {
|
||||
if server.Disabled {
|
||||
continue
|
||||
}
|
||||
if config.IsUnconfiguredDefault(name, server) {
|
||||
optional.Servers[name] = server
|
||||
continue
|
||||
}
|
||||
critical.Servers[name] = server
|
||||
}
|
||||
return critical, optional
|
||||
}
|
||||
|
||||
type optionalMCPStartup struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
runtime mcpToolRuntime
|
||||
|
||||
closeOnce sync.Once
|
||||
runtimeCloseOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func startOptionalMCP(
|
||||
parent context.Context,
|
||||
registry *tools.Registry,
|
||||
cfg config.MCPConfig,
|
||||
options mcp.RegisterOptions,
|
||||
register func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error),
|
||||
onReady func(),
|
||||
) *optionalMCPStartup {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
startup := &optionalMCPStartup{cancel: cancel, done: make(chan struct{})}
|
||||
go func() {
|
||||
runtime, err := register(ctx, registry, cfg, options)
|
||||
if err == nil && onReady != nil {
|
||||
onReady()
|
||||
}
|
||||
startup.mu.Lock()
|
||||
startup.runtime = runtime
|
||||
startup.mu.Unlock()
|
||||
close(startup.done)
|
||||
}()
|
||||
return startup
|
||||
}
|
||||
|
||||
// Await gives optional tools one bounded opportunity to join the next turn's
|
||||
// immutable registry snapshot. A timeout leaves this turn on the existing core
|
||||
// tool set; a later turn observes the completed generation.
|
||||
func (startup *optionalMCPStartup) Await(ctx context.Context, timeout time.Duration) bool {
|
||||
if startup == nil {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
default:
|
||||
}
|
||||
if timeout <= 0 {
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) Close() error {
|
||||
if startup == nil {
|
||||
return nil
|
||||
}
|
||||
startup.closeOnce.Do(func() {
|
||||
startup.cancel()
|
||||
reaped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(reaped)
|
||||
<-startup.done
|
||||
startup.closeRuntime()
|
||||
}()
|
||||
timer := time.NewTimer(optionalMCPCloseGrace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-reaped:
|
||||
case <-timer.C:
|
||||
}
|
||||
})
|
||||
startup.mu.Lock()
|
||||
defer startup.mu.Unlock()
|
||||
return startup.closeErr
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) closeRuntime() {
|
||||
startup.runtimeCloseOnce.Do(func() {
|
||||
startup.mu.Lock()
|
||||
runtime := startup.runtime
|
||||
startup.mu.Unlock()
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
err := runtime.Close()
|
||||
startup.mu.Lock()
|
||||
if err != nil && startup.closeErr == nil {
|
||||
startup.closeErr = err
|
||||
}
|
||||
startup.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type closeTrackingMCPRuntime struct {
|
||||
closed chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func (runtime *closeTrackingMCPRuntime) Close() error {
|
||||
close(runtime.closed)
|
||||
return runtime.err
|
||||
}
|
||||
|
||||
func (*closeTrackingMCPRuntime) Skipped() []mcp.SkippedServer { return nil }
|
||||
|
||||
func TestSplitMCPStartupConfigDefersOnlyUnconfiguredDefaults(t *testing.T) {
|
||||
defaults := config.DefaultMCPServers()
|
||||
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"exa": defaults["exa"],
|
||||
"docs": {Type: "http", URL: "https://docs.example.test/mcp"},
|
||||
"disabled": {Type: "http", URL: "https://disabled.example.test/mcp", Disabled: true},
|
||||
}}
|
||||
|
||||
critical, optional := splitMCPStartupConfig(cfg)
|
||||
if _, ok := critical.Servers["docs"]; !ok || len(critical.Servers) != 1 {
|
||||
t.Fatalf("critical servers = %#v, want only docs", critical.Servers)
|
||||
}
|
||||
if _, ok := optional.Servers["exa"]; !ok || len(optional.Servers) != 1 {
|
||||
t.Fatalf("optional servers = %#v, want only exa", optional.Servers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPAwaitTimesOutThenObservesPublishedTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{})
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
registry,
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(ctx context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
registry.RegisterBatch([]tools.Tool{tools.NewScopedReadFileTool(t.TempDir(), nil)})
|
||||
return noopMCPRuntime{}, nil
|
||||
case <-ctx.Done():
|
||||
return noopMCPRuntime{}, ctx.Err()
|
||||
}
|
||||
},
|
||||
nil,
|
||||
)
|
||||
defer func() { _ = startup.Close() }()
|
||||
<-started
|
||||
|
||||
if startup.Await(t.Context(), time.Millisecond) {
|
||||
t.Fatal("optional startup unexpectedly completed before release")
|
||||
}
|
||||
if got := len(registry.All()); got != 0 {
|
||||
t.Fatalf("registry published tools before readiness: %d", got)
|
||||
}
|
||||
close(release)
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("optional startup did not complete after release")
|
||||
}
|
||||
if _, ok := registry.Get("read_file"); !ok {
|
||||
t.Fatal("completed optional startup did not publish its tool batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPFailedRegistrationSkipsReadinessCallback(t *testing.T) {
|
||||
readyCalls := 0
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, errors.New("connect failed")
|
||||
},
|
||||
func() { readyCalls++ },
|
||||
)
|
||||
defer func() { _ = startup.Close() }()
|
||||
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("failed optional startup did not settle")
|
||||
}
|
||||
if readyCalls != 0 {
|
||||
t.Fatalf("readiness callback ran after failed registration: %d", readyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseCancelsBlockedStartup(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(ctx context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
<-started
|
||||
if err := startup.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseReturnsRuntimeErrorWithinGrace(t *testing.T) {
|
||||
closeErr := errors.New("close failed")
|
||||
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{}), err: closeErr}
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return runtime, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("optional startup did not settle")
|
||||
}
|
||||
if err := startup.Close(); !errors.Is(err, closeErr) {
|
||||
t.Fatalf("Close() error = %v, want %v", err, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseBoundsRegistrationThatIgnoresCancellation(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
close(release)
|
||||
}
|
||||
}()
|
||||
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{})}
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return runtime, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
<-started
|
||||
|
||||
closeResult := make(chan error, 1)
|
||||
go func() { closeResult <- startup.Close() }()
|
||||
select {
|
||||
case err := <-closeResult:
|
||||
if err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close blocked on registration after cancellation")
|
||||
}
|
||||
|
||||
close(release)
|
||||
released = true
|
||||
select {
|
||||
case <-runtime.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("late optional MCP runtime was not closed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// summarizerFactory adapts the resolved profile and the authenticated provider
|
||||
// builder into agent.Options.Summarizer; the selection rules live in
|
||||
// providers.CompactionSummarizerFactory, shared with the TUI.
|
||||
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
|
||||
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
|
||||
}
|
||||
@@ -105,7 +105,17 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
//
|
||||
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
|
||||
// it, since both tests return about one WaitDelay after the fixture exits.
|
||||
const providerCommandTestBudget = 60 * time.Second
|
||||
const (
|
||||
providerCommandTestBudget = 90 * time.Second
|
||||
providerCommandReadyWaitAttempts = 45
|
||||
)
|
||||
|
||||
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
|
||||
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
|
||||
if providerCommandTestBudget < 2*readyWaitCeiling {
|
||||
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
|
||||
}
|
||||
}
|
||||
|
||||
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
|
||||
// dying of old age is unobservable: any run where runProviderCommand has not
|
||||
@@ -341,15 +351,15 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
// assertion fail is far more diagnosable than blocking until the
|
||||
// timeout fires and reporting an unrelated error. Each iteration is
|
||||
// about a second, since `ping -n 2` sleeps between its two echoes,
|
||||
// so the ceiling is roughly 15s: far longer than any cold PowerShell
|
||||
// start seen on a runner, and far shorter than
|
||||
// providerCommandTestBudget, so giving up surfaces a legible
|
||||
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
|
||||
// runner while leaving the other half of providerCommandTestBudget for
|
||||
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
|
||||
// PID-file failure rather than a timeout that hides the real cause.
|
||||
lines = append(lines,
|
||||
":zeroWaitReady",
|
||||
"if exist \""+readyFile+"\" goto zeroReady",
|
||||
"set /a ZERO_READY_TRIES+=1",
|
||||
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
|
||||
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
|
||||
"ping -n 2 127.0.0.1 >nul",
|
||||
"goto zeroWaitReady",
|
||||
":zeroReady",
|
||||
|
||||
@@ -5,22 +5,20 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// `zero mcp disable <name>` (which writes `"disabled": true`).
|
||||
//
|
||||
// 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.
|
||||
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
|
||||
// Exa API key for higher limits.
|
||||
func DefaultMCPServers() map[string]MCPServerConfig {
|
||||
return map[string]MCPServerConfig{
|
||||
"firecrawl": {
|
||||
"exa": {
|
||||
Type: "http",
|
||||
URL: "https://mcp.firecrawl.dev/v2/mcp",
|
||||
URL: "https://mcp.exa.ai/mcp",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -35,13 +33,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 Firecrawl, no credentials).
|
||||
// running with whatever Zero ships (e.g. keyless Exa, 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 firecrawl` that leaves the
|
||||
// disable/enable toggle like `zero mcp enable exa` 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.
|
||||
@@ -58,3 +56,90 @@ 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
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
)
|
||||
|
||||
func TestIsDefaultMCPServer(t *testing.T) {
|
||||
if !IsDefaultMCPServer("firecrawl") {
|
||||
t.Fatal("firecrawl should be a built-in default")
|
||||
if !IsDefaultMCPServer("exa") {
|
||||
t.Fatal("exa should be a built-in default")
|
||||
}
|
||||
if IsDefaultMCPServer(" firecrawl ") == false {
|
||||
if IsDefaultMCPServer(" exa ") == false {
|
||||
t.Fatal("IsDefaultMCPServer should trim whitespace")
|
||||
}
|
||||
if IsDefaultMCPServer("not-a-default") {
|
||||
@@ -18,45 +18,45 @@ func TestIsDefaultMCPServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
|
||||
func TestResolveMCPSeedsEnabledExaDefault(t *testing.T) {
|
||||
cfg, err := ResolveMCP(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl, ok := cfg.Servers["firecrawl"]
|
||||
exa, ok := cfg.Servers["exa"]
|
||||
if !ok {
|
||||
t.Fatal("expected the firecrawl default to be seeded with no user config")
|
||||
t.Fatal("expected the exa default to be seeded with no user config")
|
||||
}
|
||||
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
|
||||
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
|
||||
if exa.Type != "http" || exa.URL != "https://mcp.exa.ai/mcp" {
|
||||
t.Fatalf("unexpected exa default: %#v", exa)
|
||||
}
|
||||
if firecrawl.Disabled {
|
||||
t.Fatal("the firecrawl default must be enabled out of the box")
|
||||
if exa.Disabled {
|
||||
t.Fatal("the exa 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":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"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["firecrawl"].Disabled {
|
||||
if !cfg.Servers["exa"].Disabled {
|
||||
t.Fatal("a user must be able to disable the default by writing over it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnconfiguredDefault(t *testing.T) {
|
||||
if !IsUnconfiguredDefault("firecrawl", DefaultMCPServers()["firecrawl"]) {
|
||||
t.Fatal("an untouched firecrawl default should be reported as unconfigured")
|
||||
if !IsUnconfiguredDefault("exa", DefaultMCPServers()["exa"]) {
|
||||
t.Fatal("an untouched exa default should be reported as unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "http://localhost:3002/mcp"}) {
|
||||
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://example.com/mcp"}) {
|
||||
t.Fatal("a server overriding the default URL is no longer unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "https://mcp.firecrawl.dev/v2/mcp", Auth: "bearer"}) {
|
||||
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://mcp.exa.ai/mcp", Auth: "bearer"}) {
|
||||
t.Fatal("a server with credentials added is no longer unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("not-a-default", MCPServerConfig{}) {
|
||||
@@ -65,57 +65,173 @@ func TestIsUnconfiguredDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveMCPExplicitReenableIsNotUnconfiguredDefault(t *testing.T) {
|
||||
// `zero mcp enable firecrawl` after a prior disable writes {"disabled":false}
|
||||
// `zero mcp enable exa` 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":{"firecrawl":{"disabled":false}}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 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("explicit re-enable should leave the server enabled: %#v", firecrawl)
|
||||
exa := cfg.Servers["exa"]
|
||||
if exa.Disabled {
|
||||
t.Fatalf("explicit re-enable should leave the server enabled: %#v", exa)
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", firecrawl) {
|
||||
if IsUnconfiguredDefault("exa", exa) {
|
||||
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 firecrawl's exact default type/url into their config
|
||||
// A user who copies Exa'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()["firecrawl"] — the
|
||||
// resolved value byte-identical to DefaultMCPServers()["exa"] — 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":{"firecrawl":{"type":"http","url":"https://mcp.firecrawl.dev/v2/mcp"}}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"type":"http","url":"https://mcp.exa.ai/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"]
|
||||
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)
|
||||
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)
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", firecrawl) {
|
||||
if IsUnconfiguredDefault("exa", exa) {
|
||||
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 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 {
|
||||
// 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
@@ -123,10 +239,55 @@ func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if firecrawl.URL != "http://localhost:3002/mcp" {
|
||||
t.Fatalf("user override of the default URL did not apply: %#v", 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.Type != "http" {
|
||||
t.Fatalf("override should keep the default's other fields (type), got %#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")
|
||||
}
|
||||
}
|
||||
@@ -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["firecrawl"]; !ok {
|
||||
if _, ok := cfg.Servers["exa"]; !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["firecrawl"]; !ok {
|
||||
if _, ok := cfg.Servers["exa"]; !ok {
|
||||
t.Fatal("the built-in default must survive when the project layer is dropped")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 Firecrawl for free,
|
||||
// Seed Zero's built-in default MCP servers (e.g. keyless Exa 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,6 +194,10 @@ 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:
|
||||
|
||||
@@ -119,6 +119,12 @@ type PreferencesConfig struct {
|
||||
// the user turned idle recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
Recaps *bool `json:"recaps,omitempty"`
|
||||
// CompactionModel routes compaction summarization calls to this model
|
||||
// instead of the session's main model (summaries at main-model prices are
|
||||
// the most expensive recurring event in long runs). Empty = automatic: a
|
||||
// curated cheap model on official endpoints, the main model elsewhere.
|
||||
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
|
||||
CompactionModel string `json:"compactionModel,omitempty"`
|
||||
}
|
||||
|
||||
// RecentModelEntry is one provider-qualified model selection recorded in
|
||||
@@ -151,7 +157,8 @@ type KeyBindingsConfig struct {
|
||||
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
|
||||
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
|
||||
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
|
||||
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
|
||||
// TogglePlan is retained for configuration compatibility. Plan updates render
|
||||
// in the transcript and no longer have a persistent panel to toggle.
|
||||
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
|
||||
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
@@ -464,7 +471,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 firecrawl's
|
||||
// repeat a default's exact field values (e.g. re-declaring Exa's
|
||||
// default URL) — sets it true. IsUnconfiguredDefault checks this alongside
|
||||
// a resolved-value comparison, so redeclaring default values verbatim still
|
||||
// counts as user-configured.
|
||||
|
||||
+9
-66
@@ -5,31 +5,23 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lockutil"
|
||||
)
|
||||
|
||||
// Cross-process lock tuning. The lock is held only for a single metadata
|
||||
// read-modify-write (milliseconds), never across a job's exec, so the timeout is
|
||||
// generous and the stale threshold sits far above any real hold.
|
||||
// read-modify-write (milliseconds), never across a job's exec, so the timeout
|
||||
// is generous relative to any real hold.
|
||||
const (
|
||||
cronLockTimeout = 10 * time.Second
|
||||
cronLockStaleAfter = 60 * time.Second
|
||||
cronLockRetryDelay = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
var cronLockSeq atomic.Uint64
|
||||
|
||||
// lockJob takes a cross-process exclusive lock for one job by O_EXCL-creating a
|
||||
// sibling "<id>.lock" file next to the job directory (so Remove's RemoveAll of
|
||||
// the job dir never deletes a live lock). It serializes the read-modify-write of
|
||||
// a job's metadata across concurrent schedulers and commands. The lock file is
|
||||
// removed on release; a stale lock from a crashed holder (older than
|
||||
// cronLockStaleAfter) is reclaimed. Wall-clock time.Now is used deliberately
|
||||
// (not the injectable Store.now) so lock timing never depends on a frozen test
|
||||
// clock and the stale check compares against real file mtimes.
|
||||
// lockJob takes a cross-process advisory lock for one job on a stable sibling
|
||||
// "<id>.lock" file next to the job directory. It serializes the read-modify-write
|
||||
// of a job's metadata across concurrent schedulers and commands. The kernel
|
||||
// releases the lock when a holder exits; the file is never renamed or removed.
|
||||
func (s *Store) lockJob(id string) (func(), error) {
|
||||
if !validID(id) {
|
||||
return nil, fmt.Errorf("invalid cron job id %q", id)
|
||||
@@ -38,64 +30,15 @@ func (s *Store) lockJob(id string) (func(), error) {
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), cronLockSeq.Add(1))
|
||||
deadline := time.Now().Add(cronLockTimeout)
|
||||
for {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
lock, err := lockutil.TryAcquireFileLock(lockPath)
|
||||
if err == nil {
|
||||
// A partial write would leave a lock file without our token, so the
|
||||
// releaser could never delete it — stranding the lock. Fail closed.
|
||||
if _, werr := f.WriteString(token); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
return nil, fmt.Errorf("cron: write job lock: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
return nil, fmt.Errorf("cron: close job lock: %w", cerr)
|
||||
}
|
||||
var released bool
|
||||
return func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
}
|
||||
}, nil
|
||||
return func() { _ = lock.Release() }, nil
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry, exactly like ErrExist — otherwise the lock
|
||||
// spuriously fails under concurrency on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, fmt.Errorf("cron: acquire job lock: %w", err)
|
||||
}
|
||||
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
|
||||
// Remove lets two racers both "reclaim" and recreate, so both hold the lock;
|
||||
// reclaimStaleLock renames the file aside (only one rename of a given file
|
||||
// wins) and restores it if it turns out fresh, so a live lock is never deleted
|
||||
// out from under its holder.
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > cronLockStaleAfter {
|
||||
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
|
||||
info, err := os.Stat(reclaimedPath)
|
||||
return err == nil && time.Since(info.ModTime()) <= cronLockStaleAfter
|
||||
})
|
||||
if rerr != nil {
|
||||
// Reclaim hit a hard failure: the rename aside failed outright, or a
|
||||
// live holder's lock could not be put back (the lock path may be
|
||||
// missing, so re-acquiring would break mutual exclusion). Fail closed
|
||||
// instead of spinning to the deadline.
|
||||
return nil, fmt.Errorf("cron: reclaim stale job lock: %w", rerr)
|
||||
}
|
||||
if cleared {
|
||||
continue // cleared a genuinely stale lock; retry the O_EXCL create now
|
||||
}
|
||||
// Lost the reclaim race (or it was actually fresh) — fall through to the
|
||||
// bounded wait instead of hot-spinning on a reclaim that never wins (L13).
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return nil, fmt.Errorf("cron: timed out acquiring job lock for %q", id)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestMutateRemovedJobReturnsNotFound(t *testing.T) {
|
||||
if !errors.Is(err, ErrJobNotFound) {
|
||||
t.Fatalf("Mutate on removed job = %v, want ErrJobNotFound", err)
|
||||
}
|
||||
// And the lock file left no litter / did not resurrect the job dir.
|
||||
// The stable advisory-lock file does not resurrect the removed job dir.
|
||||
if _, gerr := store.Get(job.ID); !errors.Is(gerr, ErrJobNotFound) {
|
||||
t.Fatalf("job should remain removed, Get = %v", gerr)
|
||||
}
|
||||
|
||||
+35
-67
@@ -3,107 +3,60 @@ package daemon
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lockutil"
|
||||
)
|
||||
|
||||
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
|
||||
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
|
||||
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
|
||||
// daemon recovers from an unclean shutdown without manual cleanup.
|
||||
// Single-instance lock. The stable PID file carries a kernel advisory lock for
|
||||
// the daemon's full lifetime. Process exit releases the lock automatically, so
|
||||
// stale metadata needs no rename/remove recovery protocol.
|
||||
|
||||
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
|
||||
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
|
||||
|
||||
// fileLock is an acquired single-instance lock.
|
||||
type fileLock struct {
|
||||
path string
|
||||
lock *lockutil.FileLock
|
||||
}
|
||||
|
||||
// processAlive reports whether pid is a live process. Implemented per-platform
|
||||
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
|
||||
var processAlive = osProcessAlive
|
||||
|
||||
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
|
||||
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
|
||||
// stub); nil uses the real processAlive.
|
||||
// acquireLock takes the single-instance advisory lock at path. isAlive is used
|
||||
// only to enrich a contention error with the current holder's PID; the kernel
|
||||
// lock, not PID metadata, is authoritative.
|
||||
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
if isAlive == nil {
|
||||
isAlive = processAlive
|
||||
}
|
||||
// At most two passes: create, or detect-stale-then-retry once.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
// A failed PID write would leave a malformed lock file that another
|
||||
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
|
||||
// the single-instance guarantee — so on write failure, remove it and fail.
|
||||
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = lockutil.RemoveLockFile(path)
|
||||
return nil, werr
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = lockutil.RemoveLockFile(path)
|
||||
return nil, cerr
|
||||
}
|
||||
return &fileLock{path: path}, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
lock, err := lockutil.TryAcquireFileLock(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, err
|
||||
}
|
||||
pid, perr := readPidFile(path)
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
// Stale lock (dead PID or unreadable) — reclaim it atomically, then retry the
|
||||
// O_EXCL create. A blind Remove here races: two daemons starting at once could
|
||||
// both read the stale PID, both Remove, and then one Removes the OTHER's
|
||||
// freshly-created lock — leaving both "holding" the single-instance lock.
|
||||
// reclaimStaleLock renames the file aside so only one racer wins the rename,
|
||||
// and restores it if a live holder reacquired in the gap (D6).
|
||||
if _, rerr := reclaimStaleLock(path, isAlive); rerr != nil {
|
||||
// Reclaim hit a hard failure: the rename aside failed outright, or a
|
||||
// live holder's lock could not be put back (the lock path may be
|
||||
// missing, so re-acquiring would break the single-instance guarantee).
|
||||
// Fail closed instead of spinning to the deadline.
|
||||
return nil, fmt.Errorf("daemon: reclaim stale lock: %w", rerr)
|
||||
}
|
||||
return nil, ErrAlreadyRunning
|
||||
}
|
||||
return nil, ErrAlreadyRunning
|
||||
if err := lock.WriteMetadata([]byte(fmt.Sprintf("%d\n", os.Getpid()))); err != nil {
|
||||
return nil, errors.Join(err, lock.Release())
|
||||
}
|
||||
return &fileLock{lock: lock}, nil
|
||||
}
|
||||
|
||||
// daemonLockSeq makes each reclaim attempt's sidelined filename unique per process.
|
||||
var daemonLockSeq atomic.Uint64
|
||||
|
||||
// reclaimStaleLock atomically reclaims a single-instance lock whose recorded
|
||||
// PID is dead, via lockutil.ReclaimStaleLock: the lock file is renamed aside
|
||||
// (only one racer can win the rename) and stolen only if the moved file's PID
|
||||
// is still dead; a holder that reacquired the lock in the gap between the
|
||||
// stale check and the rename carries a LIVE pid and is restored rather than
|
||||
// stolen. Returns true only when a genuinely stale lock was removed. A
|
||||
// non-nil error means the caller must fail closed instead of re-acquiring
|
||||
// (see lockutil.ReclaimStaleLock).
|
||||
func reclaimStaleLock(path string, isAlive func(pid int) bool) (bool, error) {
|
||||
suffix := fmt.Sprintf("%d-%d", os.Getpid(), daemonLockSeq.Add(1))
|
||||
return lockutil.ReclaimStaleLock(path, suffix, func(reclaimedPath string) bool {
|
||||
pid, err := readPidFile(reclaimedPath)
|
||||
return err == nil && pid > 0 && isAlive(pid)
|
||||
})
|
||||
}
|
||||
|
||||
// release removes the lock file. Safe to call once. An already-missing lock
|
||||
// file is not an error (lockutil.RemoveLockFile swallows it on every platform).
|
||||
// release drops the kernel lock and closes its handle. The stable PID file is
|
||||
// deliberately retained so contenders always address the same inode.
|
||||
func (l *fileLock) release() error {
|
||||
if l == nil || l.path == "" {
|
||||
if l == nil || l.lock == nil {
|
||||
return nil
|
||||
}
|
||||
return lockutil.RemoveLockFile(l.path)
|
||||
return l.lock.Release()
|
||||
}
|
||||
|
||||
// readPidFile reads and parses the PID recorded in a lock file.
|
||||
@@ -112,5 +65,20 @@ func readPidFile(path string) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
metadata := strings.TrimSpace(string(data))
|
||||
if pid, err := strconv.Atoi(metadata); err == nil {
|
||||
return pid, nil
|
||||
}
|
||||
pidText, sequenceText, ok := strings.Cut(metadata, "-")
|
||||
if !ok || pidText == "" || sequenceText == "" || strings.Contains(sequenceText, "-") {
|
||||
return 0, fmt.Errorf("daemon: parse lock PID metadata %q", metadata)
|
||||
}
|
||||
if _, err := strconv.ParseUint(sequenceText, 10, 64); err != nil {
|
||||
return 0, fmt.Errorf("daemon: parse lock holder sequence: %w", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(pidText)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("daemon: parse lock PID: %w", err)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
@@ -32,27 +32,48 @@ func TestLockSingleInstance(t *testing.T) {
|
||||
_ = l2.release()
|
||||
}
|
||||
|
||||
func TestLockStaleRecovery(t *testing.T) {
|
||||
// A PID liveness probe is diagnostic only. Even if it reports the recorded PID
|
||||
// dead, a contended kernel lock proves that the holder is active and must win.
|
||||
// The old rename-aside protocol trusted this stale observation and admitted a
|
||||
// second daemon while the first one was still inside its critical section.
|
||||
func TestLockDoesNotOverrideKernelHolderWithStalePIDProbe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
// Simulate a stale lock from a crashed daemon: a PID file whose process is
|
||||
// dead.
|
||||
first, err := acquireLock(path, func(int) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatalf("first acquireLock: %v", err)
|
||||
}
|
||||
defer first.release()
|
||||
|
||||
second, err := acquireLock(path, func(int) bool { return false })
|
||||
if second != nil {
|
||||
_ = second.release()
|
||||
}
|
||||
if !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("contended acquire with stale PID probe = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockIgnoresStaleMetadataWhenKernelLockIsFree(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
// A crashed daemon may leave PID metadata behind, but its kernel lock is
|
||||
// released automatically, so the next daemon can acquire without moving it.
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write stale lock: %v", err)
|
||||
}
|
||||
dead := func(int) bool { return false }
|
||||
l, err := acquireLock(path, dead)
|
||||
if err != nil {
|
||||
t.Fatalf("stale-lock recovery failed: %v", err)
|
||||
t.Fatalf("acquire over stale metadata: %v", err)
|
||||
}
|
||||
// The lock now records OUR pid, not the stale one.
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
|
||||
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
|
||||
t.Fatalf("lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
|
||||
}
|
||||
_ = l.release()
|
||||
}
|
||||
|
||||
func TestLockReleaseRemovesFile(t *testing.T) {
|
||||
func TestLockReleaseKeepsStableFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
l, err := acquireLock(path, func(int) bool { return true })
|
||||
if err != nil {
|
||||
@@ -61,46 +82,37 @@ func TestLockReleaseRemovesFile(t *testing.T) {
|
||||
if err := l.release(); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("lock file still present after release: %v", err)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("release removed stable lock file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimStaleLockRemovesDeadHolder(t *testing.T) {
|
||||
func TestReadPidFileMetadataFormats(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write stale lock: %v", err)
|
||||
}
|
||||
if ok, err := reclaimStaleLock(path, func(int) bool { return false }); err != nil || !ok {
|
||||
t.Fatalf("reclaimStaleLock must report a genuinely stale (dead-PID) lock reclaimed (ok=%v err=%v)", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("a reclaimed stale lock must be removed, stat = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimStaleLockRestoresLiveHolder(t *testing.T) {
|
||||
// If a holder reacquires the lock in the gap between the stale check and the
|
||||
// rename, the moved file carries a LIVE pid; reclaim must restore it untouched
|
||||
// rather than steal it — otherwise two daemons both "hold" the lock (D6).
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write lock: %v", err)
|
||||
}
|
||||
if ok, err := reclaimStaleLock(path, func(int) bool { return true }); err != nil || ok {
|
||||
t.Fatalf("reclaimStaleLock must NOT report a live-PID lock reclaimed (ok=%v err=%v)", ok, err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("live lock must be restored in place, read = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) != "4242" {
|
||||
t.Fatalf("restored lock content = %q, want %q (unchanged)", strings.TrimSpace(string(data)), "4242")
|
||||
}
|
||||
// No sidelined ".stale" leftovers.
|
||||
matches, _ := filepath.Glob(path + ".stale.*")
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("reclaim left sidelined files: %v", matches)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
metadata string
|
||||
wantPID int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "legacy PID", metadata: "4242\n", wantPID: 4242},
|
||||
{name: "PID and holder sequence", metadata: "4242-7\n", wantPID: 4242},
|
||||
{name: "missing sequence", metadata: "4242-", wantErr: true},
|
||||
{name: "non-numeric sequence", metadata: "4242-next", wantErr: true},
|
||||
{name: "extra component", metadata: "4242-7-8", wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(test.metadata), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readPidFile(path)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("readPidFile() error = %v, wantErr %v", err, test.wantErr)
|
||||
}
|
||||
if !test.wantErr && got != test.wantPID {
|
||||
t.Fatalf("readPidFile() = %d, want %d", got, test.wantPID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,12 +124,16 @@ func TestServerEndToEnd(t *testing.T) {
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
// Socket, status, and lock files are removed on exit.
|
||||
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
|
||||
// Runtime endpoints are removed on exit. The advisory lock file remains as
|
||||
// a stable inode, but the kernel lock on it has been released.
|
||||
for _, p := range []string{paths.Socket, paths.Status} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(paths.Lock); err != nil {
|
||||
t.Fatalf("stable lock file missing after shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSecondInstanceFails(t *testing.T) {
|
||||
|
||||
@@ -9,7 +9,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const PolicyVersion = 1
|
||||
// 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
|
||||
|
||||
type Origin string
|
||||
|
||||
|
||||
@@ -3,13 +3,93 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// requireProcessState refuses to let a broken ps pipeline masquerade as passing
|
||||
// coverage. Every test in this file proves something about zombie detection,
|
||||
// and every one of those proofs is read out of ps — so if ps cannot report
|
||||
// state, the tests verify nothing whatever they do next.
|
||||
//
|
||||
// The old code answered that with t.Skip, which is indistinguishable from
|
||||
// success in CI output. This probes BOTH ps pipelines against this test
|
||||
// process, which is by definition alive and not a zombie, so a working ps must
|
||||
// report a live state for it. Failing that:
|
||||
//
|
||||
// - In CI (or with ZERO_REQUIRE_PS=1) it is a hard failure. ps is present on
|
||||
// every image used today, so its absence means the pipeline regressed and
|
||||
// that has to surface immediately rather than as skipped-green.
|
||||
// - Elsewhere it stays a skip, so a contributor on an exotic box is not
|
||||
// blocked by an environment the project does not claim to support.
|
||||
//
|
||||
// This only covers "ps cannot report state at all". Once it passes, a later
|
||||
// failure to observe an EXPECTED state is a real defect, and the tests below
|
||||
// treat it as one.
|
||||
func requireProcessState(t *testing.T) {
|
||||
t.Helper()
|
||||
self := os.Getpid()
|
||||
var reasons []string
|
||||
// The production path: ps -A -o pid=,pgid=,stat=, parsed by
|
||||
// processTableStateMatches. This is what signalTargetRunning consults.
|
||||
if running, ok := processIsRunning(self); !ok || !running {
|
||||
reasons = append(reasons, "the process table read used by signalTargetRunning reported no live state for this test process")
|
||||
}
|
||||
// This file's independent oracle: ps -o stat= -p <pid>. It is deliberately
|
||||
// NOT the production parser, so a regression in that parser cannot hide by
|
||||
// breaking the test's own precondition check in the same way.
|
||||
if zombie, ok := processIsZombie(self); !ok || zombie {
|
||||
reasons = append(reasons, "the per-PID state read used by this test file reported no live state for this test process")
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return
|
||||
}
|
||||
reason := "cannot read process state, so ps-based zombie detection is unverifiable here: " + strings.Join(reasons, "; ")
|
||||
if requireWorkingPS() {
|
||||
t.Fatal(reason + " — failing rather than skipping because CI (or ZERO_REQUIRE_PS) is set, where ps is expected to work")
|
||||
}
|
||||
t.Skip(reason)
|
||||
}
|
||||
|
||||
// requireWorkingPS reports whether an unusable ps must fail the run instead of
|
||||
// skipping it. ZERO_REQUIRE_PS lets a developer opt into the strict behavior
|
||||
// locally to reproduce what CI does.
|
||||
func requireWorkingPS() bool {
|
||||
if strings.TrimSpace(os.Getenv("ZERO_REQUIRE_PS")) != "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("CI")) != ""
|
||||
}
|
||||
|
||||
// awaitZombie blocks until pid is an unreaped zombie. The caller must not have
|
||||
// waited on the child, so the zombie is a stable state rather than a race: it
|
||||
// persists until something reaps it. Failing to reach it therefore means the
|
||||
// child never exited or ps stopped reporting its state, and both are defects
|
||||
// rather than reasons to skip.
|
||||
func awaitZombie(t *testing.T, pid int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
zombie, ok := processIsZombie(pid)
|
||||
if ok && zombie {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
if !ok {
|
||||
t.Fatalf("pid %d left the process table without being reaped; this test never waited on it, so its zombie row must persist", pid)
|
||||
}
|
||||
t.Fatalf("pid %d did not reach the zombie state within 5s", pid)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
|
||||
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
|
||||
// zombie, so a group whose only remaining member is waiting to be reaped used to
|
||||
@@ -17,6 +97,8 @@ import (
|
||||
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
|
||||
// depended on when the reaper ran.
|
||||
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
||||
requireProcessState(t)
|
||||
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
ConfigureProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -27,23 +109,59 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
||||
// is exactly the state a terminated tree passes through.
|
||||
t.Cleanup(func() { _ = cmd.Wait() })
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if zombie, ok := processIsZombie(pid); ok && zombie {
|
||||
break
|
||||
awaitZombie(t, pid)
|
||||
|
||||
// Whether a zombie-only group can be signalled is a KERNEL difference, not a
|
||||
// ps one. On Linux the zombie still occupies the process table and
|
||||
// kill(-pid, 0) succeeds, so signalTargetRunning goes on to consult ps —
|
||||
// which is the branch this test was written for. Darwin returns EPERM for
|
||||
// the same call, so signalTargetRunning short-circuits on the kill and never
|
||||
// reaches the ps check.
|
||||
//
|
||||
// The OUTCOME is identical and is asserted on every platform below. Only the
|
||||
// branch that produces it differs, so record that instead of skipping the
|
||||
// whole test (which would drop Darwin's coverage of the outcome) or failing
|
||||
// it (which would report a supported platform as broken). Any errno other
|
||||
// than EPERM means the group is not in the state this test needs.
|
||||
psPathReachable := true
|
||||
if err := syscall.Kill(-pid, syscall.Signal(0)); err != nil {
|
||||
if !errors.Is(err, syscall.EPERM) {
|
||||
t.Fatalf("zombie group %d is unsignalable with an unexpected error (%v); the zombie case is not present, so nothing below would be meaningful", pid, err)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
|
||||
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
|
||||
psPathReachable = false
|
||||
t.Logf("kernel refuses to signal zombie-only group %d (EPERM); signalTargetRunning answers from kill(2) rather than ps here, so the ps-based zombie check is asserted directly on platforms that allow the signal", pid)
|
||||
}
|
||||
|
||||
// Where the signal IS permitted, pin the ps-based check itself. This is the
|
||||
// regression #774 was about, and asserting only signalTargetRunning would
|
||||
// let a ps-side regression hide behind the kill result.
|
||||
if psPathReachable {
|
||||
running, ok := processGroupHasRunningMember(pid)
|
||||
if !ok {
|
||||
t.Fatalf("process group %d has no rows while its zombie is unreaped; ps stopped reporting the group rather than the group being gone", pid)
|
||||
}
|
||||
if running {
|
||||
t.Fatal("the ps-based group check must not report a zombie-only group as running")
|
||||
}
|
||||
}
|
||||
|
||||
// Valid on every platform: whichever branch answers, a zombie-only group is
|
||||
// not running. On a kernel that refuses the signal this is the whole of the
|
||||
// coverage, and it is more than the previous skip provided.
|
||||
if signalTargetRunning(-pid) {
|
||||
t.Fatal("a group holding only a zombie must not count as running")
|
||||
}
|
||||
|
||||
if !psPathReachable {
|
||||
// terminateTarget's own kill(2) would hit the same EPERM and be returned
|
||||
// as a cleanup error, so this assertion cannot be made where the kernel
|
||||
// refuses the signal. That may be a real gap in TerminateProcessTree on
|
||||
// such a platform rather than a test limitation — a zombie-only tree is
|
||||
// already exited and should terminate cleanly — but confirming and
|
||||
// changing that is a production fix, not this test's business.
|
||||
t.Log("skipping the TerminateProcessTree assertion: its kill(2) would hit the same EPERM as the probe above")
|
||||
return
|
||||
}
|
||||
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
|
||||
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
|
||||
}
|
||||
@@ -53,6 +171,8 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
||||
// a live member must still be seen as running and then actually stopped, so the
|
||||
// zombie tolerance above cannot degrade into ignoring real processes.
|
||||
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
||||
requireProcessState(t)
|
||||
|
||||
cmd := exec.Command("sh", "-c", "sleep 30")
|
||||
ConfigureProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -67,7 +187,15 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
||||
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
|
||||
t.Fatalf("TerminateProcessTree: %v", err)
|
||||
}
|
||||
if running, ok := processGroupHasRunningMember(pid); ok && running {
|
||||
// ok reports whether ANY row for this group was found. This test never waits
|
||||
// on the child, so its zombie row must still be there — an unknown state
|
||||
// means ps stopped answering, not that the group is gone. Treating that as a
|
||||
// pass is what let this assertion succeed without checking anything.
|
||||
running, ok := processGroupHasRunningMember(pid)
|
||||
if !ok {
|
||||
t.Fatalf("process group %d has no rows after termination; the unreaped child's zombie row must persist, so the group's state is undeterminable rather than clean", pid)
|
||||
}
|
||||
if running {
|
||||
t.Fatal("the group still has a running member after termination")
|
||||
}
|
||||
}
|
||||
@@ -82,6 +210,8 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
||||
// "unknown" resulted every time, and signalTargetRunning conservatively (and
|
||||
// incorrectly) treated a zombie individual-PID target as still running.
|
||||
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
|
||||
requireProcessState(t)
|
||||
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
|
||||
// test process's group, so it is not its own leader and processSignalTarget
|
||||
@@ -92,40 +222,38 @@ func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
|
||||
pid := cmd.Process.Pid
|
||||
t.Cleanup(func() { _ = cmd.Wait() })
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if zombie, ok := processIsZombie(pid); ok && zombie {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
awaitZombie(t, pid)
|
||||
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
t.Fatalf("processSignalTarget: %v", err)
|
||||
}
|
||||
// The child inherited this process's group, so its PGID is this process's
|
||||
// PID and can never equal its own PID. A negative target here would mean
|
||||
// processSignalTarget misread the group, which is the very thing the
|
||||
// individual-PID fallback exists to handle.
|
||||
if target != pid {
|
||||
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
|
||||
t.Fatalf("processSignalTarget(%d) = %d, want the individual PID: the child inherited this process's group, so it is not its own leader", pid, target)
|
||||
}
|
||||
if signalTargetRunning(target) {
|
||||
t.Fatal("a zombie individual-PID target must not count as running")
|
||||
}
|
||||
}
|
||||
|
||||
// processIsZombie reports a process's zombie state via ps. ok is false when the
|
||||
// state could not be read.
|
||||
// processIsZombie reports a process's zombie state via ps. It is deliberately a
|
||||
// separate, per-PID ps invocation rather than a reuse of the production
|
||||
// process-table parser: these tests assert what that parser concludes, so
|
||||
// borrowing it to establish their own preconditions would let one regression
|
||||
// silence both sides at once.
|
||||
//
|
||||
// ok is false when no state could be read, which after requireProcessState has
|
||||
// passed means the process left the table rather than that ps is broken.
|
||||
func processIsZombie(pid int) (zombie bool, ok bool) {
|
||||
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
state := string(output)
|
||||
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
|
||||
state = state[1:]
|
||||
}
|
||||
state := strings.TrimLeft(string(output), " \n\t")
|
||||
if state == "" {
|
||||
return false, false
|
||||
}
|
||||
|
||||
+9
-63
@@ -5,93 +5,39 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lockutil"
|
||||
)
|
||||
|
||||
// Cross-process lock tuning for the audit log. The lock is held only across a
|
||||
// single read-then-append (milliseconds), so the timeout is generous and the
|
||||
// stale threshold sits far above any real hold.
|
||||
// single read-then-append (milliseconds), so the timeout is generous relative
|
||||
// to any real hold.
|
||||
const (
|
||||
auditLockTimeout = 10 * time.Second
|
||||
auditLockStaleAfter = 60 * time.Second
|
||||
auditLockRetryDelay = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
var auditLockSeq atomic.Uint64
|
||||
|
||||
// lockAudit takes a cross-process exclusive lock on the audit log by
|
||||
// O_EXCL-creating a sibling "<auditPath>.lock" file (removed on release). It makes
|
||||
// lockAudit takes a cross-process advisory lock on the audit log through a
|
||||
// stable sibling "<auditPath>.lock" file. It makes
|
||||
// the lastSequence()+append in append() atomic across processes; the audit log is
|
||||
// a shared global file, so without it two processes can read the same last
|
||||
// sequence and write duplicate numbers. A stale lock from a crashed holder (older
|
||||
// than auditLockStaleAfter) is reclaimed. Wall-clock time is used deliberately so
|
||||
// lock timing never depends on the store's injectable clock and the stale check
|
||||
// compares against real file mtimes. This mirrors internal/cron/lock.go and
|
||||
// internal/oauth/lock.go. Uses internal/lockutil for lock restore and reclaim.
|
||||
// sequence and write duplicate numbers. Kernel lock lifetime provides crash
|
||||
// recovery without moving or deleting the lock path.
|
||||
func (store *AuditStore) lockAudit() (func(), error) {
|
||||
lockPath := store.auditPath + ".lock"
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), auditLockSeq.Add(1))
|
||||
deadline := time.Now().Add(auditLockTimeout)
|
||||
for {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
lock, err := lockutil.TryAcquireFileLock(lockPath)
|
||||
if err == nil {
|
||||
// A partial write would leave a lock file without our token, so the
|
||||
// releaser could never delete it — stranding the lock. Fail closed.
|
||||
if _, werr := f.WriteString(token); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
return nil, fmt.Errorf("hooks: write audit lock: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
return nil, fmt.Errorf("hooks: close audit lock: %w", cerr)
|
||||
}
|
||||
var released bool
|
||||
return func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||||
_ = lockutil.RemoveLockFile(lockPath)
|
||||
}
|
||||
}, nil
|
||||
return func() { _ = lock.Release() }, nil
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat both
|
||||
// as contention and retry.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, fmt.Errorf("hooks: acquire audit lock: %w", err)
|
||||
}
|
||||
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
|
||||
// Remove lets two racers both reclaim + recreate and so both hold the lock;
|
||||
// reclaimStaleLock renames the file aside (only one rename wins) and restores
|
||||
// it if it turns out fresh, so a live lock is never deleted out from under it.
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > auditLockStaleAfter {
|
||||
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
|
||||
info, err := os.Stat(reclaimedPath)
|
||||
return err == nil && time.Since(info.ModTime()) <= auditLockStaleAfter
|
||||
})
|
||||
if rerr != nil {
|
||||
// Reclaim hit a hard failure: the rename aside failed outright, or a
|
||||
// live holder's lock could not be put back (the lock path may be
|
||||
// missing, so re-acquiring would break mutual exclusion). Fail closed
|
||||
// instead of spinning to the deadline.
|
||||
return nil, fmt.Errorf("hooks: reclaim stale audit lock: %w", rerr)
|
||||
}
|
||||
if cleared {
|
||||
continue
|
||||
}
|
||||
// Lost the reclaim race (or it was actually fresh) — fall through to the
|
||||
// bounded wait rather than hot-spinning on a reclaim that never wins.
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return nil, fmt.Errorf("hooks: timed out acquiring audit lock")
|
||||
}
|
||||
|
||||
+111
-48
@@ -1,64 +1,127 @@
|
||||
// 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 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
|
||||
|
||||
import (
|
||||
"io"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// 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)
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("lockutil: resolve lock path relative to root: %w", err)
|
||||
}
|
||||
dst, err := os.OpenFile(staged, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
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)
|
||||
if err != nil {
|
||||
_ = src.Close()
|
||||
return err
|
||||
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
|
||||
}
|
||||
_, 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()
|
||||
state, contended, err := tryLockFile(file)
|
||||
if err != nil {
|
||||
_ = dst.Close()
|
||||
_ = os.Remove(staged)
|
||||
return err
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("lockutil: acquire advisory lock: %w", err)
|
||||
}
|
||||
if err := dst.Close(); err != nil {
|
||||
_ = os.Remove(staged)
|
||||
return err
|
||||
if contended {
|
||||
_ = file.Close()
|
||||
return nil, ErrLockHeld
|
||||
}
|
||||
if err := publish(staged, path); 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)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -4,43 +4,92 @@ package lockutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 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)
|
||||
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)
|
||||
if err == nil {
|
||||
_ = RemoveLockFile(reclaimed)
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
return unix.Flock(int(file.Fd()), unix.LOCK_UN)
|
||||
}
|
||||
+205
-101
@@ -1,116 +1,220 @@
|
||||
package lockutil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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) {
|
||||
func TestFileLockSerializesAndKeepsStablePath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "lock")
|
||||
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
first, err := TryAcquireFileLock(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquire: %v", err)
|
||||
}
|
||||
if err := RemoveLockFile(path); err != nil {
|
||||
t.Fatalf("RemoveLockFile failed: %v", err)
|
||||
firstInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat first lock path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected lock file to be removed: %v", err)
|
||||
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
|
||||
t.Fatalf("contended acquire = %v, want ErrLockHeld", 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -4,77 +4,202 @@ package lockutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// 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)
|
||||
type platformLockState struct {
|
||||
overlapped windows.Overlapped
|
||||
}
|
||||
|
||||
// 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)
|
||||
const lockByteOffsetHigh = 1
|
||||
|
||||
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
|
||||
rootPtr, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
toPtr, err := windows.UTF16PtrFromString(to)
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("open lock root: %w", err)
|
||||
}
|
||||
return windows.MoveFileEx(fromPtr, toPtr, 0)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
defer func() {
|
||||
if parent != 0 {
|
||||
resultErr = errors.Join(resultErr, windows.CloseHandle(parent))
|
||||
}
|
||||
var errno syscall.Errno
|
||||
if errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
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)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -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 Firecrawl) that the user never touched in their
|
||||
// defaults (e.g. keyless Exa) 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
|
||||
|
||||
+118
-3
@@ -1,6 +1,8 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -90,7 +92,7 @@ func TestNormalizeConfigValidatesTransportBoundaries(t *testing.T) {
|
||||
|
||||
func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
|
||||
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"firecrawl": config.DefaultMCPServers()["firecrawl"],
|
||||
"exa": config.DefaultMCPServers()["exa"],
|
||||
"web": {
|
||||
Type: "http",
|
||||
URL: "https://example.com/mcp",
|
||||
@@ -107,8 +109,8 @@ func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
|
||||
byName[server.Name] = server
|
||||
}
|
||||
|
||||
if !byName["firecrawl"].UnconfiguredDefault {
|
||||
t.Fatal("an untouched firecrawl default should be flagged UnconfiguredDefault")
|
||||
if !byName["exa"].UnconfiguredDefault {
|
||||
t.Fatal("an untouched exa default should be flagged UnconfiguredDefault")
|
||||
}
|
||||
if byName["web"].UnconfiguredDefault {
|
||||
t.Fatal("a server the user configured must not be flagged UnconfiguredDefault")
|
||||
@@ -240,3 +242,116 @@ 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)
|
||||
}
|
||||
@@ -176,9 +176,11 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
|
||||
staged = append(staged, tool)
|
||||
}
|
||||
}
|
||||
for _, tool := range staged {
|
||||
registry.Register(tool)
|
||||
registered := make([]tools.Tool, 0, len(staged))
|
||||
for index := range staged {
|
||||
registered = append(registered, staged[index])
|
||||
}
|
||||
registry.RegisterBatch(registered)
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,62 @@ 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"),
|
||||
@@ -153,16 +209,16 @@ func TestRegisterToolsSkipsUnreachableServerAndKeepsOthers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
|
||||
// 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
|
||||
// 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
|
||||
// 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{
|
||||
"firecrawl": config.DefaultMCPServers()["firecrawl"],
|
||||
"custom": {Type: "stdio", Command: "custom-mcp"},
|
||||
"exa": config.DefaultMCPServers()["exa"],
|
||||
"custom": {Type: "stdio", Command: "custom-mcp"},
|
||||
}}, RegisterOptions{
|
||||
ClientFactory: func(_ context.Context, server Server) (ToolClient, error) {
|
||||
return nil, errors.New(server.Name + " connect failed")
|
||||
@@ -177,12 +233,12 @@ func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
|
||||
for _, skipped := range runtime.Skipped() {
|
||||
byName[skipped.Name] = skipped
|
||||
}
|
||||
firecrawl, ok := byName["firecrawl"]
|
||||
exa, ok := byName["exa"]
|
||||
if !ok {
|
||||
t.Fatalf("Skipped() = %#v, want an entry for firecrawl", runtime.Skipped())
|
||||
t.Fatalf("Skipped() = %#v, want an entry for exa", runtime.Skipped())
|
||||
}
|
||||
if !firecrawl.UnconfiguredDefault {
|
||||
t.Fatalf("Skipped()[firecrawl] = %#v, want UnconfiguredDefault", firecrawl)
|
||||
if !exa.UnconfiguredDefault {
|
||||
t.Fatalf("Skipped()[exa] = %#v, want UnconfiguredDefault", exa)
|
||||
}
|
||||
custom, ok := byName["custom"]
|
||||
if !ok {
|
||||
|
||||
@@ -231,6 +231,12 @@ 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,6 +20,10 @@ 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},
|
||||
|
||||
+7
-64
@@ -5,24 +5,16 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lockutil"
|
||||
)
|
||||
|
||||
const (
|
||||
fileLockTimeout = 5 * time.Second
|
||||
fileLockStaleAfter = 30 * time.Second
|
||||
)
|
||||
const fileLockTimeout = 5 * time.Second
|
||||
|
||||
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.
|
||||
// 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.
|
||||
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
@@ -30,64 +22,15 @@ 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 {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
lock, err := lockutil.TryAcquireFileLock(lockPath)
|
||||
if err == nil {
|
||||
// A partial/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
|
||||
return func() { _ = lock.Release() }, nil
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry, exactly like ErrExist — otherwise the lock
|
||||
// spuriously fails under concurrency on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, fmt.Errorf("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))
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
|
||||
}
|
||||
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
|
||||
if owner == nil {
|
||||
return nil, errors.New("Windows access token has no default owner")
|
||||
return nil, errors.New("windows access token has no default owner")
|
||||
}
|
||||
return owner.Copy()
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ 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"`
|
||||
@@ -479,6 +480,7 @@ 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)
|
||||
@@ -531,8 +533,9 @@ 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 (cached %d) out=%d | requests=%d tools=%d retries=%d reconnects=%d compactions=%d",
|
||||
result.Totals.InputTokens, result.Totals.CachedInputTokens, result.Totals.OutputTokens,
|
||||
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,
|
||||
result.Totals.ModelRequests, result.Totals.ToolCalls, result.Totals.Retries,
|
||||
result.Totals.Reconnects, result.Totals.Compactions))
|
||||
for _, class := range sortedClasses(result.PerClass) {
|
||||
@@ -545,6 +548,23 @@ 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.
|
||||
|
||||
@@ -74,6 +74,7 @@ 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,
|
||||
@@ -155,6 +156,9 @@ 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)
|
||||
}
|
||||
@@ -334,6 +338,35 @@ 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)
|
||||
|
||||
@@ -184,6 +184,10 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
|
||||
|
||||
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
|
||||
switch strings.TrimSpace(provider.ID) {
|
||||
case "chatgpt":
|
||||
// ChatGPT authenticates against the Codex backend, while its public
|
||||
// model metadata is published under the OpenAI provider on models.dev.
|
||||
return "openai"
|
||||
case "dashscope":
|
||||
return "alibaba"
|
||||
case "github":
|
||||
|
||||
@@ -241,6 +241,7 @@ func TestPublicLiveCatalog(t *testing.T) {
|
||||
|
||||
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"chatgpt": "openai",
|
||||
"github": "github-models",
|
||||
"moonshot": "moonshotai",
|
||||
"nvidia-nim": "nvidia",
|
||||
|
||||
@@ -539,7 +539,8 @@ 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 := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
|
||||
preferLive := provider.Local ||
|
||||
providermodelcatalog.PublicLiveCatalog(provider.ID) ||
|
||||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
|
||||
result := make([]Model, 0, len(liveModels))
|
||||
for _, live := range liveModels {
|
||||
|
||||
@@ -139,6 +139,68 @@ func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api.json":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"openai": {"models": {
|
||||
"gpt-5.6-sol": {
|
||||
"id": "gpt-5.6-sol",
|
||||
"name": "GPT-5.6 Sol",
|
||||
"description": "Frontier coding model",
|
||||
"tool_call": true,
|
||||
"reasoning": true,
|
||||
"reasoning_efforts": ["low", "high", "max"],
|
||||
"limit": {"context": 1050000},
|
||||
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
|
||||
}
|
||||
}}
|
||||
}`))
|
||||
case "/backend-api/codex/models":
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected request path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, ok := providercatalog.Get("chatgpt")
|
||||
if !ok {
|
||||
t.Fatal("ChatGPT provider missing from catalog")
|
||||
}
|
||||
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
|
||||
Name: "chatgpt",
|
||||
CatalogID: "chatgpt",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/backend-api/codex",
|
||||
}, Options{
|
||||
HTTPClient: server.Client(),
|
||||
ModelsDevURL: server.URL + "/api.json",
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
return "Authorization", "Bearer test", true, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverCatalog returned error: %v", err)
|
||||
}
|
||||
if len(models) != 1 {
|
||||
t.Fatalf("models = %#v, want one ChatGPT live model", models)
|
||||
}
|
||||
model := models[0]
|
||||
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
|
||||
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
|
||||
}
|
||||
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
|
||||
t.Fatalf("input modalities = %q, want text,image,pdf", got)
|
||||
}
|
||||
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
|
||||
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
|
||||
models := mergeLiveModels(
|
||||
providercatalog.Descriptor{ID: "chatgpt"},
|
||||
@@ -164,6 +226,29 @@ 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.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ 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"
|
||||
@@ -70,17 +71,37 @@ 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 {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// responsesReasoning carries the reasoning controls for the Responses API. The
|
||||
@@ -105,6 +126,7 @@ 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"`
|
||||
}
|
||||
@@ -118,10 +140,11 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// responsesEvent is the decoded form of one Responses SSE data payload.
|
||||
@@ -132,6 +155,7 @@ 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"`
|
||||
@@ -150,6 +174,7 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -173,7 +198,8 @@ type outputDetails struct {
|
||||
}
|
||||
|
||||
type inputDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
type errorPayload struct {
|
||||
@@ -187,8 +213,10 @@ 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
|
||||
}
|
||||
@@ -197,15 +225,57 @@ 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
|
||||
usage *usagePayload
|
||||
done bool
|
||||
toolCalls map[string]*toolCallBuilder
|
||||
toolOrder []string
|
||||
text strings.Builder
|
||||
responseID string
|
||||
usage *usagePayload
|
||||
emitted bool
|
||||
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
|
||||
@@ -215,11 +285,14 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
return nil, errors.New("codex provider: model is required")
|
||||
}
|
||||
req := &responsesRequest{
|
||||
Model: p.inner.model,
|
||||
Stream: true,
|
||||
MaxOutputTokens: p.inner.maxTokens,
|
||||
Model: p.inner.model,
|
||||
Stream: true,
|
||||
ParallelToolCalls: true,
|
||||
MaxOutputTokens: p.inner.maxTokens,
|
||||
PromptCacheKey: request.PromptCacheKey,
|
||||
}
|
||||
instructions := []string{}
|
||||
customCallIDs := map[string]string{}
|
||||
for _, msg := range request.Messages {
|
||||
switch msg.Role {
|
||||
case zeroruntime.MessageRoleSystem:
|
||||
@@ -234,13 +307,31 @@ 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: "function_call_output",
|
||||
CallID: msg.ToolCallID,
|
||||
Type: outputType,
|
||||
CallID: callID,
|
||||
Output: msg.Content,
|
||||
})
|
||||
default:
|
||||
@@ -249,12 +340,25 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
}
|
||||
req.Instructions = strings.Join(instructions, "\n\n")
|
||||
for _, tool := range request.Tools {
|
||||
req.Tools = append(req.Tools, responsesTool{
|
||||
Type: "function",
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
})
|
||||
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)
|
||||
}
|
||||
// Codex / o-series reasoning models take the effort tier nested under
|
||||
// `reasoning` on the Responses API (the chat-completions `reasoning_effort`
|
||||
@@ -312,13 +416,26 @@ func (p *CodexProvider) assistantInputItems(msg zeroruntime.Message) ([]inputIte
|
||||
if tc.Name == "" {
|
||||
return nil, errors.New("codex provider: assistant tool call missing name")
|
||||
}
|
||||
items = append(items, inputItem{
|
||||
Type: "function_call",
|
||||
ID: tc.ID,
|
||||
CallID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Arguments: tc.Arguments,
|
||||
})
|
||||
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)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -479,6 +596,18 @@ 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
|
||||
@@ -500,12 +629,17 @@ func (p *CodexProvider) emitResponsesEvent(
|
||||
// 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,
|
||||
@@ -517,6 +651,7 @@ func (p *CodexProvider) emitResponsesEvent(
|
||||
// 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,
|
||||
@@ -524,13 +659,16 @@ func (p *CodexProvider) emitResponsesEvent(
|
||||
}
|
||||
return true
|
||||
case responsesEventFunctionArgsDelta:
|
||||
p.handleFunctionArgsDelta(ctx, &event, state, events)
|
||||
p.handleToolInputDelta(ctx, event, state, events, false)
|
||||
return true
|
||||
case responsesEventCustomInputDelta:
|
||||
p.handleToolInputDelta(ctx, event, state, events, true)
|
||||
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 != "" {
|
||||
@@ -570,22 +708,38 @@ func (p *CodexProvider) handleOutputItemAdded(
|
||||
return
|
||||
}
|
||||
switch event.Item.Type {
|
||||
case "function_call":
|
||||
case "function_call", "custom_tool_call":
|
||||
key := p.toolCallKey(event)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
builder := &toolCallBuilder{
|
||||
ID: key,
|
||||
Name: event.Item.Name,
|
||||
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
|
||||
}
|
||||
state.toolCalls[key] = builder
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
ToolCallID: key,
|
||||
ToolName: event.Item.Name,
|
||||
})
|
||||
builder.started = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,11 +747,12 @@ 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) handleFunctionArgsDelta(
|
||||
func (p *CodexProvider) handleToolInputDelta(
|
||||
ctx context.Context,
|
||||
event *responsesEvent,
|
||||
state *responsesState,
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
freeform bool,
|
||||
) {
|
||||
key := p.toolCallKey(event)
|
||||
if key == "" {
|
||||
@@ -610,10 +765,16 @@ func (p *CodexProvider) handleFunctionArgsDelta(
|
||||
// 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}
|
||||
builder = &toolCallBuilder{ID: key, Freeform: freeform}
|
||||
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,
|
||||
@@ -632,7 +793,7 @@ func (p *CodexProvider) handleOutputItemDone(
|
||||
state *responsesState,
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
) {
|
||||
if event.Item == nil || event.Item.Type != "function_call" {
|
||||
if event.Item == nil || (event.Item.Type != "function_call" && event.Item.Type != "custom_tool_call") {
|
||||
return
|
||||
}
|
||||
key := p.toolCallKey(event)
|
||||
@@ -641,20 +802,31 @@ func (p *CodexProvider) handleOutputItemDone(
|
||||
}
|
||||
builder, ok := state.toolCalls[key]
|
||||
if !ok {
|
||||
builder = &toolCallBuilder{ID: key}
|
||||
builder = &toolCallBuilder{ID: key, Freeform: event.Item.Type == "custom_tool_call"}
|
||||
state.toolCalls[key] = builder
|
||||
state.toolOrder = append(state.toolOrder, key)
|
||||
}
|
||||
if event.Item.Name != "" {
|
||||
builder.Name = event.Item.Name
|
||||
}
|
||||
if event.Item.Arguments != "" && builder.Arguments.Len() == 0 {
|
||||
builder.Arguments.WriteString(event.Item.Arguments)
|
||||
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 !builder.started {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
ToolCallID: key,
|
||||
ToolName: builder.Name,
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
ToolCallID: key,
|
||||
ToolCallProviderID: builder.CallID,
|
||||
ToolName: builder.Name,
|
||||
ToolCallFreeform: builder.Freeform,
|
||||
})
|
||||
builder.started = true
|
||||
}
|
||||
@@ -664,6 +836,7 @@ func (p *CodexProvider) handleOutputItemDone(
|
||||
ToolName: builder.Name,
|
||||
})
|
||||
builder.ended = true
|
||||
state.emitted = true
|
||||
}
|
||||
|
||||
// handleTerminalResponse emits the final usage + done event for a
|
||||
@@ -693,6 +866,7 @@ 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,
|
||||
@@ -700,6 +874,7 @@ 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
|
||||
@@ -735,7 +910,7 @@ func (p *CodexProvider) handleTerminalResponse(
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone, ResponseID: event.Response.ID})
|
||||
state.done = true
|
||||
return false
|
||||
}
|
||||
@@ -754,6 +929,9 @@ 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
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
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,6 +2,8 @@ package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -25,6 +27,126 @@ 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{}
|
||||
|
||||
@@ -90,11 +212,14 @@ 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{Status: "completed"}}, &responsesState{}, ok)
|
||||
&responsesEvent{Type: responsesEventCompleted, Response: &responsePayload{ID: "resp-1", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}}}`,
|
||||
`{"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}}}}`,
|
||||
)
|
||||
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.ReasoningTokens != 1 {
|
||||
t.Fatalf("usage = %+v, want input=7 output=3 cached=2 reasoning=1", *usage)
|
||||
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 !gotDone {
|
||||
t.Fatal("expected a done event, got none")
|
||||
|
||||
@@ -357,6 +357,7 @@ 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,
|
||||
},
|
||||
})
|
||||
@@ -486,7 +487,7 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
Function: toolFunction{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
Parameters: normalizeToolParameters(tool.Parameters),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -494,6 +495,21 @@ 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
|
||||
|
||||
@@ -3,6 +3,7 @@ package openai
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -105,6 +106,72 @@ 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) {
|
||||
@@ -193,14 +260,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}}}`)
|
||||
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, `[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 {
|
||||
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 {
|
||||
t.Fatalf("unexpected usage event: %#v", events[2])
|
||||
}
|
||||
if events[3].Type != zeroruntime.StreamEventDone {
|
||||
@@ -1479,3 +1546,66 @@ 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"])
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,12 @@ func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest)
|
||||
func wireToolsDigest(tools []zeroruntime.ToolDefinition) string {
|
||||
rendered := make([]string, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
schema, err := json.Marshal(tool.Parameters)
|
||||
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)
|
||||
if err != nil {
|
||||
rendered = append(rendered, tool.Name+"\n"+tool.Description+"\n__non_json:"+tool.Name)
|
||||
continue
|
||||
|
||||
@@ -109,7 +109,8 @@ type usage struct {
|
||||
}
|
||||
|
||||
type promptTokenDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
type completionTokenDetails struct {
|
||||
|
||||
@@ -15,9 +15,27 @@ import (
|
||||
// value (e.g. "1") to enable — the same boolean idiom as ZERO_FORMAT_ON_WRITE.
|
||||
const openaiTurnSessionEnv = "ZERO_OPENAI_TURN_SESSION"
|
||||
|
||||
// ChatGPT Responses sessions are enabled by default for their native provider.
|
||||
// Setting this to 0/false/off restores the stateless HTTP/SSE transport.
|
||||
const chatGPTTurnSessionEnv = "ZERO_CHATGPT_TURN_SESSION"
|
||||
|
||||
func openaiTurnSessionEnabled() bool {
|
||||
value := strings.TrimSpace(os.Getenv(openaiTurnSessionEnv))
|
||||
return value != "" && value != "0" && !strings.EqualFold(value, "false")
|
||||
return value != "" && !explicitEnvFalse(value)
|
||||
}
|
||||
|
||||
func chatGPTTurnSessionEnabled() bool {
|
||||
value := strings.TrimSpace(os.Getenv(chatGPTTurnSessionEnv))
|
||||
return value == "" || !explicitEnvFalse(value)
|
||||
}
|
||||
|
||||
func explicitEnvFalse(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "0", "false", "off":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OptimizedTurnSessions returns the gated optimized TurnSessionProvider for an
|
||||
@@ -25,20 +43,33 @@ func openaiTurnSessionEnabled() bool {
|
||||
// is ineligible. Callers leave agent Options.TurnSessionProvider nil on false,
|
||||
// so the loop's default wrap keeps today's behavior byte-identical.
|
||||
//
|
||||
// Eligibility is deliberately narrow: the resolved provider kind must be
|
||||
// official OpenAI (NOT openai-compatible gateways — the constructor merges the
|
||||
// two, so this branches on the resolved kind explicitly), not the ChatGPT
|
||||
// Codex catalog, and the provider value must be the concrete *openai.Provider
|
||||
// (a fake, a Codex provider, or nil falls back to the default path safely).
|
||||
// No base-URL check on top of the kind: prewarm and fingerprint telemetry are
|
||||
// harmless against any host, and kind==openai mirrors the existing
|
||||
// official-OpenAI precedent used for prompt_cache_key.
|
||||
// Eligibility is deliberately narrow: native ChatGPT Responses uses its
|
||||
// concrete Codex provider and defaults on with a kill switch; official OpenAI
|
||||
// chat-completions keeps its existing opt-in prewarm session. Compatible
|
||||
// gateways and foreign provider values always retain the default adapter.
|
||||
func OptimizedTurnSessions(profile config.ProviderProfile, provider zeroruntime.Provider, options Options) (zeroruntime.TurnSessionProvider, bool) {
|
||||
if !openaiTurnSessionEnabled() {
|
||||
resolved, err := resolveProfile(profile, options)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
resolved, err := resolveProfile(profile, options)
|
||||
if err != nil || resolved.providerKind != config.ProviderKindOpenAI || isCodexCatalog(profile, resolved) {
|
||||
if isCodexCatalog(profile, resolved) {
|
||||
if !chatGPTTurnSessionEnabled() {
|
||||
return nil, false
|
||||
}
|
||||
concrete, ok := provider.(*openai.CodexProvider)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
caps, err := resolveCapabilities(profile, options)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return openai.NewCodexTurnSessionProvider(concrete, caps), true
|
||||
}
|
||||
if resolved.providerKind != config.ProviderKindOpenAI {
|
||||
return nil, false
|
||||
}
|
||||
if !openaiTurnSessionEnabled() {
|
||||
return nil, false
|
||||
}
|
||||
concrete, ok := provider.(*openai.Provider)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providers/openai"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -45,7 +46,7 @@ func TestOptimizedTurnSessionsDefaultOff(t *testing.T) {
|
||||
func TestOptimizedTurnSessionsFalseyValues(t *testing.T) {
|
||||
profile := openaiEligibleProfile()
|
||||
provider := buildGateProvider(t, profile)
|
||||
for _, value := range []string{"0", "false", "FALSE", "False", " ", ""} {
|
||||
for _, value := range []string{"0", "false", "FALSE", "False", "off", "OFF", " ", ""} {
|
||||
t.Setenv(openaiTurnSessionEnv, value)
|
||||
if _, ok := OptimizedTurnSessions(profile, provider, Options{}); ok {
|
||||
t.Fatalf("gate enabled for falsey value %q", value)
|
||||
@@ -59,6 +60,21 @@ func TestOptimizedTurnSessionsFalseyValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatGPTTurnSessionGateValues(t *testing.T) {
|
||||
for _, value := range []string{"0", "false", "FALSE", "off", "OFF"} {
|
||||
t.Setenv(chatGPTTurnSessionEnv, value)
|
||||
if chatGPTTurnSessionEnabled() {
|
||||
t.Fatalf("ChatGPT gate enabled for falsey value %q", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", " ", "1", "true", "on"} {
|
||||
t.Setenv(chatGPTTurnSessionEnv, value)
|
||||
if !chatGPTTurnSessionEnabled() {
|
||||
t.Fatalf("ChatGPT gate disabled for default/truthy value %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimizedTurnSessionsOpenAIEligible(t *testing.T) {
|
||||
t.Setenv(openaiTurnSessionEnv, "1")
|
||||
profile := openaiEligibleProfile()
|
||||
@@ -88,13 +104,64 @@ func TestOptimizedTurnSessionsRejectsCompatible(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOptimizedTurnSessionsRejectsCodexCatalog(t *testing.T) {
|
||||
t.Setenv(openaiTurnSessionEnv, "1")
|
||||
t.Setenv(chatGPTTurnSessionEnv, "0")
|
||||
profile := openaiEligibleProfile()
|
||||
profile.Name = "chatgpt"
|
||||
profile.CatalogID = "chatgpt"
|
||||
if _, ok := OptimizedTurnSessions(profile, buildChatGPTGateProvider(t, profile), Options{}); ok {
|
||||
t.Fatal("the ChatGPT Codex catalog must honor the turn-session kill switch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimizedTurnSessionsEnablesChatGPTResponsesByDefault(t *testing.T) {
|
||||
t.Setenv(chatGPTTurnSessionEnv, "")
|
||||
profile := openaiEligibleProfile()
|
||||
profile.Name = "chatgpt"
|
||||
profile.CatalogID = "chatgpt"
|
||||
provider := buildChatGPTGateProvider(t, profile)
|
||||
|
||||
turnSessions, ok := OptimizedTurnSessions(profile, provider, Options{})
|
||||
if !ok || turnSessions == nil {
|
||||
t.Fatal("ChatGPT Responses provider did not receive its default turn session")
|
||||
}
|
||||
if got := turnSessions.Capabilities().Model; got != profile.Model {
|
||||
t.Fatalf("Capabilities().Model = %q, want %q", got, profile.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimizedTurnSessionsRecognizesCompatibleLabeledChatGPTProfile(t *testing.T) {
|
||||
t.Setenv(chatGPTTurnSessionEnv, "")
|
||||
profile := openaiEligibleProfile()
|
||||
profile.Name = "chatgpt"
|
||||
profile.CatalogID = "chatgpt"
|
||||
profile.ProviderKind = config.ProviderKindOpenAICompatible
|
||||
if turnSessions, ok := OptimizedTurnSessions(profile, buildChatGPTGateProvider(t, profile), Options{}); !ok || turnSessions == nil {
|
||||
t.Fatal("ChatGPT catalog profile must use its native session regardless of the stored compatibility label")
|
||||
}
|
||||
}
|
||||
|
||||
func buildChatGPTGateProvider(t *testing.T, profile config.ProviderProfile) *openai.CodexProvider {
|
||||
t.Helper()
|
||||
provider, err := openai.NewCodexProvider(openai.CodexOptions{
|
||||
Options: openai.Options{
|
||||
APIKey: "test-token",
|
||||
BaseURL: "https://chatgpt.example/backend-api/codex",
|
||||
Model: profile.Model,
|
||||
},
|
||||
AccountID: "account-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func TestOptimizedTurnSessionsRejectsNonCodexProviderForChatGPT(t *testing.T) {
|
||||
t.Setenv(chatGPTTurnSessionEnv, "1")
|
||||
profile := openaiEligibleProfile()
|
||||
profile.CatalogID = "chatgpt"
|
||||
// Build the provider from the base profile: the point under test is the
|
||||
// catalog check, not Codex construction (which needs OAuth wiring).
|
||||
if _, ok := OptimizedTurnSessions(profile, buildGateProvider(t, openaiEligibleProfile()), Options{}); ok {
|
||||
t.Fatal("the ChatGPT Codex catalog must not get the optimized session")
|
||||
t.Fatal("a ChatGPT catalog profile with a non-Codex provider must use the default session")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
type BackendOptions struct {
|
||||
GOOS string
|
||||
LookupExecutable func(string) (string, error)
|
||||
DetectWSL func() WSLInfo
|
||||
}
|
||||
|
||||
type Backend struct {
|
||||
@@ -53,6 +54,7 @@ func SelectBackend(options BackendOptions) Backend {
|
||||
return NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: options.GOOS,
|
||||
LookupExecutable: options.LookupExecutable,
|
||||
DetectWSL: options.DetectWSL,
|
||||
}).Backend()
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
DetectWSL: func() WSLInfo { return WSLInfo{} },
|
||||
})
|
||||
if backend.Name != BackendUnavailable || backend.Available {
|
||||
t.Fatalf("linux backend = %#v, want native sandbox unavailable without Linux helper", backend)
|
||||
@@ -54,6 +55,20 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("linux helper missing under WSL reports WSL fallback", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "linux",
|
||||
LookupExecutable: func(string) (string, error) { return "", errors.New("missing") },
|
||||
DetectWSL: func() WSLInfo { return WSLInfo{IsWSL: true, IsWSL2: true} },
|
||||
})
|
||||
if backend.Name != BackendWSL || backend.Available || !backend.Fallback {
|
||||
t.Fatalf("linux backend = %#v, want explicit WSL fallback", backend)
|
||||
}
|
||||
if !strings.Contains(backend.Message, "WSL2") {
|
||||
t.Fatalf("linux fallback message = %q, want WSL2 diagnostic", backend.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("darwin sandbox exec available", func(t *testing.T) {
|
||||
backend := SelectBackend(BackendOptions{
|
||||
GOOS: "darwin",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyPatchPathBlockOnlyRejectsRelativeTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
inside := filepath.Join(root, "main.js")
|
||||
structured := func(path string) string {
|
||||
return strings.Join([]string{"*** Begin Patch", "*** Update File: " + path, "@@", "-a", "+b", "*** End Patch"}, "\n")
|
||||
}
|
||||
for name, patch := range map[string]string{
|
||||
"absolute inside workspace": structured(inside),
|
||||
"relative": structured("main.js"),
|
||||
"decorated markers": "*** Begin Patch ***\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch ***",
|
||||
"no-space marker": "***Begin Patch\n*** Update File: main.js\n@@\n-a\n+b\n***End Patch",
|
||||
} {
|
||||
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
|
||||
if block := applyPatchPathBlock(request); block != nil {
|
||||
t.Fatalf("%s: unexpected block %+v", name, block)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"../escape.js", ".."} {
|
||||
for name, patch := range map[string]string{"canonical": structured(path), "no-space": "***Begin Patch\n*** Update File: " + path + "\n@@\n-a\n+b\n***End Patch"} {
|
||||
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
|
||||
block := applyPatchPathBlock(request)
|
||||
if block == nil || block.Code != BlockOutsideWorkspace {
|
||||
t.Fatalf("%s %q must be blocked as traversal, got %+v", name, path, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every marker spelling the tool applies must be classified as structured at
|
||||
// the sandbox boundary too; otherwise the boundary scans the patch as a unified
|
||||
// diff, extracts no targets, and validates nothing (fail-open).
|
||||
func TestStructuredPatchClassifierMatchesToolSpellings(t *testing.T) {
|
||||
for _, header := range []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "\ufeff*** Begin Patch"} {
|
||||
patch := header + "\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch"
|
||||
if !IsStructuredPatch(patch) {
|
||||
t.Fatalf("%q must classify as a structured patch", header)
|
||||
}
|
||||
if paths := applyPatchPaths(patch); len(paths) != 1 || paths[0] != "main.js" {
|
||||
t.Fatalf("%q: sandbox must extract the structured target, got %v", header, paths)
|
||||
}
|
||||
}
|
||||
for _, header := range []string{"--- a/x", "Begin Patch", "*** Begin Patchwork", "*** Update File: x"} {
|
||||
if IsStructuredPatch(header + "\n-a\n+b") {
|
||||
t.Fatalf("%q must not classify as a structured patch", header)
|
||||
}
|
||||
}
|
||||
if StructuredPatchMarker("*** End Patch ***") != "end" || StructuredPatchMarker("***End Patch") != "end" {
|
||||
t.Fatal("decorated end markers must classify as end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchRequestPathsCarryAbsolutePathsToScopeValidation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// NewScope also grants the system temp dir, so a sibling t.TempDir() is
|
||||
// legitimately in scope; pick a path under the filesystem root instead.
|
||||
outside, err := filepath.Abs(filepath.Join(string(filepath.Separator), "zero-outside-workspace-test", "escape.js"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope, err := NewScope(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inside := filepath.Join(root, "main.js")
|
||||
structured := func(header, footer, path string) string {
|
||||
return strings.Join([]string{header, "*** Update File: " + path, "@@", "-a", "+b", footer}, "\n")
|
||||
}
|
||||
for name, spelling := range map[string][2]string{"canonical": {"*** Begin Patch", "*** End Patch"}, "no-space": {"***Begin Patch", "***End Patch"}} {
|
||||
// Failure path: the exact path the boundary parsed must be denied by the
|
||||
// scope. structuredPatchHeaderPaths normalises separators to "/", so the
|
||||
// parsed form is compared in slash form and then validated as-is.
|
||||
paths := applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], outside)})
|
||||
if len(paths) != 1 || paths[0] != filepath.ToSlash(outside) {
|
||||
t.Fatalf("%s: absolute patch path must reach scope validation unchanged, got %v", name, paths)
|
||||
}
|
||||
if block := scope.validate(paths[0]); block == nil || block.Code != BlockOutsideWorkspace {
|
||||
t.Fatalf("%s: scope must deny the parsed outside path %q, got %+v", name, paths[0], block)
|
||||
}
|
||||
// Success path: the parsed inside path must be accepted by the scope.
|
||||
paths = applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], inside)})
|
||||
if len(paths) != 1 || paths[0] != filepath.ToSlash(inside) {
|
||||
t.Fatalf("%s: inside patch path must reach scope validation unchanged, got %v", name, paths)
|
||||
}
|
||||
if block := scope.validate(paths[0]); block != nil {
|
||||
t.Fatalf("%s: scope must accept the parsed inside path %q, got %+v", name, paths[0], block)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,8 +148,10 @@ func unsafeCommandPrefix(prefix []string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalized := append([]string(nil), prefix...)
|
||||
normalized[0] = normalizeLauncherName(normalized[0])
|
||||
for _, banned := range bannedCommandPrefixSuggestions {
|
||||
if sameStringSlice(prefix, banned) {
|
||||
if sameStringSlice(prefix, banned) || sameStringSlice(normalized, banned) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -165,25 +167,118 @@ func unsafeCommandPrefixPart(part string) bool {
|
||||
}
|
||||
|
||||
func unsafeCommandPrefixLauncher(program string) bool {
|
||||
program = strings.ToLower(strings.TrimSpace(program))
|
||||
if strings.ContainsAny(program, `/\`) {
|
||||
// A path is already refused. ":" and "~" are refused for the same reason:
|
||||
// on Windows they introduce names that resolve to a different file than the
|
||||
// one matched here, and no list can keep up with them. "PYTHON~1.EXE" is
|
||||
// python.exe under an 8.3 short name, and 8.3 truncates the stem, so
|
||||
// "POWERS~1.EXE" cannot be recognized by name at all; "python.exe::$DATA"
|
||||
// names the same executable through its default stream. Refusing the shape
|
||||
// costs a permission prompt on names no real command uses.
|
||||
if strings.ContainsAny(program, `/\:~`) {
|
||||
return true
|
||||
}
|
||||
switch program {
|
||||
case "bash", "sh", "zsh", "/bin/bash", "/bin/zsh",
|
||||
"pwsh", "powershell", "powershell.exe",
|
||||
"env", "sudo", "doas", "su", "run0", "osascript",
|
||||
name := normalizeLauncherName(program)
|
||||
return bannedLauncherName(name) || bannedLauncherName(strings.TrimRight(name, "0123456789"))
|
||||
}
|
||||
|
||||
func bannedLauncherName(name string) bool {
|
||||
switch name {
|
||||
case "bash", "sh", "zsh", "dash", "ash", "ksh", "csh", "tcsh", "fish", "busybox",
|
||||
"pwsh", "powershell", "cmd", "wsl",
|
||||
"env", "sudo", "sudoedit", "doas", "su", "run0", "osascript",
|
||||
"command", "eval", "exec", "time",
|
||||
"find", "xargs", "timeout", "nice", "nohup", "watch", "setsid", "stdbuf", "ionice",
|
||||
"ssh", "make", "npm", "npx",
|
||||
"python", "python3", "py", "pythonw", "pyw", "pypy", "pypy3",
|
||||
"node", "perl", "ruby", "php", "lua", "deno", "bun":
|
||||
"ssh", "make", "npm", "npx", "pnpm", "yarn", "bunx",
|
||||
"python", "py", "pythonw", "pyw", "pypy", "uv", "uvx",
|
||||
"node", "nodejs", "perl", "ruby", "php", "lua", "deno", "bun":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeLauncherName reduces a program name to the launcher it actually runs
|
||||
// so the list above cannot be stepped around by spelling the same interpreter
|
||||
// differently: "python3.11", "python.exe" and "PYTHON3" all reach it as a name
|
||||
// the list matches. internal/agent's commandName already normalizes case and
|
||||
// Windows executable extensions on the allow side; this side matched raw, so
|
||||
// every versioned or .exe-suffixed launcher validated as an ordinary command
|
||||
// and persisted as a prefix grant that auto-allows every later invocation.
|
||||
//
|
||||
// A name is only ever narrowed toward an existing entry, so the failure
|
||||
// direction is an extra permission prompt, never a silent grant.
|
||||
func normalizeLauncherName(program string) string {
|
||||
name := strings.ToLower(strings.TrimSpace(program))
|
||||
// Windows discards trailing dots and spaces when resolving a filename, so
|
||||
// "python." starts python.exe. Drop them before anything matches on the
|
||||
// result, on every platform: the deny side must not depend on which OS is
|
||||
// reading the grant, since the grants file travels with a synced home
|
||||
// directory.
|
||||
name = strings.TrimRight(name, ". ")
|
||||
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com", ".ps1"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
name = strings.TrimSuffix(name, suffix)
|
||||
name = strings.TrimRight(name, ". ")
|
||||
break
|
||||
}
|
||||
}
|
||||
return trimLauncherVersion(trimLauncherABISuffix(trimLauncherBuildSuffix(name)))
|
||||
}
|
||||
|
||||
// trimLauncherBuildSuffix drops the build-channel suffixes distributions and
|
||||
// upstreams append to an otherwise unchanged interpreter, so "python3.11-dbg",
|
||||
// "bash-static" and "pwsh-preview" are the launchers they say they are. The set
|
||||
// is closed on purpose: a general "-word" strip would also swallow
|
||||
// "node-gyp", "python3-config" and "ruby-lsp", which are ordinary tools a user
|
||||
// should still be able to approve.
|
||||
func trimLauncherBuildSuffix(name string) string {
|
||||
for _, suffix := range []string{"-dbg", "-debug", "-static", "-nightly", "-preview", "-beta"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// trimLauncherABISuffix drops CPython's ABI flags from a versioned interpreter
|
||||
// name, so "python3.7m" and "python3.6dm" reduce to their version, and the
|
||||
// free-threaded builds "python3.13t" and "python3.13td" reduce to theirs. The
|
||||
// flags are only removed when a digit sits underneath them, which leaves
|
||||
// ordinary names that happen to end in those letters alone: "sha256sum" keeps
|
||||
// its "sum", "zstd" its "td", "cat" its "t".
|
||||
func trimLauncherABISuffix(name string) string {
|
||||
trimmed := strings.TrimRight(name, "dmut")
|
||||
if trimmed == name || trimmed == "" {
|
||||
return name
|
||||
}
|
||||
if last := trimmed[len(trimmed)-1]; last < '0' || last > '9' {
|
||||
return name
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// trimLauncherVersion drops a trailing "<separator><digits>" version from a
|
||||
// program name, so "python3.11" becomes "python3" and "python2.7" becomes
|
||||
// "python2". Digits without a separator are part of the name, which keeps
|
||||
// "base64", "7z" and "sha256sum" intact; the caller handles the "python3" shape
|
||||
// by also testing the digit-stripped form.
|
||||
func trimLauncherVersion(name string) string {
|
||||
for {
|
||||
trimmed := strings.TrimRight(name, "0123456789")
|
||||
if trimmed == name || trimmed == "" {
|
||||
return name
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, ".") && !strings.HasSuffix(trimmed, "-") {
|
||||
return name
|
||||
}
|
||||
next := trimmed[:len(trimmed)-1]
|
||||
if next == "" {
|
||||
return name
|
||||
}
|
||||
name = next
|
||||
}
|
||||
}
|
||||
|
||||
func sameStringSlice(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUnsafeCommandPrefixLauncherIgnoresVersionAndExtension(t *testing.T) {
|
||||
for _, program := range []string{
|
||||
"python3", "python", "node", "git", "sh", "bash", "sudo",
|
||||
"python3.11", "python3.12", "python2.7", "python3.13.1",
|
||||
"python.exe", "python3.exe", "node.exe", "git.exe", "bash.exe",
|
||||
"PYTHON3.11", "Node.EXE",
|
||||
"cmd", "cmd.exe", "wsl", "wsl.exe",
|
||||
// Windows resolves these to the same executable.
|
||||
"python.", "python3.", "python3.11.", "python.exe.", "PYTHON.",
|
||||
"python.cmd", "python.bat", "python.com", "python.ps1",
|
||||
"PYTHON~1.EXE", "POWERS~1.EXE", "python.exe::$DATA",
|
||||
// Versioned and ABI-suffixed interpreters.
|
||||
"python3.7m", "python3.6dm", "pythonw3.11", "python-3.11",
|
||||
// Free-threaded CPython (PEP 703) ships beside the GIL build.
|
||||
"python3.13t", "python3.13t.exe", "python3.14t", "python3.13td",
|
||||
// Distribution and build-channel spellings of the same interpreter.
|
||||
"nodejs", "nodejs.exe", "node-nightly", "python3-dbg", "python3.11-dbg",
|
||||
"python3.13t-dbg", "python3-debug", "python3.11-debug", "node-beta",
|
||||
"pwsh-preview", "bash-static", "sudoedit",
|
||||
// Twins of npm and npx, which the list already refuses.
|
||||
"pnpm", "yarn", "bunx", "pnpm.cmd", "yarn.exe",
|
||||
"perl5.36", "ruby3.2", "php8", "lua5.4", "node22.exe", "bash5",
|
||||
} {
|
||||
if !unsafeCommandPrefix([]string{program}) {
|
||||
t.Errorf("unsafeCommandPrefix([%q]) = false, want true", program)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsafeCommandPrefixKeepsOrdinaryCommandsGrantable(t *testing.T) {
|
||||
for _, prefix := range [][]string{
|
||||
{"cargo", "build"},
|
||||
{"go", "test"},
|
||||
{"ls", "-la"},
|
||||
{"rg", "--json"},
|
||||
{"7z", "l"},
|
||||
{"base64", "-d"},
|
||||
{"kubectl", "get", "pods"},
|
||||
{"docker", "ps"},
|
||||
{"sha256sum", "file"},
|
||||
{"gcc-13", "-c"},
|
||||
{"node-gyp", "build"},
|
||||
{"python3-config", "--includes"},
|
||||
{"s3cmd", "ls"},
|
||||
{"cat", "file"},
|
||||
{"zstd", "-d"},
|
||||
{"sqlite3", "db"},
|
||||
{"yt-dlp", "url"},
|
||||
{"perl-doc", "-h"},
|
||||
{"php-fpm", "-t"},
|
||||
{"ruby-lsp", "--version"},
|
||||
{"lua-language-server", "--check"},
|
||||
{"nodemon", "app.js"},
|
||||
} {
|
||||
if unsafeCommandPrefix(prefix) {
|
||||
t.Errorf("unsafeCommandPrefix(%q) = true, want false", prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestNormalizeLauncherName(t *testing.T) {
|
||||
for _, testCase := range []struct{ in, want string }{
|
||||
{"python3.11", "python3"},
|
||||
{"python2.7", "python2"},
|
||||
{"pypy3", "pypy3"},
|
||||
{"node.exe", "node"},
|
||||
{"POWERSHELL.EXE", "powershell"},
|
||||
{"7z", "7z"},
|
||||
{"base64", "base64"},
|
||||
{"sha256sum", "sha256sum"},
|
||||
{"python.", "python"},
|
||||
{"python3.11.exe.", "python3"},
|
||||
{"python3.7m", "python3"},
|
||||
{"python3.6dm", "python3"},
|
||||
{"python3.13t", "python3"},
|
||||
{"python3.13td", "python3"},
|
||||
{"python3.11-dbg", "python3"},
|
||||
{"node-nightly", "node"},
|
||||
{"node-gyp", "node-gyp"},
|
||||
{"python3-config", "python3-config"},
|
||||
{"zstd", "zstd"},
|
||||
{"cat", "cat"},
|
||||
{"cargo", "cargo"},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := normalizeLauncherName(testCase.in); got != testCase.want {
|
||||
t.Errorf("normalizeLauncherName(%q) = %q, want %q", testCase.in, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session-scoped grants are the agent's other entry point (loop.go grants them
|
||||
// for a single turn without touching the store), so they must refuse the same
|
||||
// launcher spellings the persisted path does.
|
||||
func TestGrantCommandPrefixForSessionRefusesLauncherVariants(t *testing.T) {
|
||||
engine := &Engine{commandPrefixes: newCommandPrefixGrantSet()}
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"cargo", "build"})
|
||||
if _, ok := engine.LookupCommandPrefixForSession("bash", []string{"cargo", "build", "--release"}); !ok {
|
||||
t.Fatal("CONTROL BROKEN: an ordinary session prefix grant did not match")
|
||||
}
|
||||
for _, prefix := range [][]string{
|
||||
{"python3.11", "-c"},
|
||||
{"python.exe", "-c"},
|
||||
{"cmd", "/c"},
|
||||
} {
|
||||
engine.GrantCommandPrefixForSession("bash", prefix)
|
||||
command := append(append([]string(nil), prefix...), "whatever")
|
||||
if _, ok := engine.LookupCommandPrefixForSession("bash", command); ok {
|
||||
t.Errorf("session grant %q matched %q, want refused", prefix, command)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user