Compare commits

..
Author SHA1 Message Date
anandh8x c6b805af1f fix(tools): reject mixed-root freeform patches 2026-08-26 01:18:34 +05:30
anandh8x 5c5d0bf8d0 Merge main into feat/runtime-performance 2026-08-25 21:04:28 +05:30
anandh8x 94ff28f5cb test: keep patch fixtures out of worktree 2026-08-25 11:43:40 +05:30
anandh8x a31b8ea28d fix: bind freeform patch writes to scoped roots 2026-08-25 11:43:40 +05:30
anandh8x 8081d0e194 fix: keep run tools and patch roots consistent 2026-08-25 11:43:39 +05:30
anandh8x e4f922af5e fix: harden turn session regressions 2026-08-25 11:43:39 +05:30
anandh8x 69958aa320 fix(cli): redact MCP registration errors 2026-08-25 11:43:39 +05:30
anandh8x c77557cfaa build: restore official Bubble Tea dependency 2026-08-25 11:43:39 +05:30
anandh8x 2b582f1086 fix: address runtime review findings 2026-08-25 11:43:39 +05:30
anandh8x 70d53c1f57 fix: harden runtime review edge cases 2026-08-25 11:43:39 +05:30
anandh8x 6a7c689155 perf(agent): reduce redundant bounded-task turns 2026-08-25 11:43:39 +05:30
anandh8x 3e4ee001d7 fix(usage): expose cache efficiency details 2026-08-25 11:43:39 +05:30
anandh8x edb6db2070 fix(runtime): address performance review findings 2026-08-25 11:43:39 +05:30
anandh8x 8aa81b7139 perf(tui): render only on screen changes 2026-08-25 11:43:39 +05:30
anandh8x f683c3cdb5 fix(provider): recognize saved chat sessions 2026-08-25 11:43:39 +05:30
anandh8x 54a9ab2371 feat(provider): add native patch tool input 2026-08-25 11:43:39 +05:30
anandh8x b0947b7d8f perf(provider): reuse compatible response sessions 2026-08-25 11:43:39 +05:30
anandh8x c3ae5c4835 perf(startup): defer optional tool discovery 2026-08-25 11:43:39 +05:30
anandh8x e187290b39 refactor(tools): publish immutable registry snapshots 2026-08-25 11:43:39 +05:30
anandh8x a15ce26333 perf(tui): stop composer wakeups after idle 2026-08-25 11:43:39 +05:30
anandh8x 496637bea3 perf(tui): disable duplicate composer cursor blink 2026-08-25 11:43:39 +05:30
66 changed files with 1246 additions and 6389 deletions

No files matched your search

+6 -52
View File
@@ -14,7 +14,6 @@ import (
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -31,12 +30,10 @@ type Deps struct {
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
// BuildWorkspace creates the per-turn scoped tool workspace for a validated
// workspace root. Its Close method releases any resources the registry owns
// (notably MCP server connections) after the turn completes or is cancelled.
// Shell tools remain confined exactly like the exec surface — never unconfined
// on the host.
BuildWorkspace func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*Workspace, error)
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
// exactly like the exec surface — never run unconfined on the host.
BuildWorkspace func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error)
// ResolveWorkspaceRoot validates + normalizes a client-supplied cwd (must be an
// existing directory; never the bare root). It is the file-tool confinement root.
ResolveWorkspaceRoot func(cwd string) (string, error)
@@ -44,27 +41,6 @@ type Deps struct {
AgentInfo Implementation
}
// Workspace is the per-turn execution environment passed to the agent. ACP does
// not retain it across turns because MCP connections belong to the registry that
// advertised their tools; keeping a stale registry after a cancelled turn would
// leak its server process and its permission state into the next turn.
type Workspace struct {
Registry *tools.Registry
Sandbox *sandbox.Engine
DeferThreshold int
Notices []string
Cleanup func() error
}
// Close releases resources created with the workspace. A nil cleanup is valid
// for core-only workspaces.
func (w *Workspace) Close() error {
if w == nil || w.Cleanup == nil {
return nil
}
return w.Cleanup()
}
// Agent is the ACP agent server bound to one JSON-RPC connection (one editor).
type Agent struct {
conn *Conn
@@ -265,34 +241,13 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
if err != nil {
return "", RPCError(codeInternalError, "provider: "+err.Error())
}
mode := sess.currentMode()
// Build the SCOPED registry + sandbox engine for this session's workspace so
// shell/file tools are confined to the workspace exactly like the exec surface.
// The workspace can also own MCP connections, which must be closed after every
// turn even if the agent run returns an error or the client cancels it.
workspace, err := a.deps.BuildWorkspace(ctx, sess.cwd, resolved, mode)
registry, sandboxEngine, err := a.deps.BuildWorkspace(sess.cwd, resolved)
if err != nil {
return "", RPCError(codeInternalError, "workspace: "+err.Error())
}
if workspace == nil || workspace.Registry == nil {
if workspace != nil {
_ = workspace.Close()
}
return "", RPCError(codeInternalError, "workspace: missing tool registry")
}
defer func() {
if err := workspace.Close(); err != nil {
log.Printf("acp: close workspace: %v", err)
}
}()
registry := workspace.Registry
sandboxEngine := workspace.Sandbox
note := &notifier{conn: a.conn, sessionID: sess.id}
for _, notice := range workspace.Notices {
if text := strings.TrimSpace(redaction.RedactString(notice, redaction.Options{})); text != "" {
note.text("\n\n[zero warning] " + text + "\n")
}
}
opts := agent.Options{
Cwd: sess.cwd,
@@ -301,8 +256,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
Model: resolved.Provider.Model,
Registry: registry,
Sandbox: sandboxEngine,
PermissionMode: mode,
DeferThreshold: workspace.DeferThreshold,
PermissionMode: sess.currentMode(),
MaxTurns: resolved.MaxTurns,
Images: images,
OnText: note.text,
+4 -152
View File
@@ -8,7 +8,6 @@ import (
"io"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -36,35 +35,6 @@ func (f fakeProvider) StreamCompletion(_ context.Context, _ zeroruntime.Completi
return ch, nil
}
type acpDeferredPromptTool struct{ name string }
func (t acpDeferredPromptTool) Name() string { return t.name }
func (acpDeferredPromptTool) Description() string { return "deferred MCP test tool" }
func (acpDeferredPromptTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (acpDeferredPromptTool) Deferred() bool { return true }
func (acpDeferredPromptTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (acpDeferredPromptTool) Safety() tools.Safety {
return tools.Safety{
SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionPrompt,
AdvertiseInAuto: true,
}
}
type captureToolsProvider struct {
requests chan zeroruntime.CompletionRequest
}
func (p captureToolsProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
p.requests <- request
ch := make(chan zeroruntime.StreamEvent, 2)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func testDeps(t *testing.T) Deps {
t.Helper()
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
@@ -83,10 +53,10 @@ func testDeps(t *testing.T) Deps {
return fakeProvider{text: "Hello from ZERO"}, nil
},
RunAgent: agent.Run,
BuildWorkspace: func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
BuildWorkspace: func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
r := tools.NewRegistry()
r.Register(tools.NewUpdatePlanTool())
return &Workspace{Registry: r}, nil
return r, nil, nil
},
ResolveWorkspaceRoot: func(cwd string) (string, error) { return cwd, nil },
Store: store,
@@ -498,8 +468,8 @@ func TestACPRunTurnWiresSandboxAndScopedRegistry(t *testing.T) {
reg := tools.NewRegistry()
reg.Register(tools.NewUpdatePlanTool())
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
deps.BuildWorkspace = func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
return &Workspace{Registry: reg, Sandbox: engine}, nil
deps.BuildWorkspace = func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
return reg, engine, nil
}
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
@@ -527,124 +497,6 @@ func TestACPRunTurnWiresSandboxAndScopedRegistry(t *testing.T) {
}
}
func TestACPRunTurnUsesWorkspaceDeferThresholdAtProviderBoundary(t *testing.T) {
for _, tc := range []struct {
name string
threshold int
want []string
}{
{name: "explicit zero disables", threshold: 0, want: []string{"mcp_docs_alpha", "mcp_docs_beta"}},
{name: "below threshold stays eager", threshold: 3, want: []string{"mcp_docs_alpha", "mcp_docs_beta"}},
{name: "at threshold defers", threshold: 2, want: []string{tools.ToolSearchToolName}},
} {
t.Run(tc.name, func(t *testing.T) {
requests := make(chan zeroruntime.CompletionRequest, 4)
deps := testDeps(t)
deps.NewProvider = func(config.ProviderProfile) (zeroruntime.Provider, error) {
return captureToolsProvider{requests: requests}, nil
}
deps.BuildWorkspace = func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
registry := tools.NewRegistry()
registry.Register(acpDeferredPromptTool{name: "mcp_docs_alpha"})
registry.Register(acpDeferredPromptTool{name: "mcp_docs_beta"})
registry.Register(tools.NewToolSearchTool(registry))
return &Workspace{Registry: registry, DeferThreshold: tc.threshold}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("hi")}}, &PromptResult{}); err != nil {
t.Fatal(err)
}
request := <-requests
got := make([]string, 0, len(request.Tools))
for _, definition := range request.Tools {
got = append(got, definition.Name)
}
if !slices.Equal(got, tc.want) {
t.Fatalf("provider tools = %#v, want %#v", got, tc.want)
}
})
}
}
func TestACPRunTurnEmitsWorkspaceSetupNotices(t *testing.T) {
deps := testDeps(t)
deps.BuildWorkspace = func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
return &Workspace{Registry: registry, Notices: []string{"MCP server docs unavailable, skipped: timeout"}}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("hi")}}, &PromptResult{}); err != nil {
t.Fatal(err)
}
got := drainText(t, h.updates)
if !strings.Contains(got, "[zero warning] MCP server docs unavailable, skipped: timeout") || !strings.Contains(got, "Hello from ZERO") {
t.Fatalf("streamed text = %q", got)
}
}
// TestACPRunTurnClosesWorkspace proves a per-turn workspace never outlives the
// agent run. This is particularly important for MCP: its registry owns live
// client connections and, for stdio servers, child processes.
func TestACPRunTurnClosesWorkspaceAfterSuccessAndFailure(t *testing.T) {
for _, tc := range []struct {
name string
runErr error
}{
{name: "success"},
{name: "agent failure", runErr: errors.New("provider interrupted")},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
closed := 0
deps.BuildWorkspace = func(context.Context, string, config.ResolvedConfig, agent.PermissionMode) (*Workspace, error) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
return &Workspace{Registry: registry, Cleanup: func() error {
closed++
return nil
}}, nil
}
deps.RunAgent = func(context.Context, string, zeroruntime.Provider, agent.Options) (agent.Result, error) {
return agent.Result{FinalAnswer: "ok"}, tc.runErr
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("hello")}}, &PromptResult{})
if tc.runErr == nil && err != nil {
t.Fatalf("session/prompt: %v", err)
}
if tc.runErr != nil && err == nil {
t.Fatal("session/prompt succeeded after agent failure")
}
if closed != 1 {
t.Fatalf("workspace cleanup calls = %d, want 1", closed)
}
})
}
}
// TestACPRejectsInvalidCwd confirms session/new fails when the workspace root
// resolver rejects the client cwd (e.g. filesystem root).
func TestACPRejectsInvalidCwd(t *testing.T) {
+11 -172
View File
@@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -398,27 +396,6 @@ type compactionState struct {
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
// (Options.Summarizer) for the current main model. summarizerProvider
// memoizes the built provider and summarizerResolved records that the
// factory ran (it may legitimately return no provider: the main model is
// already the cheap one). summarizerBroken is the sticky fallback: once a
// build or call fails, the run stays on the main provider until the next
// model switch — a misconfigured cheap model must never break (or
// repeatedly delay) compaction.
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
summarizerProvider Provider
summarizerResolved bool
summarizerBroken bool
// window is the context window the threshold was derived from. The
// reactive free-prune stage compares against it (the limit the provider
// enforces), not against the 70% proactive threshold — a pruned history
// between the two would still succeed on retry.
window int
// mainModel is the model the run currently streams on; the summarizer
// choice is made against it and remade after a mid-run switch.
mainModel string
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
@@ -453,88 +430,17 @@ func (state *compactionState) calibratedTokens(raw int) int {
func newCompactionState(options Options, task *taskState) *compactionState {
state := &compactionState{
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
window: options.ContextWindow,
mainModel: options.Model,
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
summarizerFactory: options.Summarizer,
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
}
return state
}
// updateWindow re-derives the compaction threshold after a mid-run model
// switch. The low-water mark is reset: it was measured against the old
// threshold, and keeping it could suppress a compaction the smaller window
// now requires. <= 0 (unknown model) leaves everything unchanged.
func (state *compactionState) updateWindow(window int) {
if window <= 0 {
return
}
state.enabled = true
state.window = window
state.threshold = compactionThreshold(window)
state.lowWaterMark = 0
if state.planner != nil {
state.planner.config.contextWindow = window
}
}
// switchModel records a mid-run model switch: the window is re-derived
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
// the next compaction re-resolves it against the new main model. A run that
// started on the cheap model (no dedicated summarizer) and escalated to an
// expensive one therefore regains cheap summaries — the exact workflow a
// dedicated summarizer exists for.
func (state *compactionState) switchModel(modelID string, window int) {
state.mainModel = modelID
state.updateWindow(window)
state.summarizerProvider = nil
state.summarizerResolved = false
state.summarizerBroken = false
}
// summarizeProvider returns the provider summarization calls should use and
// whether it is the dedicated summarizer: that one when it applies to the
// current main model and is healthy, else the main provider. The factory runs
// at most once per main model; a build error marks the summarizer broken for
// that model. Callers branch on the boolean rather than comparing Provider
// values — interface comparison panics on a non-comparable dynamic type.
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
if state.summarizerFactory == nil || state.summarizerBroken {
return main, false
}
if !state.summarizerResolved {
state.summarizerResolved = true
built, err := state.summarizerFactory(ctx, state.mainModel)
if err != nil {
state.summarizerBroken = true
return main, false
}
state.summarizerProvider = built
}
if state.summarizerProvider == nil {
return main, false
}
return state.summarizerProvider, true
}
// fitsWindow reports whether a calibrated size is safely under the limit the
// provider enforces. The proactive threshold (70% of the window) is a trigger
// for the paid summarizer, not the hard limit; the reactive path compares
// against the window itself, with a small margin for estimate error, so a
// history pruned to, say, 85% of the window takes the free retry.
func (state *compactionState) fitsWindow(size int) bool {
if state.window <= 0 {
return size <= state.threshold
}
return size <= state.window-state.window/20
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
// when the history is under threshold, or when the history has not grown past
@@ -628,34 +534,10 @@ func (state *compactionState) recover(
// context-limit error never triggers an unexpected summarization call.
return messages, false, nil
}
if !isContextLimitError(errorMessage) {
if state.reactiveAttempted {
return messages, false, nil
}
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
// often be fixed for free by dropping superseded reads and pruning old
// tool-result bodies. It runs regardless of the one-shot reactive budget
// (it costs nothing and cannot loop: a second pass finds nothing left to
// prune). The free retry is taken only when the pruned history fits the
// window the provider enforces — otherwise a retry would fail the same
// way, so fall through to the paid summarizer in this same call.
toolTokens := estimateToolDefTokens(tools)
prunedAny := false
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if prunedAny {
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if state.fitsWindow(size) {
state.lowWaterMark = size
return messages, true, nil
}
}
if state.reactiveAttempted {
if !isContextLimitError(errorMessage) {
return messages, false, nil
}
@@ -695,21 +577,11 @@ func (state *compactionState) recover(
// provider call. The summary stream intentionally does NOT forward OnText (so
// compaction stays invisible on the user-facing surface), but it DOES forward
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
// The call goes to the dedicated (cheap) summarizer when one applies; if that
// call fails the summarizer is marked broken for the current model and the
// same slice is retried once on the main provider, so the caller sees at most
// one (main-provider-quality) error.
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
if state.planner == nil {
state.planner = newContextPlanner(contextPlannerConfig{})
}
return func(toSummarize []zeroruntime.Message) (string, error) {
chosen, dedicated := state.summarizeProvider(ctx, provider)
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
if err == nil || !dedicated {
return summary, err
}
state.summarizerBroken = true
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -793,41 +665,8 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
return summary, nil
}
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
// search results), yet the summary instructions only need their gist — the
// model that ACTED on the full output already turned it into decisions the
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
// head+tail of a result while cutting the summarizer input by 5-10x on
// tool-heavy sessions; the summarizer's own cost is the price being cut.
const summaryToolResultClamp = 2000
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
// a write_file call carries the whole file body in its arguments.
const summaryToolArgsClamp = 500
// clampForSummary head+tail-truncates text for the summarizer transcript,
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
func clampForSummary(text string, max int) string {
if max <= 0 || len(text) <= max {
return text
}
head := max * 3 / 4
tail := max - head
for head > 0 && !utf8.RuneStart(text[head]) {
head--
}
start := len(text) - tail
for start < len(text) && !utf8.RuneStart(text[start]) {
start++
}
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
}
// renderTranscript flattens messages into a plain-text transcript for the
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
// Tool-result bodies and tool-call arguments are clamped (see the constants
// above): the summary needs what was done and decided, not the raw payloads.
func renderTranscript(messages []zeroruntime.Message) string {
lines := make([]string, 0, len(messages))
for _, message := range messages {
@@ -837,13 +676,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
if len(message.ToolCalls) > 0 {
calls := make([]string, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
calls = append(calls, call.Name+"("+call.Arguments+")")
}
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
}
lines = append(lines, line)
case zeroruntime.MessageRoleTool:
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
lines = append(lines, "tool result: "+message.Content)
default:
lines = append(lines, string(message.Role)+": "+message.Content)
}
-469
View File
@@ -1,469 +0,0 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
// the proactive threshold for a 1000-token window.
func compactableHistory() []zeroruntime.Message {
return []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
}
}
// Summarization must run on the dedicated (cheap) summarizer when one is
// configured — the main provider sees no summarize traffic at all.
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
main := &mockProvider{}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return cheap, nil
},
}, nil)
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
if len(cheap.requests) == 0 {
t.Fatal("dedicated summarizer never received the summarize call")
}
if len(main.requests) != 0 {
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
}
if factoryCalls != 1 {
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
}
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
t.Fatal("compaction did not shrink the history")
}
found := false
for _, message := range compacted {
if strings.Contains(message.Content, "CHEAP SUMMARY") {
found = true
}
}
if !found {
t.Fatal("cheap summarizer's summary missing from the compacted history")
}
}
// A failing summarizer falls back to the main provider for the SAME slice and
// stays broken for the rest of the run — no repeated failed cheap calls.
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
summary, err := summarize(input)
if err != nil {
t.Fatalf("fallback must succeed via the main provider: %v", err)
}
if summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, want the main provider's", summary)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
}
if !state.summarizerBroken {
t.Fatal("a failed summarizer must be marked broken for the run")
}
// Second summarization: straight to main, no cheap retry.
if _, err := summarize(input); err != nil {
t.Fatal(err)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
}
if len(main.requests) != 2 {
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
}
}
// A factory that errors (misconfigured model) is equivalent to no summarizer:
// main provider used, factory not retried.
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return nil, errors.New("unknown model")
},
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
}
if factoryCalls != 1 {
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("after a factory failure the run must stay on the main provider")
}
if factoryCalls != 1 {
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
}
}
// Tool payloads are clamped in the summarizer transcript: results to
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
bigResult := strings.Repeat("r", 50_000)
bigArgs := strings.Repeat("w", 20_000)
transcript := renderTranscript([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "c1", Name: "write_file", Arguments: bigArgs},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
})
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
}
if !strings.Contains(transcript, "chars omitted for summarization") {
t.Fatal("clamped transcript must carry the omission marker")
}
if !strings.Contains(transcript, "keep going") {
t.Fatal("non-tool content must survive unclamped")
}
// Head AND tail of the clamped payloads survive.
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
}
}
// bigToolHistory returns a history with n large tool results (~40k estimated
// tokens each): the trailing protection window keeps the newest verbatim,
// the older ones are prunable.
func bigToolHistory(n int) []zeroruntime.Message {
bigBody := strings.Repeat("x", 160_000)
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
for i := 0; i < n; i++ {
id := string(rune('a' + i))
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
)
}
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
}
func summaryProvider(text string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
}}
}
// A context-limit error is first answered with the free prune stage; when the
// pruned history fits the window the retry is free (no summarizer call) and
// the one-shot reactive budget is untouched. The paid summarizer only runs
// when a later error finds nothing left to prune.
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
provider := summaryProvider("SUMMARY")
// The protected trailing tool output (~40k tokens) must fit the window.
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("prunable history that fits must produce a free retry")
}
if len(provider.requests) != 0 {
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
}
if state.reactiveAttempted {
t.Fatal("a free prune retry must not consume the reactive budget")
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// Second context-limit error on the pruned history: nothing meaningfully
// prunable remains, so the paid summarizer fires and consumes the budget.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("second recover must compact and retry")
}
if len(provider.requests) == 0 {
t.Fatal("second recover must reach the summarizer")
}
if !state.reactiveAttempted {
t.Fatal("a paid recover must consume the reactive budget")
}
}
// When pruning reclaims something but the pruned history still exceeds the
// window, recover falls through to the paid summarizer in the SAME call
// instead of spending a retry that would fail the same way.
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
provider := summaryProvider("SUMMARY")
// A 1,000-token window: even the protected trailing output overflows it.
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("insufficient prune must still end in a retry via summarization")
}
if len(provider.requests) == 0 {
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
}
if !state.reactiveAttempted {
t.Fatal("the paid summarizer consumes the reactive budget")
}
if estimateTokens(compacted) >= estimateTokens(messages) {
t.Fatal("recover did not shrink the history")
}
}
// The free prune stage is not gated behind the one-shot budget: after a paid
// reactive compaction, a later context-limit error still gets a free retry
// when pruning makes the history fit.
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
provider := summaryProvider("SUMMARY")
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
state.reactiveAttempted = true
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// With nothing left to prune and the budget spent, the caller keeps its
// original error — recover neither loops nor summarizes.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil || retried || len(provider.requests) != 0 {
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
}
}
// The free-retry gate compares against the window the provider enforces, not
// the 70% proactive threshold: a history pruned to ~80% of the window still
// takes the free retry.
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
provider := summaryProvider("SUMMARY")
messages := bigToolHistory(5)
pruned, _ := pruneStaleToolOutput(messages, 2)
prunedTokens := estimateTokens(pruned)
// Choose a window so the pruned size sits between 70% and 95% of it.
window := prunedTokens * 100 / 80
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
if prunedTokens <= state.threshold {
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
}
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
}
}
// After a mid-run model switch the summarizer is re-resolved against the new
// main model: a run that started on the cheap model (no dedicated summarizer)
// and escalated gets a cheap summarizer, and a previously broken one is
// retried for the new model.
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
cheap := summaryProvider("CHEAP SUMMARY")
main := summaryProvider("MAIN SUMMARY")
var factoryModels []string
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Model: "cheap-model",
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
factoryModels = append(factoryModels, mainModelID)
if mainModelID == "cheap-model" {
return nil, nil // main already is the cheap model
}
return cheap, nil
},
}, nil)
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("on the cheap model the main provider must summarize")
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
}
state.switchModel("big-model", 400_000)
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
}
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
}
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
}
// A broken summarizer is retried after the next switch.
state.summarizerBroken = true
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("a broken summarizer must fall back to main")
}
state.switchModel("other-model", 0)
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
}
}
func TestCompactionUpdateWindow(t *testing.T) {
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
state.lowWaterMark = 50_000
before := state.threshold
state.updateWindow(0) // unknown model: unchanged
if state.threshold != before || state.lowWaterMark != 50_000 {
t.Fatal("updateWindow(0) must be a no-op")
}
state.updateWindow(10_000)
if state.threshold >= before {
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
}
if state.lowWaterMark != 0 {
t.Fatal("low-water mark must reset on a window change")
}
if !state.enabled {
t.Fatal("a positive window must keep compaction enabled")
}
}
// End-to-end: after escalate_model switches providers, the loop re-derives the
// context window via ContextWindowFor, visible in the OnContext report.
func TestRunEscalationRederivesContextWindow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "big-model"})
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
}}}
var windows []int
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
ContextWindow: 100_000,
Model: "small-model",
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
ContextWindowFor: func(modelID string) int {
if modelID == "big-model" {
return 400_000
}
return 0
},
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(windows) < 2 {
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
}
if windows[0] != 100_000 {
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
}
if windows[len(windows)-1] != 400_000 {
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
}
}
// nonComparableProvider has a slice field, so comparing two Provider interface
// values holding it would panic; the summarizer path must never compare them.
type nonComparableProvider struct {
inner *mockProvider
tags []string
}
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return p.inner.StreamCompletion(ctx, request)
}
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
}, nil)
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
}
// And with no dedicated summarizer at all the closure still works.
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
t.Fatal(err)
}
}
+5 -15
View File
@@ -846,21 +846,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// Re-derive the context window for the new model so compaction and
// the OnContext budget report track the model actually in force —
// without this a switch to a smaller-window model can overflow the
// target, and a switch to a larger one over-compacts. An unknown
// model (<= 0) keeps the original window. The compactor also
// re-resolves its dedicated summarizer against the new model.
window := 0
if options.ContextWindowFor != nil {
window = options.ContextWindowFor(turnRequestedModel)
}
if window > 0 {
options.ContextWindow = window
planner.config.contextWindow = window
}
compactor.switchModel(turnRequestedModel, window)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs the switcher to also
// report the new window — out of scope for this change.
}
}
}
+4 -20
View File
@@ -348,26 +348,10 @@ type Options struct {
// CompactionPreserveLast is how many trailing messages compaction keeps
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
CompactionPreserveLast int
// Summarizer, when set, lazily builds the provider used for compaction
// summarization calls — typically a cheap/fast model, since summaries at
// main-model prices are the single most expensive recurring event in a
// long run. It receives the main model currently in force and may return
// (nil, nil) when no dedicated summarizer applies to it (the main model is
// already the cheap one). Built on the first paid compaction and again
// after a mid-run model switch. Any failure (build or call) falls back to
// the run's main provider until the next switch, so a misconfigured
// summarizer can never break compaction. nil keeps today's behavior.
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
// ContextWindowFor, when set, resolves a model ID to its context window so
// the compactor can re-derive its threshold after a mid-run model switch
// (escalate_model). Without it a switch keeps compacting against the
// original model's window — overflowing a smaller target or over-compacting
// a larger one. Return <= 0 when the model is unknown (window unchanged).
ContextWindowFor func(modelID string) int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// FileTracker records per-session file read/write versions so the write tools
// can detect a file changed on disk outside Zero since it was last read. nil
// disables the check. Created once per session and threaded into every tool run.
+15 -75
View File
@@ -10,11 +10,9 @@ import (
"github.com/Gitlawb/zero/internal/acp"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
const acpUsage = `zero acp — serve the Agent Client Protocol (ACP) over stdio
@@ -59,8 +57,20 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
// surface — no ACP-specific credential handling needed.
NewProvider: deps.newProvider,
RunAgent: agent.Run,
BuildWorkspace: func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*acp.Workspace, error) {
return buildACPWorkspace(ctx, workspaceRoot, resolved, mode, deps)
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
// exec surface, so ACP shell/file tools are confined — never run unconfined.
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return nil, nil, err
}
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
if err != nil {
return nil, nil, err
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
return registry, engine, nil
},
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
Store: deps.newSessionStore(),
@@ -75,76 +85,6 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
return exitSuccess
}
// buildACPWorkspace matches exec's registry construction for one ACP turn. MCP
// servers use the same sandbox-prepared execution runner, project configuration is
// gated by the validated workspace's trust state, and their runtime is released
// when acp.Agent completes the turn. ACP deliberately stays at low autonomy: an
// editor connection never upgrades MCP permissions beyond an interactive prompt.
func buildACPWorkspace(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode, deps appDeps) (*acp.Workspace, error) {
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return nil, err
}
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
if err != nil {
return nil, err
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
workspace := &acp.Workspace{
Registry: registry, Sandbox: engine,
DeferThreshold: resolved.Tools.DeferThreshold,
}
if mode != agent.PermissionModePlan {
// MCP stdio servers are subprocesses. Passing the engine to their runner
// keeps them inside the exact sandbox / lifecycle path used by zero exec.
runtime, trustSkip, err := registerMCPToolsForWorkspaceWithOptions(
ctx, workspaceRoot, registry, deps, mcp.AutonomyLow, workspaceRoot,
mcp.RegisterOptions{Execution: execution.NewRunner(engine), AdvertiseInAuto: true},
)
if err != nil {
// RegisterTools may have connected an earlier server before reporting a
// later failure, so do not orphan a partial runtime on this error path.
if runtime != nil {
_ = runtime.Close()
}
return nil, err
}
workspace.Cleanup = runtime.Close
workspace.Notices = acpMCPSetupNotices(trustSkip, runtime)
}
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
// MCP tools are deferred-eligible. Register their loader only after every
// ACP-visible tool is present, using the same mode the agent receives.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, mode, nil, nil)
return workspace, nil
}
func acpMCPSetupNotices(skip trustSkip, runtime mcpToolRuntime) []string {
var notices []string
if skip.excludedProjectConfig {
if skip.trustCheckErrored {
notices = append(notices, "The workspace-trust store could not be read; project MCP servers were ignored (fail-closed). Run 'zero trust' to enable them.")
} else {
notices = append(notices, "Project MCP servers were ignored in this untrusted workspace. Run 'zero trust' to enable them.")
}
}
if runtime == nil {
return notices
}
for _, skipped := range runtime.Skipped() {
if skipped.UnconfiguredDefault {
continue
}
message := fmt.Sprintf("MCP server %s unavailable, skipped", skipped.Name)
if skipped.Err != nil {
message += ": " + redaction.ErrorMessage(skipped.Err, redaction.Options{})
}
notices = append(notices, redaction.RedactString(message, redaction.Options{}))
}
return notices
}
// acpWorkspaceRootResolver validates a client-supplied cwd into a confinement
// root. It reuses exec's resolveWorkspaceRoot (abs+clean, must be an existing
// dir) and additionally rejects the filesystem root and the home directory — an
-201
View File
@@ -5,19 +5,10 @@ import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
type acpTestReader func([]byte) (int, error)
@@ -117,198 +108,6 @@ func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
}
}
func TestBuildACPWorkspaceRegistersSandboxedMCPToolsAndClosesThem(t *testing.T) {
setTrustConfigRoot(t)
workspaceRoot := t.TempDir()
if err := workspacetrust.Trust(workspaceRoot); err != nil {
t.Fatalf("trust workspace: %v", err)
}
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
if err != nil {
t.Fatalf("new grant store: %v", err)
}
var gotExclude, registered, closed bool
deps := fillAppDeps(appDeps{
resolveMCPConfig: func(root string, excludeProject bool) (config.MCPConfig, error) {
if root != workspaceRoot {
t.Fatalf("MCP workspace root = %q, want %q", root, workspaceRoot)
}
gotExclude = excludeProject
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "fake-docs"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
if ctx == nil {
t.Fatal("MCP registration received nil context")
}
if len(cfg.Servers) != 1 || cfg.Servers["docs"].Command != "fake-docs" {
t.Fatalf("MCP config = %+v", cfg)
}
if options.Autonomy != mcp.AutonomyLow {
t.Fatalf("MCP autonomy = %q, want low", options.Autonomy)
}
if options.Execution == nil {
t.Fatal("MCP stdio server was not given the sandbox execution runner")
}
if options.WorkspaceRoot != workspaceRoot {
t.Fatalf("MCP execution workspace = %q, want %q", options.WorkspaceRoot, workspaceRoot)
}
if !options.AdvertiseInAuto {
t.Fatal("ACP MCP tools must be advertised in auto mode without being pre-approved")
}
registered = true
registry.Register(cliFakeDeferredTool{
name: "mcp_docs_search", permission: tools.PermissionPrompt,
advertiseInAuto: options.AdvertiseInAuto,
})
return closeFunc(func() error {
closed = true
return nil
}), nil
},
})
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{
Tools: config.ToolsConfig{DeferThreshold: 1},
}, agent.PermissionModeAuto, deps)
if err != nil {
t.Fatalf("buildACPWorkspace: %v", err)
}
if gotExclude {
t.Fatal("trusted ACP workspace excluded project MCP configuration")
}
if !registered {
t.Fatal("ACP workspace did not register configured MCP tools")
}
if _, ok := workspace.Registry.Get("mcp_docs_search"); !ok {
t.Fatal("MCP tool is absent from ACP agent registry")
}
if _, ok := workspace.Registry.Get(tools.ToolSearchToolName); !ok {
t.Fatal("ACP registry is missing tool_search for deferred MCP tools")
}
if workspace.DeferThreshold != 1 {
t.Fatalf("workspace defer threshold = %d, want 1", workspace.DeferThreshold)
}
if err := workspace.Close(); err != nil {
t.Fatalf("close ACP workspace: %v", err)
}
if !closed {
t.Fatal("ACP workspace did not close its MCP runtime")
}
}
func TestBuildACPWorkspacePreservesRedactedMCPSetupNotices(t *testing.T) {
setTrustConfigRoot(t)
workspaceRoot := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspaceRoot, ".zero"), 0o755); err != nil {
t.Fatal(err)
}
projectConfig := `{"mcp":{"servers":{"project-docs":{"type":"stdio","command":"project-docs"}}}}`
if err := os.WriteFile(filepath.Join(workspaceRoot, ".zero", "config.json"), []byte(projectConfig), 0o600); err != nil {
t.Fatal(err)
}
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "grants.json")})
if err != nil {
t.Fatal(err)
}
deps := fillAppDeps(appDeps{
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "fake-docs"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{{
Name: "docs", Err: errors.New("token=" + secret),
}}}, nil
},
})
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
joined := strings.Join(workspace.Notices, "\n")
if !strings.Contains(joined, "untrusted workspace") || !strings.Contains(joined, "MCP server docs unavailable") {
t.Fatalf("workspace notices = %#v", workspace.Notices)
}
if strings.Contains(joined, secret) || !strings.Contains(joined, "[REDACTED]") {
t.Fatalf("workspace notices leaked secret: %q", joined)
}
}
func TestBuildACPWorkspaceSkipsMCPInPlanMode(t *testing.T) {
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
if err != nil {
t.Fatalf("new grant store: %v", err)
}
called := false
deps := fillAppDeps(appDeps{
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
called = true
return config.MCPConfig{}, nil
},
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
called = true
return nil, nil
},
})
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModePlan, deps)
if err != nil {
t.Fatalf("buildACPWorkspace: %v", err)
}
if called {
t.Fatal("plan-mode ACP workspace must not resolve or start MCP servers")
}
if err := workspace.Close(); err != nil {
t.Fatalf("close plan workspace: %v", err)
}
}
func TestBuildACPWorkspaceClosesPartialMCPRuntimeOnRegistrationError(t *testing.T) {
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
if err != nil {
t.Fatalf("new grant store: %v", err)
}
closed := false
deps := fillAppDeps(appDeps{
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"partial": {Type: "stdio", Command: "fake-partial"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return closeFunc(func() error {
closed = true
return nil
}), errors.New("second MCP server failed")
},
})
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
if err == nil {
t.Fatal("buildACPWorkspace succeeded after MCP registration error")
}
if workspace != nil {
t.Fatal("buildACPWorkspace returned a workspace after MCP registration error")
}
if !closed {
t.Fatal("partial MCP runtime was not closed after registration error")
}
}
type acpNotifyingReadCloser struct {
io.ReadCloser
readStarted chan<- struct{}
-1
View File
@@ -974,7 +974,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
CompactionModel: resolved.Preferences.CompactionModel,
Provider: provider,
NewProvider: deps.newProvider,
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
+2 -8
View File
@@ -18,20 +18,14 @@ import (
// cliFakeDeferredTool is deferred-eligible (implements Deferred() bool), mirroring
// an MCP registry tool, so it counts toward the deferral threshold.
type cliFakeDeferredTool struct {
name string
permission tools.Permission
advertiseInAuto bool
name string
}
func (t cliFakeDeferredTool) Name() string { return t.name }
func (t cliFakeDeferredTool) Description() string { return "fake deferred tool" }
func (t cliFakeDeferredTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (t cliFakeDeferredTool) Safety() tools.Safety {
permission := t.permission
if permission == "" {
permission = tools.PermissionAllow
}
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: permission, AdvertiseInAuto: t.advertiseInAuto}
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
}
func (t cliFakeDeferredTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
+10 -14
View File
@@ -682,20 +682,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
ModelSessionSwitcher: modelSessionSwitcher,
Summarizer: summarizerFactory(resolved, deps.newProvider),
ContextWindowFor: func(modelID string) int {
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
},
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
+10 -12
View File
@@ -31,14 +31,6 @@ type mcpToolListItem struct {
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
// otherwise a workspace whose only project config is MCP would be gated silently.
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
options := mcp.RegisterOptions{}
if len(runners) > 0 {
options.Execution = runners[0]
}
return registerMCPToolsForWorkspaceWithOptions(ctx, workspaceRoot, registry, deps, autonomy, trustRoot, options)
}
func registerMCPToolsForWorkspaceWithOptions(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, options mcp.RegisterOptions) (mcpToolRuntime, trustSkip, error) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
@@ -55,10 +47,16 @@ func registerMCPToolsForWorkspaceWithOptions(ctx context.Context, workspaceRoot
if err != nil {
return nil, skip, err
}
options.PermissionStore = store
options.Autonomy = autonomy
options.WorkspaceRoot = workspaceRoot
runtime, err := deps.registerMCPTools(ctx, registry, cfg, options)
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
PermissionStore: store,
Autonomy: autonomy,
Execution: runner,
WorkspaceRoot: workspaceRoot,
})
return runtime, skip, err
}
-17
View File
@@ -1,17 +0,0 @@
package cli
import (
"context"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// summarizerFactory adapts the resolved profile and the authenticated provider
// builder into agent.Options.Summarizer; the selection rules live in
// providers.CompactionSummarizerFactory, shared with the TUI.
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
}
-6
View File
@@ -119,12 +119,6 @@ type PreferencesConfig struct {
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
// CompactionModel routes compaction summarization calls to this model
// instead of the session's main model (summaries at main-model prices are
// the most expensive recurring event in long runs). Empty = automatic: a
// curated cheap model on official endpoints, the main model elsewhere.
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
CompactionModel string `json:"compactionModel,omitempty"`
}
// RecentModelEntry is one provider-qualified model selection recorded in
+66 -9
View File
@@ -5,23 +5,31 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning. The lock is held only for a single metadata
// read-modify-write (milliseconds), never across a job's exec, so the timeout
// is generous relative to any real hold.
// read-modify-write (milliseconds), never across a job's exec, so the timeout is
// generous and the stale threshold sits far above any real hold.
const (
cronLockTimeout = 10 * time.Second
cronLockStaleAfter = 60 * time.Second
cronLockRetryDelay = 20 * time.Millisecond
)
// lockJob takes a cross-process advisory lock for one job on a stable sibling
// "<id>.lock" file next to the job directory. It serializes the read-modify-write
// of a job's metadata across concurrent schedulers and commands. The kernel
// releases the lock when a holder exits; the file is never renamed or removed.
var cronLockSeq atomic.Uint64
// lockJob takes a cross-process exclusive lock for one job by O_EXCL-creating a
// sibling "<id>.lock" file next to the job directory (so Remove's RemoveAll of
// the job dir never deletes a live lock). It serializes the read-modify-write of
// a job's metadata across concurrent schedulers and commands. The lock file is
// removed on release; a stale lock from a crashed holder (older than
// cronLockStaleAfter) is reclaimed. Wall-clock time.Now is used deliberately
// (not the injectable Store.now) so lock timing never depends on a frozen test
// clock and the stale check compares against real file mtimes.
func (s *Store) lockJob(id string) (func(), error) {
if !validID(id) {
return nil, fmt.Errorf("invalid cron job id %q", id)
@@ -30,15 +38,64 @@ func (s *Store) lockJob(id string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), cronLockSeq.Add(1))
deadline := time.Now().Add(cronLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial write would leave a lock file without our token, so the
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: write job lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: close job lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("cron: acquire job lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both "reclaim" and recreate, so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename of a given file
// wins) and restores it if it turns out fresh, so a live lock is never deleted
// out from under its holder.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > cronLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= cronLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("cron: reclaim stale job lock: %w", rerr)
}
if cleared {
continue // cleared a genuinely stale lock; retry the O_EXCL create now
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait instead of hot-spinning on a reclaim that never wins (L13).
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("cron: timed out acquiring job lock for %q", id)
}
+1 -1
View File
@@ -64,7 +64,7 @@ func TestMutateRemovedJobReturnsNotFound(t *testing.T) {
if !errors.Is(err, ErrJobNotFound) {
t.Fatalf("Mutate on removed job = %v, want ErrJobNotFound", err)
}
// The stable advisory-lock file does not resurrect the removed job dir.
// And the lock file left no litter / did not resurrect the job dir.
if _, gerr := store.Get(job.ID); !errors.Is(gerr, ErrJobNotFound) {
t.Fatalf("job should remain removed, Get = %v", gerr)
}
+67 -35
View File
@@ -3,60 +3,107 @@ package daemon
import (
"errors"
"fmt"
"io/fs"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Single-instance lock. The stable PID file carries a kernel advisory lock for
// the daemon's full lifetime. Process exit releases the lock automatically, so
// stale metadata needs no rename/remove recovery protocol.
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
// daemon recovers from an unclean shutdown without manual cleanup.
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
// fileLock is an acquired single-instance lock.
type fileLock struct {
lock *lockutil.FileLock
path string
}
// processAlive reports whether pid is a live process. Implemented per-platform
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
var processAlive = osProcessAlive
// acquireLock takes the single-instance advisory lock at path. isAlive is used
// only to enrich a contention error with the current holder's PID; the kernel
// lock, not PID metadata, is authoritative.
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
// stub); nil uses the real processAlive.
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
if isAlive == nil {
isAlive = processAlive
}
lock, err := lockutil.TryAcquireFileLock(path)
if err != nil {
if !errors.Is(err, lockutil.ErrLockHeld) {
// At most two passes: create, or detect-stale-then-retry once.
for attempt := 0; attempt < 2; attempt++ {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
// A failed PID write would leave a malformed lock file that another
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
// the single-instance guarantee — so on write failure, remove it and fail.
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(path)
return nil, werr
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(path)
return nil, cerr
}
return &fileLock{path: path}, nil
}
if !errors.Is(err, fs.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, err
}
pid, perr := readPidFile(path)
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
return nil, ErrAlreadyRunning
// Stale lock (dead PID or unreadable) — reclaim it atomically, then retry the
// O_EXCL create. A blind Remove here races: two daemons starting at once could
// both read the stale PID, both Remove, and then one Removes the OTHER's
// freshly-created lock — leaving both "holding" the single-instance lock.
// reclaimStaleLock renames the file aside so only one racer wins the rename,
// and restores it if a live holder reacquired in the gap (D6).
if _, rerr := reclaimStaleLock(path, isAlive); rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break the single-instance guarantee).
// Fail closed instead of spinning to the deadline.
return nil, fmt.Errorf("daemon: reclaim stale lock: %w", rerr)
}
}
if err := lock.WriteMetadata([]byte(fmt.Sprintf("%d\n", os.Getpid()))); err != nil {
return nil, errors.Join(err, lock.Release())
}
return &fileLock{lock: lock}, nil
return nil, ErrAlreadyRunning
}
// release drops the kernel lock and closes its handle. The stable PID file is
// deliberately retained so contenders always address the same inode.
// daemonLockSeq makes each reclaim attempt's sidelined filename unique per process.
var daemonLockSeq atomic.Uint64
// reclaimStaleLock atomically reclaims a single-instance lock whose recorded
// PID is dead, via lockutil.ReclaimStaleLock: the lock file is renamed aside
// (only one racer can win the rename) and stolen only if the moved file's PID
// is still dead; a holder that reacquired the lock in the gap between the
// stale check and the rename carries a LIVE pid and is restored rather than
// stolen. Returns true only when a genuinely stale lock was removed. A
// non-nil error means the caller must fail closed instead of re-acquiring
// (see lockutil.ReclaimStaleLock).
func reclaimStaleLock(path string, isAlive func(pid int) bool) (bool, error) {
suffix := fmt.Sprintf("%d-%d", os.Getpid(), daemonLockSeq.Add(1))
return lockutil.ReclaimStaleLock(path, suffix, func(reclaimedPath string) bool {
pid, err := readPidFile(reclaimedPath)
return err == nil && pid > 0 && isAlive(pid)
})
}
// release removes the lock file. Safe to call once. An already-missing lock
// file is not an error (lockutil.RemoveLockFile swallows it on every platform).
func (l *fileLock) release() error {
if l == nil || l.lock == nil {
if l == nil || l.path == "" {
return nil
}
return l.lock.Release()
return lockutil.RemoveLockFile(l.path)
}
// readPidFile reads and parses the PID recorded in a lock file.
@@ -65,20 +112,5 @@ func readPidFile(path string) (int, error) {
if err != nil {
return 0, err
}
metadata := strings.TrimSpace(string(data))
if pid, err := strconv.Atoi(metadata); err == nil {
return pid, nil
}
pidText, sequenceText, ok := strings.Cut(metadata, "-")
if !ok || pidText == "" || sequenceText == "" || strings.Contains(sequenceText, "-") {
return 0, fmt.Errorf("daemon: parse lock PID metadata %q", metadata)
}
if _, err := strconv.ParseUint(sequenceText, 10, 64); err != nil {
return 0, fmt.Errorf("daemon: parse lock holder sequence: %w", err)
}
pid, err := strconv.Atoi(pidText)
if err != nil {
return 0, fmt.Errorf("daemon: parse lock PID: %w", err)
}
return pid, nil
return strconv.Atoi(strings.TrimSpace(string(data)))
}
+42 -54
View File
@@ -32,48 +32,27 @@ func TestLockSingleInstance(t *testing.T) {
_ = l2.release()
}
// A PID liveness probe is diagnostic only. Even if it reports the recorded PID
// dead, a contended kernel lock proves that the holder is active and must win.
// The old rename-aside protocol trusted this stale observation and admitted a
// second daemon while the first one was still inside its critical section.
func TestLockDoesNotOverrideKernelHolderWithStalePIDProbe(t *testing.T) {
func TestLockStaleRecovery(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
first, err := acquireLock(path, func(int) bool { return true })
if err != nil {
t.Fatalf("first acquireLock: %v", err)
}
defer first.release()
second, err := acquireLock(path, func(int) bool { return false })
if second != nil {
_ = second.release()
}
if !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("contended acquire with stale PID probe = %v, want ErrAlreadyRunning", err)
}
}
func TestLockIgnoresStaleMetadataWhenKernelLockIsFree(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
// A crashed daemon may leave PID metadata behind, but its kernel lock is
// released automatically, so the next daemon can acquire without moving it.
// Simulate a stale lock from a crashed daemon: a PID file whose process is
// dead.
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
dead := func(int) bool { return false }
l, err := acquireLock(path, dead)
if err != nil {
t.Fatalf("acquire over stale metadata: %v", err)
t.Fatalf("stale-lock recovery failed: %v", err)
}
// The lock now records OUR pid, not the stale one.
data, _ := os.ReadFile(path)
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
t.Fatalf("lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
}
_ = l.release()
}
func TestLockReleaseKeepsStableFile(t *testing.T) {
func TestLockReleaseRemovesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
l, err := acquireLock(path, func(int) bool { return true })
if err != nil {
@@ -82,37 +61,46 @@ func TestLockReleaseKeepsStableFile(t *testing.T) {
if err := l.release(); err != nil {
t.Fatalf("release: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("release removed stable lock file: %v", err)
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("lock file still present after release: %v", err)
}
}
func TestReadPidFileMetadataFormats(t *testing.T) {
func TestReclaimStaleLockRemovesDeadHolder(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
for _, test := range []struct {
name string
metadata string
wantPID int
wantErr bool
}{
{name: "legacy PID", metadata: "4242\n", wantPID: 4242},
{name: "PID and holder sequence", metadata: "4242-7\n", wantPID: 4242},
{name: "missing sequence", metadata: "4242-", wantErr: true},
{name: "non-numeric sequence", metadata: "4242-next", wantErr: true},
{name: "extra component", metadata: "4242-7-8", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(test.metadata), 0o600); err != nil {
t.Fatal(err)
}
got, err := readPidFile(path)
if (err != nil) != test.wantErr {
t.Fatalf("readPidFile() error = %v, wantErr %v", err, test.wantErr)
}
if !test.wantErr && got != test.wantPID {
t.Fatalf("readPidFile() = %d, want %d", got, test.wantPID)
}
})
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
if ok, err := reclaimStaleLock(path, func(int) bool { return false }); err != nil || !ok {
t.Fatalf("reclaimStaleLock must report a genuinely stale (dead-PID) lock reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("a reclaimed stale lock must be removed, stat = %v", err)
}
}
func TestReclaimStaleLockRestoresLiveHolder(t *testing.T) {
// If a holder reacquires the lock in the gap between the stale check and the
// rename, the moved file carries a LIVE pid; reclaim must restore it untouched
// rather than steal it — otherwise two daemons both "hold" the lock (D6).
path := filepath.Join(t.TempDir(), "daemon.lock")
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write lock: %v", err)
}
if ok, err := reclaimStaleLock(path, func(int) bool { return true }); err != nil || ok {
t.Fatalf("reclaimStaleLock must NOT report a live-PID lock reclaimed (ok=%v err=%v)", ok, err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("live lock must be restored in place, read = %v", err)
}
if strings.TrimSpace(string(data)) != "4242" {
t.Fatalf("restored lock content = %q, want %q (unchanged)", strings.TrimSpace(string(data)), "4242")
}
// No sidelined ".stale" leftovers.
matches, _ := filepath.Glob(path + ".stale.*")
if len(matches) != 0 {
t.Fatalf("reclaim left sidelined files: %v", matches)
}
}
+2 -6
View File
@@ -124,16 +124,12 @@ func TestServerEndToEnd(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
// Runtime endpoints are removed on exit. The advisory lock file remains as
// a stable inode, but the kernel lock on it has been released.
for _, p := range []string{paths.Socket, paths.Status} {
// Socket, status, and lock files are removed on exit.
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
}
}
if _, err := os.Stat(paths.Lock); err != nil {
t.Fatalf("stable lock file missing after shutdown: %v", err)
}
}
func TestServerSecondInstanceFails(t *testing.T) {
+29 -157
View File
@@ -3,93 +3,13 @@
package execution
import (
"errors"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// requireProcessState refuses to let a broken ps pipeline masquerade as passing
// coverage. Every test in this file proves something about zombie detection,
// and every one of those proofs is read out of ps — so if ps cannot report
// state, the tests verify nothing whatever they do next.
//
// The old code answered that with t.Skip, which is indistinguishable from
// success in CI output. This probes BOTH ps pipelines against this test
// process, which is by definition alive and not a zombie, so a working ps must
// report a live state for it. Failing that:
//
// - In CI (or with ZERO_REQUIRE_PS=1) it is a hard failure. ps is present on
// every image used today, so its absence means the pipeline regressed and
// that has to surface immediately rather than as skipped-green.
// - Elsewhere it stays a skip, so a contributor on an exotic box is not
// blocked by an environment the project does not claim to support.
//
// This only covers "ps cannot report state at all". Once it passes, a later
// failure to observe an EXPECTED state is a real defect, and the tests below
// treat it as one.
func requireProcessState(t *testing.T) {
t.Helper()
self := os.Getpid()
var reasons []string
// The production path: ps -A -o pid=,pgid=,stat=, parsed by
// processTableStateMatches. This is what signalTargetRunning consults.
if running, ok := processIsRunning(self); !ok || !running {
reasons = append(reasons, "the process table read used by signalTargetRunning reported no live state for this test process")
}
// This file's independent oracle: ps -o stat= -p <pid>. It is deliberately
// NOT the production parser, so a regression in that parser cannot hide by
// breaking the test's own precondition check in the same way.
if zombie, ok := processIsZombie(self); !ok || zombie {
reasons = append(reasons, "the per-PID state read used by this test file reported no live state for this test process")
}
if len(reasons) == 0 {
return
}
reason := "cannot read process state, so ps-based zombie detection is unverifiable here: " + strings.Join(reasons, "; ")
if requireWorkingPS() {
t.Fatal(reason + " — failing rather than skipping because CI (or ZERO_REQUIRE_PS) is set, where ps is expected to work")
}
t.Skip(reason)
}
// requireWorkingPS reports whether an unusable ps must fail the run instead of
// skipping it. ZERO_REQUIRE_PS lets a developer opt into the strict behavior
// locally to reproduce what CI does.
func requireWorkingPS() bool {
if strings.TrimSpace(os.Getenv("ZERO_REQUIRE_PS")) != "" {
return true
}
return strings.TrimSpace(os.Getenv("CI")) != ""
}
// awaitZombie blocks until pid is an unreaped zombie. The caller must not have
// waited on the child, so the zombie is a stable state rather than a race: it
// persists until something reaps it. Failing to reach it therefore means the
// child never exited or ps stopped reporting its state, and both are defects
// rather than reasons to skip.
func awaitZombie(t *testing.T, pid int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
zombie, ok := processIsZombie(pid)
if ok && zombie {
return
}
if time.Now().After(deadline) {
if !ok {
t.Fatalf("pid %d left the process table without being reaped; this test never waited on it, so its zombie row must persist", pid)
}
t.Fatalf("pid %d did not reach the zombie state within 5s", pid)
}
time.Sleep(10 * time.Millisecond)
}
}
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
// zombie, so a group whose only remaining member is waiting to be reaped used to
@@ -97,8 +17,6 @@ func awaitZombie(t *testing.T, pid int) {
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
// depended on when the reaper ran.
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "exit 0")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
@@ -109,59 +27,23 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
// is exactly the state a terminated tree passes through.
t.Cleanup(func() { _ = cmd.Wait() })
awaitZombie(t, pid)
// Whether a zombie-only group can be signalled is a KERNEL difference, not a
// ps one. On Linux the zombie still occupies the process table and
// kill(-pid, 0) succeeds, so signalTargetRunning goes on to consult ps —
// which is the branch this test was written for. Darwin returns EPERM for
// the same call, so signalTargetRunning short-circuits on the kill and never
// reaches the ps check.
//
// The OUTCOME is identical and is asserted on every platform below. Only the
// branch that produces it differs, so record that instead of skipping the
// whole test (which would drop Darwin's coverage of the outcome) or failing
// it (which would report a supported platform as broken). Any errno other
// than EPERM means the group is not in the state this test needs.
psPathReachable := true
if err := syscall.Kill(-pid, syscall.Signal(0)); err != nil {
if !errors.Is(err, syscall.EPERM) {
t.Fatalf("zombie group %d is unsignalable with an unexpected error (%v); the zombie case is not present, so nothing below would be meaningful", pid, err)
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
psPathReachable = false
t.Logf("kernel refuses to signal zombie-only group %d (EPERM); signalTargetRunning answers from kill(2) rather than ps here, so the ps-based zombie check is asserted directly on platforms that allow the signal", pid)
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
}
// Where the signal IS permitted, pin the ps-based check itself. This is the
// regression #774 was about, and asserting only signalTargetRunning would
// let a ps-side regression hide behind the kill result.
if psPathReachable {
running, ok := processGroupHasRunningMember(pid)
if !ok {
t.Fatalf("process group %d has no rows while its zombie is unreaped; ps stopped reporting the group rather than the group being gone", pid)
}
if running {
t.Fatal("the ps-based group check must not report a zombie-only group as running")
}
}
// Valid on every platform: whichever branch answers, a zombie-only group is
// not running. On a kernel that refuses the signal this is the whole of the
// coverage, and it is more than the previous skip provided.
if signalTargetRunning(-pid) {
t.Fatal("a group holding only a zombie must not count as running")
}
if !psPathReachable {
// terminateTarget's own kill(2) would hit the same EPERM and be returned
// as a cleanup error, so this assertion cannot be made where the kernel
// refuses the signal. That may be a real gap in TerminateProcessTree on
// such a platform rather than a test limitation — a zombie-only tree is
// already exited and should terminate cleanly — but confirming and
// changing that is a production fix, not this test's business.
t.Log("skipping the TerminateProcessTree assertion: its kill(2) would hit the same EPERM as the probe above")
return
}
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
}
@@ -171,8 +53,6 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
// a live member must still be seen as running and then actually stopped, so the
// zombie tolerance above cannot degrade into ignoring real processes.
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "sleep 30")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
@@ -187,15 +67,7 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree: %v", err)
}
// ok reports whether ANY row for this group was found. This test never waits
// on the child, so its zombie row must still be there — an unknown state
// means ps stopped answering, not that the group is gone. Treating that as a
// pass is what let this assertion succeed without checking anything.
running, ok := processGroupHasRunningMember(pid)
if !ok {
t.Fatalf("process group %d has no rows after termination; the unreaped child's zombie row must persist, so the group's state is undeterminable rather than clean", pid)
}
if running {
if running, ok := processGroupHasRunningMember(pid); ok && running {
t.Fatal("the group still has a running member after termination")
}
}
@@ -210,8 +82,6 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
// "unknown" resulted every time, and signalTargetRunning conservatively (and
// incorrectly) treated a zombie individual-PID target as still running.
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "exit 0")
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
// test process's group, so it is not its own leader and processSignalTarget
@@ -222,38 +92,40 @@ func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
pid := cmd.Process.Pid
t.Cleanup(func() { _ = cmd.Wait() })
awaitZombie(t, pid)
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
target, err := processSignalTarget(pid)
if err != nil {
t.Fatalf("processSignalTarget: %v", err)
}
// The child inherited this process's group, so its PGID is this process's
// PID and can never equal its own PID. A negative target here would mean
// processSignalTarget misread the group, which is the very thing the
// individual-PID fallback exists to handle.
if target != pid {
t.Fatalf("processSignalTarget(%d) = %d, want the individual PID: the child inherited this process's group, so it is not its own leader", pid, target)
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
}
if signalTargetRunning(target) {
t.Fatal("a zombie individual-PID target must not count as running")
}
}
// processIsZombie reports a process's zombie state via ps. It is deliberately a
// separate, per-PID ps invocation rather than a reuse of the production
// process-table parser: these tests assert what that parser concludes, so
// borrowing it to establish their own preconditions would let one regression
// silence both sides at once.
//
// ok is false when no state could be read, which after requireProcessState has
// passed means the process left the table rather than that ps is broken.
// processIsZombie reports a process's zombie state via ps. ok is false when the
// state could not be read.
func processIsZombie(pid int) (zombie bool, ok bool) {
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false, false
}
state := strings.TrimLeft(string(output), " \n\t")
state := string(output)
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
state = state[1:]
}
if state == "" {
return false, false
}
+63 -9
View File
@@ -5,39 +5,93 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning for the audit log. The lock is held only across a
// single read-then-append (milliseconds), so the timeout is generous relative
// to any real hold.
// single read-then-append (milliseconds), so the timeout is generous and the
// stale threshold sits far above any real hold.
const (
auditLockTimeout = 10 * time.Second
auditLockStaleAfter = 60 * time.Second
auditLockRetryDelay = 20 * time.Millisecond
)
// lockAudit takes a cross-process advisory lock on the audit log through a
// stable sibling "<auditPath>.lock" file. It makes
var auditLockSeq atomic.Uint64
// lockAudit takes a cross-process exclusive lock on the audit log by
// O_EXCL-creating a sibling "<auditPath>.lock" file (removed on release). It makes
// the lastSequence()+append in append() atomic across processes; the audit log is
// a shared global file, so without it two processes can read the same last
// sequence and write duplicate numbers. Kernel lock lifetime provides crash
// recovery without moving or deleting the lock path.
// sequence and write duplicate numbers. A stale lock from a crashed holder (older
// than auditLockStaleAfter) is reclaimed. Wall-clock time is used deliberately so
// lock timing never depends on the store's injectable clock and the stale check
// compares against real file mtimes. This mirrors internal/cron/lock.go and
// internal/oauth/lock.go. Uses internal/lockutil for lock restore and reclaim.
func (store *AuditStore) lockAudit() (func(), error) {
lockPath := store.auditPath + ".lock"
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), auditLockSeq.Add(1))
deadline := time.Now().Add(auditLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial write would leave a lock file without our token, so the
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: write audit lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: close audit lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat both
// as contention and retry.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("hooks: acquire audit lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > auditLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= auditLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("hooks: reclaim stale audit lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("hooks: timed out acquiring audit lock")
}
+48 -111
View File
@@ -1,127 +1,64 @@
// Package lockutil provides cross-process advisory file locks used by cron,
// daemon, hooks, oauth, and swarm. Locks are held by the kernel for the lifetime
// of an open file handle, so process exit releases them without a stale-file
// reclaim protocol. The lock file itself is stable: holders never rename or
// remove it while another process may still have its inode locked.
// Package lockutil provides the platform-specific file primitives behind the
// O_EXCL lock files in cron, daemon, hooks, oauth, and swarm: a no-overwrite
// restore for locks that were sidelined during a stale-reclaim attempt, and a
// lock file remover with one cross-platform contract (missing files are a
// no-op; Windows retries transient sharing violations).
package lockutil
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
)
var (
// ErrLockHeld reports that another process or goroutine currently holds the
// advisory lock. Callers may wait and retry until their own deadline.
ErrLockHeld = errors.New("lockutil: lock is held")
// ErrLockReleased reports an attempt to update metadata after Release.
ErrLockReleased = errors.New("lockutil: lock is released")
)
var holderSequence atomic.Uint64
// FileLock is an exclusive advisory lock held through an open file handle.
// Release is idempotent. The lock path remains on disk after release; deleting
// or replacing it would let different processes lock different inodes.
type FileLock struct {
mu sync.Mutex
file *os.File
state platformLockState
released bool
releaseErr error
}
// TryAcquireFileLock attempts to take an exclusive advisory lock without
// waiting. A contended lock returns ErrLockHeld. The kernel releases a lock if
// its process exits, so an old unlocked file never needs stale reclamation.
func TryAcquireFileLock(path string) (*FileLock, error) {
return TryAcquireFileLockAt(filepath.Dir(path), path)
}
// TryAcquireFileLockAt attempts to lock path while confining every component
// below root to a handle-relative traversal. root is the caller's trusted
// boundary and may itself be reached through a legitimate link; links and
// reparse points below it are rejected before metadata can be written.
func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
if root == "" {
return nil, errors.New("lockutil: lock root is empty")
}
relative, err := filepath.Rel(root, path)
// restoreByCopy restores reclaimed to path without overwriting an existing
// path, as a fallback for when the platform's primary no-replace primitive
// (hard link on POSIX, MoveFileEx on Windows) fails for a reason other than
// the destination existing. It stages a full copy under a private name next
// to reclaimed and publishes it to path with publish (the same no-replace
// primitive the caller's platform uses for the primary restore), so path
// never appears in a partially-copied state: a crash between staging and
// publish leaves path exactly as it was (missing or, if a competing holder
// created it, untouched), never a truncated file that a PID/content-based
// liveness check could mistake for dead. Leaving path missing here would let
// the next O_EXCL create succeed while the sidelined holder is still live,
// breaking mutual exclusion. publish keeps the no-overwrite guarantee: a new
// holder that appeared in the meantime wins and this returns os.ErrExist. The
// copy resets the lock's mtime to now, which only makes the restored lock
// look fresher; that is safe, since it is being handed back to a live holder.
func restoreByCopy(reclaimed, path string, publish func(from, to string) error) error {
staged := reclaimed + ".copy"
src, err := os.Open(reclaimed)
if err != nil {
return nil, fmt.Errorf("lockutil: resolve lock path relative to root: %w", err)
return err
}
relative = filepath.Clean(relative)
if relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("lockutil: lock path %q escapes root %q", path, root)
}
file, err := openLockFileAt(root, relative, path)
dst, err := os.OpenFile(staged, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
_ = src.Close()
return err
}
state, contended, err := tryLockFile(file)
_, err = io.Copy(dst, src)
// Close the source before removing anything below: Go opens files without
// FILE_SHARE_DELETE on Windows, so deleting reclaimed or staged while src
// is open would fail with a sharing violation.
_ = src.Close()
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("lockutil: acquire advisory lock: %w", err)
_ = dst.Close()
_ = os.Remove(staged)
return err
}
if contended {
_ = file.Close()
return nil, ErrLockHeld
if err := dst.Close(); err != nil {
_ = os.Remove(staged)
return err
}
lock := &FileLock{file: file, state: state}
metadata := []byte(fmt.Sprintf("%d-%d\n", os.Getpid(), holderSequence.Add(1)))
if err := lock.WriteMetadata(metadata); err != nil {
return nil, errors.Join(err, lock.Release())
}
return lock, nil
}
// WriteMetadata replaces the diagnostic contents of the lock file while the
// caller holds it. Metadata does not establish ownership; the kernel lock does.
func (lock *FileLock) WriteMetadata(data []byte) error {
if lock == nil {
return ErrLockReleased
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released || lock.file == nil {
return ErrLockReleased
}
if err := lock.file.Truncate(0); err != nil {
return fmt.Errorf("lockutil: truncate lock metadata: %w", err)
}
if _, err := lock.file.Seek(0, 0); err != nil {
return fmt.Errorf("lockutil: seek lock metadata: %w", err)
}
if _, err := lock.file.Write(data); err != nil {
return fmt.Errorf("lockutil: write lock metadata: %w", err)
if err := publish(staged, path); err != nil {
_ = os.Remove(staged)
return err
}
// publish may leave staged behind (a hard-link publish keeps both names),
// so remove it explicitly; a move-based publish already made this a no-op.
_ = os.Remove(staged)
// The lock is back at path, so the restore has succeeded; failing to clean
// up the sidelined name must not be reported as a failed restore.
_ = RemoveLockFile(reclaimed)
return nil
}
// Release unlocks and closes the held file handle. It deliberately leaves the
// stable lock file in place. Repeated calls return the first release result.
func (lock *FileLock) Release() error {
if lock == nil {
return nil
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released {
return lock.releaseErr
}
lock.released = true
unlockErr := unlockFile(lock.file, &lock.state)
closeErr := lock.file.Close()
lock.file = nil
if err := errors.Join(unlockErr, closeErr); err != nil {
lock.releaseErr = fmt.Errorf("lockutil: release advisory lock: %w", err)
}
return lock.releaseErr
}
+33 -82
View File
@@ -4,92 +4,43 @@ package lockutil
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
type platformLockState struct{}
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
parentFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, fmt.Errorf("open lock root: %w", err)
}
defer func() {
if parentFD >= 0 {
resultErr = errors.Join(resultErr, unix.Close(parentFD))
}
}()
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
for _, component := range components[:len(components)-1] {
nextFD, openErr := unix.Openat(parentFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
if openErr != nil {
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
}
if closeErr := unix.Close(parentFD); closeErr != nil {
_ = unix.Close(nextFD)
parentFD = -1
return nil, fmt.Errorf("close lock path component: %w", closeErr)
}
parentFD = nextFD
}
name := components[len(components)-1]
fd := -1
// Darwin can transiently return ENOENT when many callers race to create the
// same O_NOFOLLOW entry. The bound parent handle cannot be redirected, so a
// bounded retry preserves first-use contention without weakening no-follow.
for attempt := 0; attempt < 10; attempt++ {
fd, err = unix.Openat(parentFD, name, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
if !errors.Is(err, unix.ENOENT) {
break
}
}
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(fd), displayPath)
if file == nil {
_ = unix.Close(fd)
return nil, errors.New("convert lock handle to file")
}
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil {
return nil, errors.Join(fmt.Errorf("inspect lock file: %w", err), file.Close())
}
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, errors.Join(errors.New("refusing non-regular lock file"), file.Close())
}
if stat.Nlink != 1 {
return nil, errors.Join(errors.New("refusing multiply-linked lock file"), file.Close())
}
if err := unix.Close(parentFD); err != nil {
parentFD = -1
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
}
parentFD = -1
return file, nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
// RestoreLockFile restores a sidelined lock file on non-Windows platforms. It
// uses os.Link so a competing lock created at path in the meantime wins: the
// link fails with os.ErrExist instead of overwriting it. If the link fails for
// any other reason (hardlink-incapable filesystems such as FAT or some
// FUSE/network mounts, ENOSPC, EPERM), it falls back to an O_EXCL copy rather
// than leaving path missing and the live holder's lock stranded in reclaimed.
// Once the lock is back at path the restore has succeeded, so a failed cleanup
// of the sidelined name is not reported as an error; the leftover file is
// invisible to the lock protocol.
func RestoreLockFile(reclaimed, path string) error {
err := os.Link(reclaimed, path)
if err == nil {
return platformLockState{}, false, nil
}
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return platformLockState{}, true, nil
}
return platformLockState{}, false, err
}
func unlockFile(file *os.File, _ *platformLockState) error {
if file == nil {
_ = RemoveLockFile(reclaimed)
return nil
}
return unix.Flock(int(file.Fd()), unix.LOCK_UN)
if errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, os.Link)
}
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means it was lost to a racer rather than a hard failure. POSIX
// rename has no contention errno (a lost race surfaces only as ENOENT, which
// ReclaimStaleLock already treats as benign), so nothing extra is benign here.
func isReclaimContended(error) bool { return false }
// RemoveLockFile removes a lock file on non-Windows platforms. Removing an
// already-missing file reports nil, matching the Windows implementation, so
// callers see one cross-platform contract.
func RemoveLockFile(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
+101 -205
View File
@@ -1,220 +1,116 @@
package lockutil
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestFileLockSerializesAndKeepsStablePath(t *testing.T) {
func TestRestoreLockFile(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Successful restore when target does not exist
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := RestoreLockFile(reclaimed, path); err != nil {
t.Fatalf("RestoreLockFile failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !os.IsNotExist(err) {
t.Fatalf("expected sidelined lock to be cleaned up/removed: %v", err)
}
// 2. Fail when target already exists (prevent overwrite)
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := RestoreLockFile(reclaimed, path)
if err == nil {
t.Fatal("expected RestoreLockFile to fail when target exists")
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist, got: %v", err)
}
// The sidelined lock must still exist on failure, and the competing lock
// must be untouched.
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
}
func TestRestoreByCopy(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Restores content and cleans up the sidelined file when the target is
// free (the fallback taken when the platform's no-replace primitive fails
// for a reason other than the destination existing).
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); err != nil {
t.Fatalf("restoreByCopy failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected sidelined lock to be cleaned up after copy: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up: %v", err)
}
// 2. Keeps the no-overwrite guarantee: a competing lock at the target wins
// and the sidelined lock survives for the caller's ErrExist cleanup.
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist when target exists, got: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up after a failed publish: %v", err)
}
}
func TestRemoveLockFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
first, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("first acquire: %v", err)
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
firstInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat first lock path: %v", err)
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile failed: %v", err)
}
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
t.Fatalf("contended acquire = %v, want ErrLockHeld", err)
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
if err := first.Release(); err != nil {
t.Fatalf("release first: %v", err)
}
releasedInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("release removed stable lock path: %v", err)
}
if !os.SameFile(firstInfo, releasedInfo) {
t.Fatal("release replaced the stable lock file")
}
second, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("acquire after release: %v", err)
}
secondInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat second lock path: %v", err)
}
if !os.SameFile(firstInfo, secondInfo) {
t.Fatal("reacquire used a different lock file")
}
if err := second.Release(); err != nil {
t.Fatalf("release second: %v", err)
}
if err := second.Release(); err != nil {
t.Fatalf("idempotent release: %v", err)
// Removing an already-missing lock file is a no-op on every platform.
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile on a missing file = %v, want nil", err)
}
}
func TestFileLockMetadata(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
lock, err := TryAcquireFileLock(path)
if err != nil {
t.Fatal(err)
}
if err := lock.WriteMetadata([]byte("holder\n")); err != nil {
t.Fatalf("write metadata: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "holder\n" {
t.Fatalf("metadata = %q, err %v", data, err)
}
if err := lock.Release(); err != nil {
t.Fatal(err)
}
if err := lock.WriteMetadata(nil); !errors.Is(err, ErrLockReleased) {
t.Fatalf("write after release = %v, want ErrLockReleased", err)
}
}
func TestFileLockConcurrentFirstAcquisition(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
const contenders = 200
start := make(chan struct{})
results := make(chan error, contenders)
locks := make(chan *FileLock, contenders)
var ready sync.WaitGroup
ready.Add(contenders)
for range contenders {
go func() {
ready.Done()
<-start
lock, err := TryAcquireFileLock(path)
if lock != nil {
locks <- lock
}
results <- err
}()
}
ready.Wait()
close(start)
acquired := 0
for range contenders {
err := <-results
switch {
case err == nil:
acquired++
case !errors.Is(err, ErrLockHeld):
t.Errorf("concurrent first acquire: %v", err)
}
}
close(locks)
for lock := range locks {
if err := lock.Release(); err != nil {
t.Error(err)
}
}
if acquired != 1 {
t.Fatalf("concurrent first acquire: got %d holders, want 1", acquired)
}
}
func TestFileLockRefusesMultiplyLinkedEntry(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
lockPath := filepath.Join(dir, "lock")
const sentinel = "do not overwrite"
if err := os.WriteFile(target, []byte(sentinel), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Link(target, lockPath); err != nil {
t.Fatalf("create redirected hard link: %v", err)
}
if lock, err := TryAcquireFileLock(lockPath); err == nil {
_ = lock.Release()
t.Fatal("TryAcquireFileLock accepted a multiply-linked entry")
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(data) != sentinel {
t.Fatalf("redirected target was modified: %q", data)
}
}
func TestFileLockAtRejectsEscapingPath(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(filepath.Dir(base), "outside.lock")
if lock, err := TryAcquireFileLockAt(base, outside); err == nil {
_ = lock.Release()
t.Fatal("TryAcquireFileLockAt accepted a path outside its root")
}
if _, err := os.Stat(outside); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("escaping path was created: %v", err)
}
}
// TestFileLockReleasedByProcessExit proves crash recovery is provided by the
// kernel lock lifetime. The child exits without calling Release; the next
// process can then lock the same persistent file without moving or deleting it.
func TestFileLockReleasedByProcessExit(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
cmd := exec.Command(os.Args[0], "-test.run=^TestFileLockHelperProcess$")
cmd.Env = append(os.Environ(), "ZERO_LOCKUTIL_HELPER=1", "ZERO_LOCKUTIL_PATH="+path)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "ready" {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("helper readiness = %q, err %v", ready, err)
}
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("acquire while helper holds lock = %v, want ErrLockHeld", err)
}
if err := stdin.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("helper exit: %v", err)
}
lock, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("acquire after helper exit: %v", err)
}
if err := lock.Release(); err != nil {
t.Fatal(err)
}
}
func TestFileLockHelperProcess(t *testing.T) {
if os.Getenv("ZERO_LOCKUTIL_HELPER") != "1" {
return
}
lock, err := TryAcquireFileLock(os.Getenv("ZERO_LOCKUTIL_PATH"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if _, err := fmt.Fprintln(os.Stdout, "ready"); err != nil {
os.Exit(3)
}
_, _ = io.Copy(io.Discard, os.Stdin)
runtime.KeepAlive(lock) // deliberately exit without Release
}
+54 -179
View File
@@ -4,202 +4,77 @@ package lockutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unsafe"
"syscall"
"time"
"golang.org/x/sys/windows"
)
type platformLockState struct {
overlapped windows.Overlapped
// RestoreLockFile restores a sidelined lock file on Windows. It moves the file
// with a no-replace MoveFileEx so a competing lock created at path in the
// meantime wins: the move fails with os.ErrExist instead of overwriting it. If
// the move fails for any other reason (ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED under concurrent access to the file), it falls back to
// an O_EXCL copy rather than leaving path missing and the live holder's lock
// stranded in reclaimed.
func RestoreLockFile(reclaimed, path string) error {
err := moveFileNoReplace(reclaimed, path)
if err == nil || errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, moveFileNoReplace)
}
const lockByteOffsetHigh = 1
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
rootPtr, err := windows.UTF16PtrFromString(root)
// moveFileNoReplace renames from to to, failing if to already exists. It calls
// MoveFileExW directly via golang.org/x/sys/windows (the standard library's
// syscall package does not export MoveFileEx on this platform) with no flags,
// so an existing destination fails with ERROR_ALREADY_EXISTS, which satisfies
// errors.Is(err, os.ErrExist) (pinned by
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist), instead of overwriting it
// the way os.Rename does on Windows (it passes MOVEFILE_REPLACE_EXISTING to
// match POSIX rename semantics cross-platform).
func moveFileNoReplace(from, to string) error {
fromPtr, err := windows.UTF16PtrFromString(from)
if err != nil {
return nil, err
return err
}
parent, err := windows.CreateFile(
rootPtr,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
toPtr, err := windows.UTF16PtrFromString(to)
if err != nil {
return nil, fmt.Errorf("open lock root: %w", err)
return err
}
defer func() {
if parent != 0 {
resultErr = errors.Join(resultErr, windows.CloseHandle(parent))
}
}()
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
for _, component := range components[:len(components)-1] {
next, openErr := openLockDirectoryAt(parent, component)
if openErr != nil {
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
}
if closeErr := windows.CloseHandle(parent); closeErr != nil {
_ = windows.CloseHandle(next)
parent = 0
return nil, fmt.Errorf("close lock path component: %w", closeErr)
}
parent = next
}
handle, err := openLockFileHandleAt(parent, components[len(components)-1])
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), displayPath)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, errors.New("convert lock handle to file")
}
if err := windows.CloseHandle(parent); err != nil {
parent = 0
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
}
parent = 0
return file, nil
return windows.MoveFileEx(fromPtr, toPtr, 0)
}
func openLockDirectoryAt(parent windows.Handle, name string) (windows.Handle, error) {
handle, err := openLockPathAt(
parent,
name,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_ATTRIBUTE_DIRECTORY,
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err != nil {
return 0, err
}
if err := validateWindowsLockHandle(handle, true); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means the file was concurrently open or pending deletion (which
// surfaces as ERROR_SHARING_VIOLATION or ERROR_ACCESS_DENIED on Windows)
// rather than a hard failure, so ReclaimStaleLock reports a lost race instead
// of an error. Neither errno satisfies errors.Is(err, os.ErrExist), and
// ERROR_SHARING_VIOLATION does not satisfy errors.Is(err, os.ErrPermission)
// either, so the classification compares raw errnos like RemoveLockFile below.
func isReclaimContended(err error) bool {
var errno syscall.Errno
return errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED)
}
func openLockFileHandleAt(parent windows.Handle, name string) (windows.Handle, error) {
for attempt := 0; attempt < 10; attempt++ {
handle, err := openLockPathAt(
parent,
name,
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err == nil {
if err := validateWindowsLockHandle(handle, false); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
// RemoveLockFile removes a lock file on Windows, retrying on the sharing
// violation or access denied errors that are common under heavy concurrent
// contention. Removing an already-missing file reports nil, matching the
// non-Windows implementation, so callers see one cross-platform contract.
func RemoveLockFile(path string) error {
var err error
for i := 0; i < 15; i++ {
err = os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
if !windowsLockPathMissing(err) {
return 0, err
}
handle, err = openLockPathAt(
parent,
name,
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
windows.FILE_CREATE,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err == windows.STATUS_OBJECT_NAME_COLLISION || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
var errno syscall.Errno
if errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED) {
time.Sleep(5 * time.Millisecond)
continue
}
if err != nil {
return 0, err
}
if err := validateWindowsLockHandle(handle, false); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
return err
}
return 0, errors.New("lock file creation did not stabilize")
}
func windowsLockPathMissing(err error) bool {
return err == windows.STATUS_NO_SUCH_FILE ||
err == windows.STATUS_OBJECT_NAME_NOT_FOUND ||
err == windows.STATUS_OBJECT_PATH_NOT_FOUND ||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND)
}
func openLockPathAt(parent windows.Handle, name string, access, disposition, attributes, options uint32) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, err
}
objectAttributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
}
var handle windows.Handle
err = windows.NtCreateFile(
&handle,
access,
objectAttributes,
&windows.IO_STATUS_BLOCK{},
nil,
attributes,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
disposition,
options,
0,
0,
)
return handle, err
}
func validateWindowsLockHandle(handle windows.Handle, wantDirectory bool) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect lock path handle: %w", err)
}
isDirectory := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || isDirectory != wantDirectory {
return errors.New("refusing non-file or reparse-point lock path")
}
if !wantDirectory && info.NumberOfLinks != 1 {
return errors.New("refusing multiply-linked lock file")
}
return nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
state := platformLockState{overlapped: windows.Overlapped{OffsetHigh: lockByteOffsetHigh}}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &state.overlapped)
if err == nil {
return state, false, nil
}
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
return platformLockState{}, true, nil
}
return platformLockState{}, false, err
}
func unlockFile(file *os.File, state *platformLockState) error {
if file == nil {
return nil
}
return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &state.overlapped)
return err
}
@@ -0,0 +1,83 @@
//go:build windows
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist pins the errno coupling
// every caller keys its cleanup on: with no flags, MoveFileEx fails with
// ERROR_ALREADY_EXISTS when the destination exists, and that errno must
// satisfy errors.Is(err, os.ErrExist).
func TestMoveFileNoReplaceMapsAlreadyExistsToErrExist(t *testing.T) {
dir := t.TempDir()
from := filepath.Join(dir, "from")
to := filepath.Join(dir, "to")
if err := os.WriteFile(from, []byte("sidelined"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(to, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := moveFileNoReplace(from, to)
if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
t.Fatalf("MoveFileEx with an existing destination = %v, want ERROR_ALREADY_EXISTS", err)
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("ERROR_ALREADY_EXISTS must satisfy errors.Is(err, os.ErrExist), got: %v", err)
}
if data, _ := os.ReadFile(to); string(data) != "competing" {
t.Fatalf("destination was overwritten: %q", data)
}
}
// TestRemoveLockFileRetriesSharingViolation holds the file open briefly (Go
// opens files without FILE_SHARE_DELETE on Windows, so a concurrent DeleteFile
// fails with ERROR_SHARING_VIOLATION while the handle is open); RemoveLockFile
// must ride its retry loop (15 x 5ms) past the window and succeed once the
// handle closes.
func TestRemoveLockFileRetriesSharingViolation(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(10 * time.Millisecond)
_ = f.Close()
}()
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile should succeed once the contending handle closes: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
}
// TestRemoveLockFileSharingViolationExhausts verifies the retry loop is
// bounded: with the contending handle held for the whole call, RemoveLockFile
// surfaces the sharing violation instead of retrying forever.
func TestRemoveLockFileSharingViolationExhausts(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := RemoveLockFile(path); !errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
t.Fatalf("RemoveLockFile with a held handle = %v, want ERROR_SHARING_VIOLATION", err)
}
}
+82
View File
@@ -0,0 +1,82 @@
package lockutil
import (
"errors"
"os"
)
// restoreLockFile is swappable so tests can force the fail-closed path of
// ReclaimStaleLock, which requires both the fast restore and its no-replace
// fallback (with its own copy fallback) to fail; that cannot be provoked
// portably on a healthy filesystem.
var restoreLockFile = restoreLiveLock
// restoreLiveLock puts a lock that turned out to be live back at path after
// ReclaimStaleLock moved it aside to inspect it. It first tries a fast,
// replacing rename straight from reclaimed to path: a single syscall, which
// keeps the window during which path does not exist (and so is open to a
// fourth process's unrelated O_EXCL create landing on it) as short as
// possible. RestoreLockFile's no-replace restore (with its slower copy
// fallback on some failures) is a correctness-preserving fallback for when
// the fast path itself fails (e.g. a cross-device sidelined name): it is a
// much longer version of the identical race, but still detects rather than
// silently clobbers a competing lock, which the fast path's replacing rename
// cannot do. Neither path makes the race impossible, only unlikely; see
// ReclaimStaleLock's doc comment.
func restoreLiveLock(reclaimed, path string) error {
if err := os.Rename(reclaimed, path); err == nil {
return nil
}
return RestoreLockFile(reclaimed, path)
}
// ReclaimStaleLock atomically reclaims a suspected-stale lock file. It renames
// lockPath aside to "<lockPath>.stale.<suffix>" (only one racer can win the
// rename of a given file, so two racers can never both reclaim the same lock),
// then consults isLive on the moved file; if the lock turns out to be live (a
// holder reacquired it in the gap between the caller's stale check and the
// rename) it is restored rather than stolen. The suffix must be unique per
// acquirer attempt. Returns true only when a genuinely stale lock was removed,
// so the caller knows it is safe to retry its exclusive create immediately; on
// a lost race it returns false. A non-nil error means either the rename aside
// failed for a reason that is not contention (so retrying cannot help and the
// caller should fail fast instead of spinning to its deadline), or a live
// holder's lock could not be restored (both restoreLiveLock's fast path and
// its no-replace fallback failed), so lockPath may be missing; callers must
// fail closed instead of re-acquiring. The sidelined file is removed on every
// restore failure: once the restore has failed it has no protocol function
// (release only consults the lock path), so keeping it would only leak files.
//
// The live-restore path has an inherent, unclosed race: between the rename
// aside above and restoreLiveLock putting the lock back, lockPath does not
// exist, so an unrelated caller's O_EXCL create can legitimately succeed
// there. restoreLiveLock's fast path then silently overwrites that new
// claimant's lock file, which does not corrupt release (it is
// ownership-aware, so the new claimant's later release safely no-ops against
// content it no longer owns) but does mean the new claimant can still run
// its critical section concurrently with the original live holder. Making
// this race actually impossible would need an OS-level advisory lock (flock
// / LockFileEx) held for a holder's whole critical section, checked
// non-destructively instead of by moving the file; restoreLiveLock only
// shrinks the window to roughly one syscall, it does not close it.
func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) bool) (bool, error) {
reclaimed := lockPath + ".stale." + suffix
if err := os.Rename(lockPath, reclaimed); err != nil {
if errors.Is(err, os.ErrNotExist) || isReclaimContended(err) {
return false, nil // another racer already moved/removed it, or it vanished
}
return false, err
}
if isLive(reclaimed) {
// Put the live lock back instead of stealing it, and let the caller wait.
if rerr := restoreLockFile(reclaimed, lockPath); rerr != nil {
_ = RemoveLockFile(reclaimed)
if !errors.Is(rerr, os.ErrExist) {
return false, rerr
}
}
return false, nil
}
_ = RemoveLockFile(reclaimed)
return true, nil
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside: a failure that is not a lost race must reach the caller.
// Mapping it to the benign lost-race result would leave acquirers spinning
// until their deadline and reporting a timeout instead of the real cause. A
// directory squatting on the sidelined name makes os.Rename fail with EISDIR,
// which matches neither os.ErrNotExist nor a contention errno.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(lockPath+".stale.tok", 0o755); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+112
View File
@@ -0,0 +1,112 @@
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestReclaimStaleLock(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
dead := func(string) bool { return false }
live := func(string) bool { return true }
// A lock the predicate reports dead is reclaimed and removed.
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-a", dead); err != nil || !ok {
t.Fatalf("a dead lock should be reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("reclaimed dead lock should be gone, stat err=%v", err)
}
// A LIVE lock (a holder reacquired in the gap) must be RESTORED intact.
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-b", live); err != nil || ok {
t.Fatalf("a live lock must not be reclaimed (ok=%v err=%v)", ok, err)
}
if data, err := os.ReadFile(lockPath); err != nil || string(data) != "live-holder" {
t.Fatalf("live lock must be left intact, got %q err %v", data, err)
}
// A missing lock reports no reclaim (nothing to steal).
_ = os.Remove(lockPath)
if ok, err := ReclaimStaleLock(lockPath, "tok-c", live); err != nil || ok {
t.Fatalf("a missing lock should not report a reclaim (ok=%v err=%v)", ok, err)
}
}
// restoreLiveLock's fast path is a single replacing rename, chosen to
// minimize (not eliminate) the window during which lockPath is absent and
// open to an unrelated O_EXCL create; see ReclaimStaleLock's doc comment for
// why that residual race exists. This test pins the resulting behavior: the
// fast path overwrites a competing file at path rather than detecting it,
// unlike RestoreLockFile's own no-replace contract.
func TestRestoreLiveLockFastPathOverwritesCompetingFile(t *testing.T) {
dir := t.TempDir()
reclaimed := filepath.Join(dir, "lock.stale.tok")
path := filepath.Join(dir, "lock")
if err := os.WriteFile(reclaimed, []byte("original-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("new-claimant"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreLiveLock(reclaimed, path); err != nil {
t.Fatalf("restoreLiveLock failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "original-holder" {
t.Fatalf("path = %q, err %v; want the original holder's content restored", data, err)
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected reclaimed to be consumed by the rename: %v", err)
}
}
func TestReclaimStaleLockFailsClosedOnRestoreError(t *testing.T) {
// When both the no-replace restore and its copy fallback fail (only
// provokable via the seam; a healthy filesystem cannot produce it), the
// caller must receive an error so it fails closed instead of re-acquiring a
// missing lock path, and the sidelined file must not leak.
restoreLockFile = func(reclaimed, path string) error { return errors.New("restore failed") }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err == nil || ok {
t.Fatalf("a failed restore must surface an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("a failed restore must not leak sidelined files: %v", matches)
}
}
func TestReclaimStaleLockDropsSidelinedWhenNewHolderWins(t *testing.T) {
// An os.ErrExist restore failure means a new holder recreated the lock
// path; that is not an error for the caller, and the sidelined file is
// dropped rather than leaked.
restoreLockFile = func(reclaimed, path string) error { return os.ErrExist }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err != nil || ok {
t.Fatalf("losing to a new holder is not an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("the sidelined file must be dropped when a new holder wins: %v", matches)
}
}
+50
View File
@@ -0,0 +1,50 @@
//go:build windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockTreatsSharingViolationAsLostRace pins the errno
// classification the Windows acquire loops depend on: a rename aside that
// fails because the lock file is concurrently open (Go opens files without
// FILE_SHARE_DELETE, so the rename fails with ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED) is contention, not a hard failure, and must report a
// benign lost race so the caller keeps waiting.
func TestReclaimStaleLockTreatsSharingViolationAsLostRace(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("held"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(lockPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if ok, rerr := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true }); rerr != nil || ok {
t.Fatalf("a sharing-violation rename must report a benign lost race (ok=%v err=%v)", ok, rerr)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place: %v", err)
}
}
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside on Windows: a failure that is not contention must reach
// the caller instead of masquerading as a lost race. A quote is an invalid
// filename character, so the sidelined name fails with ERROR_INVALID_NAME.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, `bad"name`, func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+14 -100
View File
@@ -63,9 +63,8 @@ func tokenToStored(t oauth.Token) StoredToken {
}
// OAuthConfig describes how to authenticate to a remote MCP server using OAuth.
// Endpoints may be discovered through RFC 9728 protected-resource metadata and
// RFC 8414 authorization-server metadata; explicit values override or fill in
// anything discovery cannot provide.
// Endpoints may be discovered from the server's metadata document; explicit
// values here override or fill in anything discovery cannot provide.
type OAuthConfig struct {
ClientID string
ClientSecret string
@@ -86,11 +85,6 @@ type authServerMetadata struct {
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
ScopesSupported []string `json:"scopes_supported"`
protectedResourceURL string
protectAuthorizationEndpoint bool
protectTokenEndpoint bool
protectRegistrationEndpoint bool
}
// pkceParams holds a PKCE verifier/challenge pair.
@@ -126,7 +120,7 @@ type authorizationFlow struct {
// discoverAuthorizationServer fetches the RFC 8414 authorization server metadata
// at the well-known path under baseURL, via the shared engine.
func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (authServerMetadata, error) {
meta, err := oauth.DiscoverAuthorizationServer(ctx, withoutDiscoveryRedirects(client), baseURL)
meta, err := oauth.DiscoverAuthorizationServer(ctx, client, baseURL)
if err != nil {
return authServerMetadata{}, err
}
@@ -139,9 +133,10 @@ func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseU
}, nil
}
// validateResolvedEndpoints applies the shared https/loopback scheme rule to
// every non-empty endpoint in the resolved metadata. Protected-resource
// discovery adds its stricter provenance-aware network rule below.
// validateResolvedEndpoints applies the shared https/loopback endpoint rule to
// every non-empty endpoint in the resolved metadata — configured or discovered
// alike — so discovery metadata can never downgrade an MCP login to an insecure
// or attacker-controlled authorization, token, or registration endpoint.
func validateResolvedEndpoints(metadata authServerMetadata) error {
for _, ep := range []struct{ kind, url string }{
{"authorization", metadata.AuthorizationEndpoint},
@@ -158,43 +153,9 @@ func validateResolvedEndpoints(metadata authServerMetadata) error {
return nil
}
func validateProtectedDiscoveredEndpoints(ctx context.Context, metadata authServerMetadata) error {
if metadata.protectedResourceURL == "" {
return nil
}
policy, err := newOAuthDiscoveryNetworkPolicy(metadata.protectedResourceURL)
if err != nil {
return err
}
for _, endpoint := range []struct {
kind string
url string
protected bool
}{
{kind: "authorization", url: metadata.AuthorizationEndpoint, protected: metadata.protectAuthorizationEndpoint},
{kind: "token", url: metadata.TokenEndpoint, protected: metadata.protectTokenEndpoint},
{kind: "registration", url: metadata.RegistrationEndpoint, protected: metadata.protectRegistrationEndpoint},
} {
if !endpoint.protected || strings.TrimSpace(endpoint.url) == "" {
continue
}
parsed, parseErr := url.Parse(endpoint.url)
if parseErr != nil || parsed.Hostname() == "" {
return fmt.Errorf("mcp oauth: %s endpoint: %w", endpoint.kind, errUnsafeOAuthDiscoveryTarget)
}
if err := policy.validateLiteralHost(parsed.Hostname()); err != nil {
return fmt.Errorf("mcp oauth: %s endpoint: %w", endpoint.kind, err)
}
if _, err := policy.resolve(ctx, parsed.Hostname()); err != nil {
return fmt.Errorf("mcp oauth: %s endpoint: %w", endpoint.kind, err)
}
}
return nil
}
// resolveAuthorizationServer follows protected-resource metadata when the MCP
// endpoint advertises it, falls back to direct authorization-server discovery
// for legacy providers, then applies explicit config overrides.
// resolveAuthorizationServer discovers metadata and applies explicit config
// overrides. Configured endpoints take precedence over discovered ones, and act
// as a fallback when discovery fails or omits a value.
func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseURL string, cfg OAuthConfig) (authServerMetadata, error) {
// When the config supplies both the authorization and token endpoints
// directly, skip network discovery entirely: there is nothing to discover,
@@ -213,40 +174,17 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
return metadata, nil
}
discoveryClient := withoutDiscoveryRedirects(client)
discoveryBase := strings.TrimSpace(cfg.IssuerURL)
protectedResourceDiscovery := false
if discoveryBase == "" {
issuer, found, protectedErr := discoverProtectedResourceAuthorizationServer(ctx, discoveryClient, baseURL)
if protectedErr != nil {
return authServerMetadata{}, protectedErr
}
if found {
discoveryBase = issuer
protectedResourceDiscovery = true
discoveryClient, protectedErr = newAdvertisedOAuthDiscoveryClient(discoveryClient, baseURL)
if protectedErr != nil {
return authServerMetadata{}, protectedErr
}
} else {
discoveryBase = baseURL
}
discoveryBase = baseURL
}
metadata, err := discoverAuthorizationServer(ctx, discoveryClient, discoveryBase)
metadata, err := discoverAuthorizationServer(ctx, client, discoveryBase)
if err != nil {
// Discovery failures are non-fatal when the config supplies the endpoints
// directly; otherwise surface the discovery error.
metadata = authServerMetadata{}
}
if protectedResourceDiscovery {
if strings.TrimSpace(metadata.Issuer) == "" {
return authServerMetadata{}, errors.New("mcp oauth: authorization server metadata is missing its required issuer")
}
if metadata.Issuer != discoveryBase {
return authServerMetadata{}, errors.New("mcp oauth: authorization server metadata issuer does not match protected resource metadata")
}
}
if endpoint := strings.TrimSpace(cfg.AuthorizationEndpoint); endpoint != "" {
metadata.AuthorizationEndpoint = endpoint
@@ -257,12 +195,6 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
if endpoint := strings.TrimSpace(cfg.RegistrationEndpoint); endpoint != "" {
metadata.RegistrationEndpoint = endpoint
}
if protectedResourceDiscovery {
metadata.protectedResourceURL = strings.TrimSpace(baseURL)
metadata.protectAuthorizationEndpoint = strings.TrimSpace(cfg.AuthorizationEndpoint) == ""
metadata.protectTokenEndpoint = strings.TrimSpace(cfg.TokenEndpoint) == ""
metadata.protectRegistrationEndpoint = strings.TrimSpace(cfg.RegistrationEndpoint) == ""
}
if strings.TrimSpace(metadata.AuthorizationEndpoint) == "" {
return authServerMetadata{}, errors.New("no authorization endpoint discovered or configured")
@@ -273,9 +205,6 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
if err := validateResolvedEndpoints(metadata); err != nil {
return authServerMetadata{}, err
}
if err := validateProtectedDiscoveredEndpoints(ctx, metadata); err != nil {
return authServerMetadata{}, err
}
return metadata, nil
}
@@ -434,25 +363,10 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
}
defer listener.Close()
redirectURI := fmt.Sprintf("http://%s/callback", listener.Addr().String())
protectedEndpointClient := httpClient
if metadata.protectRegistrationEndpoint || metadata.protectTokenEndpoint {
protectedEndpointClient, err = newAdvertisedOAuthDiscoveryClient(httpClient, metadata.protectedResourceURL)
if err != nil {
return StoredToken{}, err
}
}
tokenClient := httpClient
if metadata.protectTokenEndpoint {
tokenClient = protectedEndpointClient
}
if strings.TrimSpace(cfg.ClientID) == "" {
if registration := strings.TrimSpace(metadata.RegistrationEndpoint); registration != "" {
registrationClient := httpClient
if metadata.protectRegistrationEndpoint {
registrationClient = protectedEndpointClient
}
clientID, clientSecret, regErr := registerClient(loginCtx, registrationClient, registration, redirectURI, cfg.Scopes)
clientID, clientSecret, regErr := registerClient(loginCtx, httpClient, registration, redirectURI, cfg.Scopes)
if regErr != nil {
return StoredToken{}, regErr
}
@@ -476,7 +390,7 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
}
flow := &authorizationFlow{
httpClient: tokenClient,
httpClient: httpClient,
metadata: metadata,
config: cfg,
pkce: pkce,
-290
View File
@@ -1,290 +0,0 @@
package mcp
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
)
var errUnsafeOAuthDiscoveryTarget = errors.New("mcp oauth: refused non-public server-advertised discovery target")
var nonPublicOAuthDiscoveryPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/4"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001:2::/48"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
}
type oauthDiscoveryNetworkPolicy struct {
allowLoopback bool
lookupNetIP func(context.Context, string, string) ([]netip.Addr, error)
}
type advertisedOAuthDiscoveryTransport struct {
base http.RoundTripper
policy oauthDiscoveryNetworkPolicy
}
// newAdvertisedOAuthDiscoveryClient binds server-advertised discovery to the
// public network. Loopback targets remain available only when the operator
// explicitly configured a loopback MCP resource.
func newAdvertisedOAuthDiscoveryClient(client *http.Client, resourceURL string) (*http.Client, error) {
policy, err := newOAuthDiscoveryNetworkPolicy(resourceURL)
if err != nil {
return nil, err
}
copied := withoutDiscoveryRedirects(client)
transport := copied.Transport
if transport == nil {
transport = http.DefaultTransport
}
copied.Transport = advertisedOAuthDiscoveryTransport{base: transport, policy: policy}
return copied, nil
}
func newOAuthDiscoveryNetworkPolicy(resourceURL string) (oauthDiscoveryNetworkPolicy, error) {
parsed, err := url.Parse(strings.TrimSpace(resourceURL))
if err != nil || parsed.Hostname() == "" {
return oauthDiscoveryNetworkPolicy{}, errors.New("mcp oauth: invalid MCP resource URL for discovery policy")
}
return oauthDiscoveryNetworkPolicy{allowLoopback: isExplicitOAuthLoopbackHost(parsed.Hostname())}, nil
}
func validateAdvertisedOAuthDiscoveryURL(resourceURL string, targetURL string) error {
policy, err := newOAuthDiscoveryNetworkPolicy(resourceURL)
if err != nil {
return err
}
parsed, err := url.Parse(targetURL)
if err != nil || parsed.Hostname() == "" {
return errUnsafeOAuthDiscoveryTarget
}
return policy.validateLiteralHost(parsed.Hostname())
}
func (transport advertisedOAuthDiscoveryTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if request == nil || request.URL == nil {
return nil, errors.New("mcp oauth: discovery request has no URL")
}
if err := transport.policy.validateLiteralHost(request.URL.Hostname()); err != nil {
return nil, err
}
baseTransport, ok := transport.base.(*http.Transport)
if !ok {
return nil, errors.New("mcp oauth: cannot enforce discovery network policy with a custom HTTP transport")
}
addresses, err := transport.policy.resolve(request.Context(), request.URL.Hostname())
if err != nil {
return nil, err
}
var lastErr error
for index, address := range addresses {
attempt := request
if index > 0 && request.Body != nil {
if request.GetBody == nil {
break
}
body, bodyErr := request.GetBody()
if bodyErr != nil {
return nil, bodyErr
}
attempt = request.Clone(request.Context())
attempt.Body = body
}
response, roundTripErr := roundTripPinnedOAuthDiscovery(attempt, baseTransport, address)
if roundTripErr == nil {
return response, nil
}
lastErr = roundTripErr
}
if lastErr == nil {
lastErr = errUnsafeOAuthDiscoveryTarget
}
return nil, lastErr
}
func roundTripPinnedOAuthDiscovery(request *http.Request, base *http.Transport, address netip.Addr) (*http.Response, error) {
pinnedRequest := request.Clone(request.Context())
pinnedURL := *request.URL
originalHost := request.URL.Hostname()
port := request.URL.Port()
if port == "" {
switch strings.ToLower(request.URL.Scheme) {
case "http":
port = "80"
case "https":
port = "443"
default:
return nil, errUnsafeOAuthDiscoveryTarget
}
}
pinnedURL.Host = net.JoinHostPort(address.String(), port)
pinnedRequest.URL = &pinnedURL
if pinnedRequest.Host == "" {
pinnedRequest.Host = request.URL.Host
}
pinnedTransport := base.Clone()
pinnedTransport.DisableKeepAlives = true
var proxyURL *url.URL
if base.Proxy != nil {
var err error
proxyURL, err = base.Proxy(request)
if err != nil {
return nil, fmt.Errorf("mcp oauth: resolve discovery proxy: %w", err)
}
if proxyURL == nil {
pinnedTransport.Proxy = nil
} else {
pinnedTransport.Proxy = http.ProxyURL(proxyURL)
}
}
if pinnedTransport.TLSClientConfig == nil {
pinnedTransport.TLSClientConfig = &tls.Config{ServerName: originalHost} //nolint:gosec // standard verification remains enabled
} else {
pinnedTransport.TLSClientConfig = pinnedTransport.TLSClientConfig.Clone()
pinnedTransport.TLSClientConfig.ServerName = originalHost
}
// A custom TLS dialer must not resolve the advertised hostname again after
// the policy has pinned it. For an HTTPS proxy, however, that proxy remains
// the TLS first hop and the transport sends CONNECT to the pinned target.
dialContext := pinnedTransport.DialContext
if dialContext == nil {
dialContext = (&net.Dialer{}).DialContext
}
pinnedAddress := net.JoinHostPort(address.String(), port)
targetTLSConfig := pinnedTransport.TLSClientConfig
pinnedTransport.DialTLSContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
if proxyURL != nil && strings.EqualFold(proxyURL.Scheme, "https") {
proxyTLSConfig := targetTLSConfig.Clone()
proxyTLSConfig.ServerName = proxyURL.Hostname()
return dialOAuthDiscoveryTLS(ctx, dialContext, network, address, proxyTLSConfig)
}
return dialOAuthDiscoveryTLS(ctx, dialContext, network, pinnedAddress, targetTLSConfig)
}
return pinnedTransport.RoundTrip(pinnedRequest)
}
func dialOAuthDiscoveryTLS(ctx context.Context, dialContext func(context.Context, string, string) (net.Conn, error), network string, address string, config *tls.Config) (net.Conn, error) {
connection, err := dialContext(ctx, network, address)
if err != nil {
return nil, err
}
tlsConnection := tls.Client(connection, config)
if err := tlsConnection.HandshakeContext(ctx); err != nil {
connection.Close()
return nil, err
}
return tlsConnection, nil
}
func (policy oauthDiscoveryNetworkPolicy) resolve(ctx context.Context, host string) ([]netip.Addr, error) {
if address, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
address = address.Unmap()
if err := policy.validateAddress(address); err != nil {
return nil, err
}
return []netip.Addr{address}, nil
}
lookupNetIP := policy.lookupNetIP
if lookupNetIP == nil {
lookupNetIP = net.DefaultResolver.LookupNetIP
}
addresses, err := lookupNetIP(ctx, "ip", host)
if err != nil {
return nil, errors.New("mcp oauth: resolve server-advertised discovery target failed")
}
if len(addresses) == 0 {
return nil, errors.New("mcp oauth: server-advertised discovery target resolved to no addresses")
}
explicitLoopback := isExplicitOAuthLoopbackHost(host)
resolved := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
address = address.Unmap()
if explicitLoopback && !address.IsLoopback() {
return nil, errUnsafeOAuthDiscoveryTarget
}
if err := policy.validateAddress(address); err != nil {
return nil, err
}
resolved = append(resolved, address)
}
return resolved, nil
}
func (policy oauthDiscoveryNetworkPolicy) validateLiteralHost(host string) error {
normalized := strings.ToLower(strings.TrimSuffix(strings.Trim(host, "[]"), "."))
if normalized == "" {
return errUnsafeOAuthDiscoveryTarget
}
if isCloudMetadataHostname(normalized) {
return errUnsafeOAuthDiscoveryTarget
}
if isExplicitOAuthLoopbackHost(normalized) && !policy.allowLoopback {
return errUnsafeOAuthDiscoveryTarget
}
if address, err := netip.ParseAddr(normalized); err == nil {
return policy.validateAddress(address.Unmap())
}
return nil
}
func (policy oauthDiscoveryNetworkPolicy) validateAddress(address netip.Addr) error {
if policy.allowLoopback && address.IsLoopback() {
return nil
}
if !address.IsValid() || !address.IsGlobalUnicast() || address.IsPrivate() {
return errUnsafeOAuthDiscoveryTarget
}
for _, prefix := range nonPublicOAuthDiscoveryPrefixes {
if prefix.Contains(address) {
return errUnsafeOAuthDiscoveryTarget
}
}
return nil
}
func isExplicitOAuthLoopbackHost(host string) bool {
normalized := strings.ToLower(strings.TrimSuffix(strings.Trim(host, "[]"), "."))
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
return true
}
address, err := netip.ParseAddr(normalized)
return err == nil && address.Unmap().IsLoopback()
}
func isCloudMetadataHostname(host string) bool {
switch strings.ToLower(strings.TrimSuffix(host, ".")) {
case "metadata", "metadata.google.internal", "metadata.azure.internal":
return true
default:
return false
}
}
@@ -1,180 +0,0 @@
package mcp
import (
"bufio"
"context"
"errors"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"sync/atomic"
"testing"
)
func TestOAuthDiscoveryNetworkPolicyRejectsNonPublicAddresses(t *testing.T) {
policy := oauthDiscoveryNetworkPolicy{}
for _, value := range []string{
"127.0.0.1",
"10.0.0.1",
"169.254.169.254",
"192.0.2.10",
"224.0.0.1",
"::1",
"fc00::1",
"fe80::1",
"2001:db8::1",
} {
t.Run(value, func(t *testing.T) {
if err := policy.validateAddress(netip.MustParseAddr(value)); err == nil {
t.Fatalf("address %s should be rejected", value)
}
})
}
for _, value := range []string{"8.8.8.8", "2606:4700:4700::1111"} {
if err := policy.validateAddress(netip.MustParseAddr(value)); err != nil {
t.Fatalf("public address %s rejected: %v", value, err)
}
}
loopbackPolicy := oauthDiscoveryNetworkPolicy{allowLoopback: true}
if err := loopbackPolicy.validateAddress(netip.MustParseAddr("127.0.0.1")); err != nil {
t.Fatalf("explicit loopback resource should allow loopback discovery: %v", err)
}
if err := loopbackPolicy.validateAddress(netip.MustParseAddr("10.0.0.1")); err == nil {
t.Fatal("loopback development must not permit arbitrary private targets")
}
}
func TestProtectedDiscoveryRejectsNonPublicOAuthEndpoints(t *testing.T) {
for _, endpoint := range []struct {
name string
metadata authServerMetadata
}{
{
name: "authorization",
metadata: authServerMetadata{
AuthorizationEndpoint: "http://127.0.0.1/authorize",
protectAuthorizationEndpoint: true,
},
},
{
name: "token",
metadata: authServerMetadata{
TokenEndpoint: "https://10.0.0.1/token",
protectTokenEndpoint: true,
},
},
{
name: "registration",
metadata: authServerMetadata{
RegistrationEndpoint: "https://169.254.169.254/register",
protectRegistrationEndpoint: true,
},
},
} {
t.Run(endpoint.name, func(t *testing.T) {
endpoint.metadata.protectedResourceURL = "https://mcp.example/mcp"
if err := validateProtectedDiscoveredEndpoints(context.Background(), endpoint.metadata); err == nil {
t.Fatalf("protected %s endpoint should be rejected", endpoint.name)
}
})
}
}
func TestAdvertisedOAuthDiscoveryRejectsHostnameResolvingPrivateAtUseTime(t *testing.T) {
var dialed atomic.Bool
policy := oauthDiscoveryNetworkPolicy{
lookupNetIP: func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("10.0.0.12")}, nil
},
}
client := &http.Client{Transport: advertisedOAuthDiscoveryTransport{
base: &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) {
dialed.Store(true)
return nil, io.EOF
}},
policy: policy,
}}
_, err := client.Get("https://auth.example/.well-known/oauth-authorization-server")
if err == nil || !strings.Contains(err.Error(), errUnsafeOAuthDiscoveryTarget.Error()) {
t.Fatalf("error = %v, want private-resolution rejection", err)
}
if dialed.Load() {
t.Fatal("private resolved address reached the dialer")
}
}
func TestAdvertisedOAuthDiscoveryPinsResolvedAddress(t *testing.T) {
const publicAddress = "93.184.216.34"
var dialedAddress string
policy := oauthDiscoveryNetworkPolicy{
lookupNetIP: func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr(publicAddress)}, nil
},
}
baseTransport := &http.Transport{DialContext: func(_ context.Context, _, address string) (net.Conn, error) {
dialedAddress = address
clientConn, serverConn := net.Pipe()
go func() {
defer serverConn.Close()
reader := bufio.NewReader(serverConn)
for {
line, err := reader.ReadString('\n')
if err != nil || line == "\r\n" {
break
}
}
_, _ = io.WriteString(serverConn, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
}()
return clientConn, nil
}}
client := &http.Client{Transport: advertisedOAuthDiscoveryTransport{base: baseTransport, policy: policy}}
response, err := client.Get("http://auth.example/metadata")
if err != nil {
t.Fatalf("GET pinned discovery target: %v", err)
}
response.Body.Close()
if dialedAddress != net.JoinHostPort(publicAddress, "80") {
t.Fatalf("dialed %q, want resolved address", dialedAddress)
}
}
func TestPinnedOAuthDiscoveryPreservesHTTPSProxyFirstHop(t *testing.T) {
for _, test := range []struct {
name string
proxy string
firstHop string
}{
{name: "HTTP", proxy: "http://proxy.example:8080", firstHop: "proxy.example:8080"},
{name: "HTTPS", proxy: "https://proxy.example:8443", firstHop: "proxy.example:8443"},
{name: "SOCKS", proxy: "socks5://proxy.example:1080", firstHop: "proxy.example:1080"},
} {
t.Run(test.name, func(t *testing.T) {
proxyURL, err := url.Parse(test.proxy)
if err != nil {
t.Fatalf("parse proxy URL: %v", err)
}
var dialedAddress string
baseTransport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DialContext: func(_ context.Context, _, address string) (net.Conn, error) {
dialedAddress = address
return nil, errors.New("stop after observing first hop")
},
}
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://auth.example/metadata", nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
_, _ = roundTripPinnedOAuthDiscovery(request, baseTransport, netip.MustParseAddr("93.184.216.34"))
if dialedAddress != test.firstHop {
t.Fatalf("dialed %q, want %s proxy first hop", dialedAddress, test.name)
}
})
}
}
-233
View File
@@ -1,233 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/Gitlawb/zero/internal/oauth"
)
const protectedResourceMetadataLimit = 1 << 20
type protectedResourceMetadata struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
}
// withoutDiscoveryRedirects returns a shallow client copy that exposes 3xx
// responses to the caller. Every discovered URL is validated before use, so a
// redirect must never replace that validated target with an unchecked one.
func withoutDiscoveryRedirects(client *http.Client) *http.Client {
if client == nil {
client = http.DefaultClient
}
copied := *client
// Discovery requests are deliberately unauthenticated. A caller-supplied
// jar must not turn a plain metadata GET into a credential-bearing request.
copied.Jar = nil
copied.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return &copied
}
// discoverProtectedResourceAuthorizationServer follows the RFC 9728 URL
// advertised by a protected MCP resource. found is false when the endpoint does
// not advertise protected-resource metadata, allowing legacy direct discovery.
func discoverProtectedResourceAuthorizationServer(ctx context.Context, client *http.Client, resourceURL string) (issuer string, found bool, err error) {
resourceURL = strings.TrimSpace(resourceURL)
client = withoutDiscoveryRedirects(client)
metadataURL, found, err := probeProtectedResourceMetadataURL(ctx, client, resourceURL)
if err != nil || !found {
return "", found, err
}
if err := validateResourceMetadataOrigin(resourceURL, metadataURL); err != nil {
return "", true, err
}
client, err = newAdvertisedOAuthDiscoveryClient(client, resourceURL)
if err != nil {
return "", true, err
}
metadata, err := fetchProtectedResourceMetadata(ctx, client, metadataURL)
if err != nil {
return "", true, err
}
if metadata.Resource != resourceURL {
return "", true, errors.New("mcp oauth: protected resource metadata resource does not match the MCP server URL")
}
if len(metadata.AuthorizationServers) == 0 {
return "", true, errors.New("mcp oauth: protected resource metadata has no authorization_servers")
}
issuer = metadata.AuthorizationServers[0]
if strings.TrimSpace(issuer) == "" {
return "", true, errors.New("mcp oauth: protected resource metadata has an empty authorization server")
}
if issuer != strings.TrimSpace(issuer) {
return "", true, errors.New("mcp oauth: protected resource authorization server must not contain surrounding whitespace")
}
if err := oauth.ValidateEndpointURL(issuer); err != nil {
return "", true, fmt.Errorf("mcp oauth: protected resource authorization server: %w", err)
}
if err := validateAdvertisedOAuthDiscoveryURL(resourceURL, issuer); err != nil {
return "", true, err
}
return issuer, true, nil
}
func validateResourceMetadataOrigin(resourceURL string, metadataURL string) error {
resource, resourceErr := url.Parse(resourceURL)
metadata, metadataErr := url.Parse(metadataURL)
if resourceErr != nil || metadataErr != nil || !sameMCPOrigin(resource, metadata) {
return errors.New("mcp oauth: protected resource metadata URL must be same-origin with the MCP server URL")
}
return nil
}
func probeProtectedResourceMetadataURL(ctx context.Context, client *http.Client, resourceURL string) (string, bool, error) {
if client == nil {
client = http.DefaultClient
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(resourceURL), nil)
if err != nil {
return "", false, errors.New("mcp oauth: invalid MCP resource URL")
}
request.Header.Set("Accept", "application/json, text/event-stream")
request.Header.Set("MCP-Protocol-Version", defaultProtocolVersion)
response, err := client.Do(request)
if err != nil {
// A resource that cannot be probed may still support the legacy direct
// authorization-server discovery flow.
return "", false, nil
}
defer response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
return "", false, nil
}
metadataURL, found, err := parseResourceMetadataURL(response.Header.Values("WWW-Authenticate"))
if err != nil {
return "", false, fmt.Errorf("mcp oauth: invalid WWW-Authenticate resource_metadata parameter: %w", err)
}
if !found {
return "", false, nil
}
if err := oauth.ValidateEndpointURL(metadataURL); err != nil {
return "", false, fmt.Errorf("mcp oauth: protected resource metadata URL: %w", err)
}
return metadataURL, true, nil
}
func fetchProtectedResourceMetadata(ctx context.Context, client *http.Client, metadataURL string) (protectedResourceMetadata, error) {
if client == nil {
client = http.DefaultClient
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
if err != nil {
return protectedResourceMetadata{}, errors.New("mcp oauth: invalid protected resource metadata URL")
}
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return protectedResourceMetadata{}, errors.New("mcp oauth: fetch protected resource metadata failed")
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return protectedResourceMetadata{}, fmt.Errorf("mcp oauth: protected resource metadata returned HTTP %d", response.StatusCode)
}
var metadata protectedResourceMetadata
if err := json.NewDecoder(io.LimitReader(response.Body, protectedResourceMetadataLimit)).Decode(&metadata); err != nil {
return protectedResourceMetadata{}, errors.New("mcp oauth: decode protected resource metadata failed")
}
return metadata, nil
}
func parseResourceMetadataURL(headerValues []string) (string, bool, error) {
var selected string
for _, headerValue := range headerValues {
for index := 0; index < len(headerValue); {
if headerValue[index] == '"' {
_, next, err := readAuthQuotedString(headerValue, index)
if err != nil {
// Malformed parameters for another authentication scheme do
// not suppress a valid challenge in a separate header value.
break
}
index = next
continue
}
if !isAuthTokenByte(headerValue[index]) {
index++
continue
}
start := index
for index < len(headerValue) && isAuthTokenByte(headerValue[index]) {
index++
}
name := headerValue[start:index]
for index < len(headerValue) && (headerValue[index] == ' ' || headerValue[index] == '\t') {
index++
}
if index >= len(headerValue) || headerValue[index] != '=' {
continue
}
index++
for index < len(headerValue) && (headerValue[index] == ' ' || headerValue[index] == '\t') {
index++
}
if !strings.EqualFold(name, "resource_metadata") {
continue
}
if index >= len(headerValue) || headerValue[index] != '"' {
return "", false, errors.New("resource_metadata must be a quoted URL")
}
value, next, err := readAuthQuotedString(headerValue, index)
if err != nil {
return "", false, err
}
value = strings.TrimSpace(value)
if value == "" {
return "", false, errors.New("resource_metadata URL is empty")
}
if selected != "" && selected != value {
return "", false, errors.New("conflicting resource_metadata URLs")
}
selected = value
index = next
}
}
return selected, selected != "", nil
}
func readAuthQuotedString(value string, quote int) (string, int, error) {
var decoded strings.Builder
for index := quote + 1; index < len(value); index++ {
switch value[index] {
case '"':
return decoded.String(), index + 1, nil
case '\\':
index++
if index >= len(value) {
return "", 0, errors.New("unterminated quoted resource_metadata URL")
}
decoded.WriteByte(value[index])
default:
if value[index] < 0x20 || value[index] == 0x7f {
return "", 0, errors.New("resource_metadata URL contains a control character")
}
decoded.WriteByte(value[index])
}
}
return "", 0, errors.New("unterminated quoted resource_metadata URL")
}
func isAuthTokenByte(value byte) bool {
if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' {
return true
}
return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(value))
}
@@ -1,742 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestParseResourceMetadataURL(t *testing.T) {
got, found, err := parseResourceMetadataURL([]string{
`Basic realm="resource_metadata=not-a-parameter"`,
`Bearer realm="OAuth", scope="read", Resource_Metadata="https://mcp.example/resource-metadata"`,
})
if err != nil || !found {
t.Fatalf("parseResourceMetadataURL() found=%v err=%v", found, err)
}
if got != "https://mcp.example/resource-metadata" {
t.Fatalf("resource metadata URL = %q", got)
}
got, found, err = parseResourceMetadataURL([]string{
`Basic realm="unterminated`,
`Bearer resource_metadata="https://mcp.example/fallback-metadata"`,
})
if err != nil || !found || got != "https://mcp.example/fallback-metadata" {
t.Fatalf("unrelated malformed challenge blocked discovery: got=%q found=%v err=%v", got, found, err)
}
for name, headers := range map[string][]string{
"unquoted": {`Bearer resource_metadata=https://mcp.example/metadata`},
"unterminated": {`Bearer resource_metadata="https://mcp.example/metadata`},
"conflicting": {
`Bearer resource_metadata="https://mcp.example/one"`,
`DPoP resource_metadata="https://mcp.example/two"`,
},
} {
t.Run(name, func(t *testing.T) {
if _, _, err := parseResourceMetadataURL(headers); err == nil {
t.Fatal("expected malformed resource_metadata rejection")
}
})
}
}
func TestProtectedResourceDiscoveryRejectsInvalidMetadata(t *testing.T) {
for _, test := range []struct {
name string
challenge func(base string) string
metadata func(base string) string
wantError string
}{
{
name: "malformed challenge",
challenge: func(string) string { return `Bearer resource_metadata="unterminated` },
wantError: "WWW-Authenticate",
},
{
name: "insecure metadata URL",
challenge: func(string) string { return `Bearer resource_metadata="http://metadata.example/resource"` },
wantError: "metadata URL",
},
{
name: "invalid JSON",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(string) string { return `{` },
wantError: "decode protected resource metadata",
},
{
name: "resource mismatch",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":"` + base + `/other","authorization_servers":["https://auth.example"]}`
},
wantError: "does not match",
},
{
name: "resource leading whitespace mismatch",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":" ` + base + `/mcp","authorization_servers":["https://auth.example"]}`
},
wantError: "does not match",
},
{
name: "resource trailing whitespace mismatch",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":"` + base + `/mcp ","authorization_servers":["https://auth.example"]}`
},
wantError: "does not match",
},
{
name: "authorization servers missing",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":"` + base + `/mcp"}`
},
wantError: "authorization_servers",
},
{
name: "insecure authorization server",
challenge: func(base string) string { return `Bearer resource_metadata="` + base + `/metadata"` },
metadata: func(base string) string {
return `{"resource":"` + base + `/mcp","authorization_servers":["http://auth.example"]}`
},
wantError: "authorization server",
},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", test.challenge(base))
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
if test.metadata == nil {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(test.metadata(base)))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
_, _, err := discoverProtectedResourceAuthorizationServer(context.Background(), server.Client(), server.URL+"/mcp")
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("error = %v, want %q", err, test.wantError)
}
})
}
}
func TestProtectedResourceProbeSendsNoCredentials(t *testing.T) {
var authorization, cookie string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authorization = r.Header.Get("Authorization")
cookie = r.Header.Get("Cookie")
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{Name: "session", Value: "secret"}})
client := server.Client()
client.Jar = jar
_, found, err := discoverProtectedResourceAuthorizationServer(context.Background(), client, server.URL+"/mcp")
if err != nil || found {
t.Fatalf("discovery found=%v err=%v", found, err)
}
if authorization != "" || cookie != "" {
t.Fatalf("probe sent credentials: authorization=%q cookie=%q", authorization, cookie)
}
}
func TestDirectAuthorizationServerDiscoverySendsNoCookies(t *testing.T) {
var cookie string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie = r.Header.Get("Cookie")
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer server.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{Name: "session", Value: "secret"}})
client := server.Client()
client.Jar = jar
if _, err := resolveAuthorizationServer(context.Background(), client, server.URL, OAuthConfig{}); err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if cookie != "" {
t.Fatalf("direct discovery sent cookie %q", cookie)
}
}
func TestProtectedResourceDiscoveryRejectsAdvertisedLoopbackMetadata(t *testing.T) {
var metadataHits atomic.Int64
client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
response := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: request,
}
switch request.URL.String() {
case "https://mcp.example/mcp":
response.StatusCode = http.StatusUnauthorized
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="http://127.0.0.1/metadata"`)
case "http://127.0.0.1/metadata":
metadataHits.Add(1)
response.Body = io.NopCloser(strings.NewReader(`{"resource":"https://mcp.example/mcp","authorization_servers":["https://auth.example"]}`))
default:
response.StatusCode = http.StatusNotFound
}
return response, nil
})}
_, _, err := discoverProtectedResourceAuthorizationServer(context.Background(), client, "https://mcp.example/mcp")
if err == nil {
t.Fatal("advertised loopback metadata URL should be rejected")
}
if metadataHits.Load() != 0 {
t.Fatalf("loopback metadata target was fetched %d time(s)", metadataHits.Load())
}
}
func TestProtectedResourceDiscoveryRejectsAdvertisedLoopbackIssuer(t *testing.T) {
var issuerHits atomic.Int64
client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
response := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: request,
}
switch request.URL.String() {
case "https://mcp.example/mcp":
response.StatusCode = http.StatusUnauthorized
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="https://mcp.example/metadata"`)
case "https://mcp.example/metadata":
response.Body = io.NopCloser(strings.NewReader(`{"resource":"https://mcp.example/mcp","authorization_servers":["http://127.0.0.1"]}`))
case "http://127.0.0.1/.well-known/oauth-authorization-server":
issuerHits.Add(1)
response.Body = io.NopCloser(strings.NewReader(`{"issuer":"http://127.0.0.1","authorization_endpoint":"http://127.0.0.1/authorize","token_endpoint":"http://127.0.0.1/token"}`))
default:
response.StatusCode = http.StatusNotFound
}
return response, nil
})}
_, err := resolveAuthorizationServer(context.Background(), client, "https://mcp.example/mcp", OAuthConfig{})
if err == nil {
t.Fatal("advertised loopback issuer should be rejected")
}
if issuerHits.Load() != 0 {
t.Fatalf("loopback issuer target was fetched %d time(s)", issuerHits.Load())
}
}
func TestProtectedResourceDiscoveryRejectsAdvertisedPrivateTargets(t *testing.T) {
for _, target := range []string{
"http://10.0.0.8/metadata",
"https://169.254.169.254/metadata",
"https://metadata.google.internal/metadata",
} {
t.Run(target, func(t *testing.T) {
if err := validateAdvertisedOAuthDiscoveryURL("https://mcp.example/mcp", target); err == nil {
t.Fatalf("advertised target %q should be rejected", target)
}
})
}
}
func TestProtectedDiscoveryPathsSendNoCookies(t *testing.T) {
var probeCookie, resourceCookie, issuerCookie string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
probeCookie = r.Header.Get("Cookie")
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
resourceCookie = r.Header.Get("Cookie")
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{base},
})
case "/.well-known/oauth-authorization-server":
issuerCookie = r.Header.Get("Cookie")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{Name: "session", Value: "secret"}})
client := server.Client()
client.Jar = jar
if _, err := resolveAuthorizationServer(context.Background(), client, server.URL+"/mcp", OAuthConfig{}); err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if probeCookie != "" || resourceCookie != "" || issuerCookie != "" {
t.Fatalf("discovery sent cookies: probe=%q resource=%q issuer=%q", probeCookie, resourceCookie, issuerCookie)
}
}
func TestProtectedDiscoveryPreservesExplicitEndpointOverride(t *testing.T) {
fixture := newPublicProtectedOAuthFixture(t, "", "https://93.184.216.35/token", "", "")
explicitToken := "http://127.0.0.1:19432/token"
metadata, err := resolveAuthorizationServer(context.Background(), fixture.client, fixture.resourceURL, OAuthConfig{TokenEndpoint: explicitToken})
if err != nil {
t.Fatalf("resolve with explicit endpoint override: %v", err)
}
if metadata.TokenEndpoint != explicitToken || metadata.protectTokenEndpoint {
t.Fatalf("explicit token override lost provenance: endpoint=%q protected=%v", metadata.TokenEndpoint, metadata.protectTokenEndpoint)
}
}
func TestProtectedDiscoveryRejectsLoopbackRegistrationEndpointBeforeUse(t *testing.T) {
var registrationHits atomic.Int64
registration := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
registrationHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"client_id": "registered-client"})
}))
defer registration.Close()
fixture := newPublicProtectedOAuthFixture(t, registration.URL+"/register", "https://93.184.216.35/token", "", "")
_, err := Login(context.Background(), LoginOptions{
ServerName: "registration-ssrf",
ServerURL: fixture.resourceURL,
HTTPClient: fixture.client,
OpenBrowser: func(string) error {
return errors.New("authorization browser should not open")
},
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("protected discovery should reject a loopback registration endpoint")
}
if registrationHits.Load() != 0 {
t.Fatalf("loopback registration endpoint received %d request(s)", registrationHits.Load())
}
}
func TestProtectedDiscoveryRejectsLoopbackTokenEndpointBeforeUse(t *testing.T) {
var tokenHits atomic.Int64
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
tokenHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "unexpected",
"token_type": "Bearer",
})
}))
defer tokenServer.Close()
fixture := newPublicProtectedOAuthFixture(t, "", tokenServer.URL+"/token", "", "")
_, err := Login(context.Background(), LoginOptions{
ServerName: "token-ssrf",
ServerURL: fixture.resourceURL,
Config: OAuthConfig{ClientID: "configured-client"},
HTTPClient: fixture.client,
OpenBrowser: func(authURL string) error {
parsed, parseErr := url.Parse(authURL)
if parseErr != nil {
return parseErr
}
callbackURL := parsed.Query().Get("redirect_uri") + "?code=code&state=" + url.QueryEscape(parsed.Query().Get("state"))
go func() {
for attempt := 0; attempt < 20; attempt++ {
response, requestErr := http.Get(callbackURL)
if requestErr == nil {
response.Body.Close()
return
}
time.Sleep(25 * time.Millisecond)
}
}()
return nil
},
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("protected discovery should reject a loopback token endpoint")
}
if tokenHits.Load() != 0 {
t.Fatalf("loopback token endpoint received %d request(s)", tokenHits.Load())
}
}
func TestProtectedDiscoveryRegistrationRefusesPublicToPrivateRedirect(t *testing.T) {
var victimHits atomic.Int64
victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
victimHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"client_id": "redirected-client"})
}))
defer victim.Close()
fixture := newPublicProtectedOAuthFixture(t, "", "https://93.184.216.35/token", victim.URL+"/register", "")
_, err := Login(context.Background(), LoginOptions{
ServerName: "registration-redirect",
ServerURL: fixture.resourceURL,
HTTPClient: fixture.client,
OpenBrowser: func(string) error {
return errors.New("authorization browser should not open")
},
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("protected registration redirect should be refused")
}
if victimHits.Load() != 0 {
t.Fatalf("registration redirect reached private victim %d time(s)", victimHits.Load())
}
}
func TestProtectedDiscoveryTokenRefusesPublicToPrivateRedirect(t *testing.T) {
var victimHits atomic.Int64
victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
victimHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "redirected-token", "token_type": "Bearer"})
}))
defer victim.Close()
fixture := newPublicProtectedOAuthFixture(t, "", "", "", victim.URL+"/token")
_, err := Login(context.Background(), LoginOptions{
ServerName: "token-redirect",
ServerURL: fixture.resourceURL,
Config: OAuthConfig{ClientID: "configured-client"},
HTTPClient: fixture.client,
OpenBrowser: func(authURL string) error {
parsed, parseErr := url.Parse(authURL)
if parseErr != nil {
return parseErr
}
callbackURL := parsed.Query().Get("redirect_uri") + "?code=code&state=" + url.QueryEscape(parsed.Query().Get("state"))
go func() {
for attempt := 0; attempt < 20; attempt++ {
response, requestErr := http.Get(callbackURL)
if requestErr == nil {
response.Body.Close()
return
}
time.Sleep(25 * time.Millisecond)
}
}()
return nil
},
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("protected token redirect should be refused")
}
if victimHits.Load() != 0 {
t.Fatalf("token redirect reached private victim %d time(s)", victimHits.Load())
}
}
type publicProtectedOAuthFixture struct {
client *http.Client
resourceURL string
}
func newPublicProtectedOAuthFixture(t *testing.T, registrationEndpoint string, tokenEndpoint string, registrationRedirect string, tokenRedirect string) publicProtectedOAuthFixture {
t.Helper()
var resourceOrigin, issuerOrigin string
gateway := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resourceHost := strings.TrimPrefix(resourceOrigin, "https://")
issuerHost := strings.TrimPrefix(issuerOrigin, "https://")
switch {
case r.Host == resourceHost && r.URL.Path == "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+resourceOrigin+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case r.Host == resourceHost && r.URL.Path == "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": resourceOrigin + "/mcp",
"authorization_servers": []string{issuerOrigin},
})
case r.Host == issuerHost && r.URL.Path == "/.well-known/oauth-authorization-server":
metadataRegistration := registrationEndpoint
metadataToken := tokenEndpoint
if registrationRedirect != "" {
metadataRegistration = issuerOrigin + "/register"
}
if tokenRedirect != "" {
metadataToken = issuerOrigin + "/token"
}
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuerOrigin,
"authorization_endpoint": issuerOrigin + "/authorize",
"token_endpoint": metadataToken,
"registration_endpoint": metadataRegistration,
})
case r.Host == issuerHost && r.URL.Path == "/register" && registrationRedirect != "":
http.Redirect(w, r, registrationRedirect, http.StatusTemporaryRedirect)
case r.Host == issuerHost && r.URL.Path == "/token" && tokenRedirect != "":
http.Redirect(w, r, tokenRedirect, http.StatusTemporaryRedirect)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(gateway.Close)
port := gateway.Listener.Addr().(*net.TCPAddr).Port
resourceOrigin = fmt.Sprintf("https://93.184.216.34:%d", port)
issuerOrigin = fmt.Sprintf("https://93.184.216.35:%d", port)
transport := gateway.Client().Transport.(*http.Transport).Clone()
transport.TLSClientConfig = transport.TLSClientConfig.Clone()
transport.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // hermetic public-address routing fixture
dialer := &net.Dialer{}
gatewayAddress := gateway.Listener.Addr().String()
transport.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err == nil && (host == "93.184.216.34" || host == "93.184.216.35") {
return dialer.DialContext(ctx, network, gatewayAddress)
}
return dialer.DialContext(ctx, network, address)
}
return publicProtectedOAuthFixture{
client: &http.Client{Transport: transport},
resourceURL: resourceOrigin + "/mcp",
}
}
func TestProtectedResourceProbeDoesNotFollowRedirects(t *testing.T) {
var redirectedHits atomic.Int64
redirected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
redirectedHits.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer redirected.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirected.URL+"/mcp", http.StatusFound)
}))
defer source.Close()
_, found, err := discoverProtectedResourceAuthorizationServer(context.Background(), source.Client(), source.URL+"/mcp")
if err != nil || found {
t.Fatalf("redirected probe found=%v err=%v", found, err)
}
if redirectedHits.Load() != 0 {
t.Fatalf("probe followed redirect %d time(s)", redirectedHits.Load())
}
}
func TestProtectedResourceMetadataDoesNotFollowRedirects(t *testing.T) {
var redirectedHits atomic.Int64
redirected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
redirectedHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": "https://attacker.example/mcp",
"authorization_servers": []string{"https://attacker.example"},
})
}))
defer redirected.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
http.Redirect(w, r, redirected.URL+"/metadata", http.StatusFound)
}
}))
defer source.Close()
_, _, err := discoverProtectedResourceAuthorizationServer(context.Background(), source.Client(), source.URL+"/mcp")
if err == nil || !strings.Contains(err.Error(), "HTTP 302") {
t.Fatalf("error = %v, want redirect refusal", err)
}
if redirectedHits.Load() != 0 {
t.Fatalf("metadata fetch followed redirect %d time(s)", redirectedHits.Load())
}
}
func TestResolveAuthorizationServerRejectsProtectedIssuerMismatch(t *testing.T) {
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": "https://unexpected.example",
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer authorizationServer.Close()
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
}
}))
defer resourceServer.Close()
_, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err == nil || !strings.Contains(err.Error(), "issuer does not match") {
t.Fatalf("error = %v, want issuer mismatch", err)
}
}
func TestResolveAuthorizationServerRejectsMissingProtectedIssuer(t *testing.T) {
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer authorizationServer.Close()
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
}
}))
defer resourceServer.Close()
_, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err == nil || !strings.Contains(err.Error(), "issuer") {
t.Fatalf("error = %v, want missing issuer rejection", err)
}
}
func TestResolveAuthorizationServerRejectsWhitespaceAlteredProtectedIssuer(t *testing.T) {
for _, alter := range []struct {
name string
apply func(string) string
}{
{name: "leading", apply: func(value string) string { return " " + value }},
{name: "trailing", apply: func(value string) string { return value + " " }},
} {
t.Run(alter.name, func(t *testing.T) {
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": alter.apply(base),
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer authorizationServer.Close()
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
}
}))
defer resourceServer.Close()
_, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err == nil || !strings.Contains(err.Error(), "issuer does not match") {
t.Fatalf("error = %v, want whitespace issuer mismatch", err)
}
})
}
}
func TestAuthorizationServerMetadataDoesNotFollowRedirects(t *testing.T) {
var redirectedHits atomic.Int64
redirected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectedHits.Add(1)
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
}))
defer redirected.Close()
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirected.URL+"/.well-known/oauth-authorization-server", http.StatusFound)
}))
defer authorizationServer.Close()
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/metadata":
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
}
}))
defer resourceServer.Close()
_, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err == nil {
t.Fatal("authorization metadata redirect should fail")
}
if redirectedHits.Load() != 0 {
t.Fatalf("authorization metadata fetch followed redirect %d time(s)", redirectedHits.Load())
}
}
-220
View File
@@ -10,7 +10,6 @@ import (
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
@@ -146,130 +145,6 @@ func TestResolveEndpointsConfigOverridesDiscovery(t *testing.T) {
}
}
func TestResolveAuthorizationServerUsesProtectedResourceMetadata(t *testing.T) {
var authorizationMetadataHits atomic.Int64
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/oauth-authorization-server" {
http.NotFound(w, r)
return
}
authorizationMetadataHits.Add(1)
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
"registration_endpoint": base + "/register",
})
}))
defer authorizationServer.Close()
var resourceProbeHits, resourceMetadataHits atomic.Int64
resourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/mcp":
resourceProbeHits.Add(1)
metadataURL := "http://" + r.Host + "/resource-metadata"
w.Header().Set("WWW-Authenticate", `Bearer realm="OAuth", resource_metadata="`+metadataURL+`"`)
w.WriteHeader(http.StatusUnauthorized)
case "/resource-metadata":
resourceMetadataHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": "http://" + r.Host + "/mcp",
"authorization_servers": []string{authorizationServer.URL},
})
default:
http.NotFound(w, r)
}
}))
defer resourceServer.Close()
metadata, err := resolveAuthorizationServer(context.Background(), resourceServer.Client(), resourceServer.URL+"/mcp", OAuthConfig{})
if err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if metadata.Issuer != authorizationServer.URL {
t.Fatalf("issuer = %q, want %q", metadata.Issuer, authorizationServer.URL)
}
if metadata.AuthorizationEndpoint != authorizationServer.URL+"/authorize" || metadata.TokenEndpoint != authorizationServer.URL+"/token" {
t.Fatalf("resolved metadata = %#v", metadata)
}
if resourceProbeHits.Load() != 1 || resourceMetadataHits.Load() != 1 || authorizationMetadataHits.Load() != 1 {
t.Fatalf("discovery hits = probe:%d resource:%d authorization:%d", resourceProbeHits.Load(), resourceMetadataHits.Load(), authorizationMetadataHits.Load())
}
}
func TestResolveAuthorizationServerFallsBackToDirectDiscovery(t *testing.T) {
var resourceProbeHits, directDiscoveryHits atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/mcp":
resourceProbeHits.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
<-r.Context().Done()
case "/.well-known/oauth-authorization-server/mcp":
directDiscoveryHits.Add(1)
base := "http://" + r.Host
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base + "/mcp",
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
metadata, err := resolveAuthorizationServer(context.Background(), server.Client(), server.URL+"/mcp", OAuthConfig{})
if err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if metadata.AuthorizationEndpoint != server.URL+"/authorize" || metadata.TokenEndpoint != server.URL+"/token" {
t.Fatalf("resolved metadata = %#v", metadata)
}
if resourceProbeHits.Load() != 1 || directDiscoveryHits.Load() != 1 {
t.Fatalf("discovery hits = probe:%d direct:%d", resourceProbeHits.Load(), directDiscoveryHits.Load())
}
}
func TestResolveAuthorizationServerIssuerOverrideSkipsResourceProbe(t *testing.T) {
var resourceProbeHits, issuerDiscoveryHits atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
resourceProbeHits.Add(1)
w.WriteHeader(http.StatusUnauthorized)
case "/.well-known/oauth-authorization-server/issuer":
issuerDiscoveryHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base + "/issuer",
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
metadata, err := resolveAuthorizationServer(context.Background(), server.Client(), server.URL+"/mcp", OAuthConfig{IssuerURL: server.URL + "/issuer"})
if err != nil {
t.Fatalf("resolveAuthorizationServer() error = %v", err)
}
if metadata.AuthorizationEndpoint != server.URL+"/authorize" || metadata.TokenEndpoint != server.URL+"/token" {
t.Fatalf("resolved metadata = %#v", metadata)
}
if resourceProbeHits.Load() != 0 || issuerDiscoveryHits.Load() != 1 {
t.Fatalf("discovery hits = resource:%d issuer:%d", resourceProbeHits.Load(), issuerDiscoveryHits.Load())
}
}
func TestPKCEChallengeIsS256OfVerifier(t *testing.T) {
pkce, err := newPKCE()
if err != nil {
@@ -573,98 +448,3 @@ func TestFullFlowStoresTokens(t *testing.T) {
t.Fatalf("login token = %#v", result)
}
}
func TestFullFlowUsesProtectedResourceDiscovery(t *testing.T) {
var resourceProbeHits, resourceMetadataHits, authorizationMetadataHits, tokenHits atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
switch r.URL.Path {
case "/mcp":
resourceProbeHits.Add(1)
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" {
t.Error("resource probe sent credentials")
}
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/resource-metadata"`)
w.WriteHeader(http.StatusUnauthorized)
case "/resource-metadata":
resourceMetadataHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": base + "/mcp",
"authorization_servers": []string{base},
})
case "/.well-known/oauth-authorization-server":
authorizationMetadataHits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
})
case "/token":
tokenHits.Add(1)
if err := r.ParseForm(); err != nil {
t.Errorf("parse token form: %v", err)
}
if r.Form.Get("code") != "authorization-code-secret" || r.Form.Get("code_verifier") == "" {
t.Error("token exchange omitted the authorization code or PKCE verifier")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-token-secret",
"refresh_token": "refresh-token-secret",
"token_type": "Bearer",
"expires_in": 3600,
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
opener := func(authURL string) error {
parsed, err := url.Parse(authURL)
if err != nil {
return err
}
if parsed.Path != "/authorize" || parsed.Query().Get("code_challenge_method") != "S256" || parsed.Query().Get("code_challenge") == "" {
return errors.New("authorization request omitted the discovered endpoint or PKCE challenge")
}
if parsed.Query().Get("code") != "" || parsed.Query().Get("code_verifier") != "" {
return errors.New("authorization request exposed an authorization code or PKCE verifier")
}
state := parsed.Query().Get("state")
redirect := parsed.Query().Get("redirect_uri")
go func() {
callbackURL := redirect + "?code=authorization-code-secret&state=" + url.QueryEscape(state)
for attempt := 0; attempt < 20; attempt++ {
response, requestErr := http.Get(callbackURL)
if requestErr == nil {
response.Body.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
}()
return nil
}
token, err := Login(context.Background(), LoginOptions{
ServerName: "notion-style",
ServerURL: server.URL + "/mcp",
Config: OAuthConfig{
ClientID: "client-123",
Scopes: []string{"read"},
},
HTTPClient: server.Client(),
OpenBrowser: opener,
Timeout: 5 * time.Second,
Now: time.Now,
})
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if token.AccessToken != "access-token-secret" || token.RefreshToken != "refresh-token-secret" {
t.Fatal("login did not return the issued tokens")
}
if resourceProbeHits.Load() != 1 || resourceMetadataHits.Load() != 1 || authorizationMetadataHits.Load() != 1 || tokenHits.Load() != 1 {
t.Fatalf("flow hits = probe:%d resource:%d authorization:%d token:%d", resourceProbeHits.Load(), resourceMetadataHits.Load(), authorizationMetadataHits.Load(), tokenHits.Load())
}
}
+3 -7
View File
@@ -23,9 +23,6 @@ const defaultConnectTimeout = 8 * time.Second
type RegisterOptions struct {
PermissionStore *PermissionStore
Autonomy PermissionAutonomy
// AdvertiseInAuto exposes prompt-gated MCP tools to model surfaces whose
// default mode is auto while preserving the execution-time prompt.
AdvertiseInAuto bool
ClientFactory func(context.Context, Server) (ToolClient, error)
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
// defaultConnectTimeout.
@@ -274,10 +271,9 @@ func newRegistryTool(server Server, remote RemoteTool, client ToolClient, option
client: client,
parameters: SchemaFromMCP(remote.InputSchema),
safety: tools.Safety{
SideEffect: tools.SideEffectNetwork,
Permission: permission,
Reason: fmt.Sprintf("MCP tool %s/%s runs through the configured %s server.", server.Name, remote.Name, server.Type),
AdvertiseInAuto: options.AdvertiseInAuto,
SideEffect: tools.SideEffectNetwork,
Permission: permission,
Reason: fmt.Sprintf("MCP tool %s/%s runs through the configured %s server.", server.Name, remote.Name, server.Type),
},
}
}
-13
View File
@@ -342,19 +342,6 @@ func TestRegistryToolIsDeferredEligible(t *testing.T) {
}
}
func TestRegistryToolCanAdvertiseInAutoWithoutPreapproval(t *testing.T) {
tool := newRegistryTool(
Server{Name: "docs", Type: "stdio"},
RemoteTool{Name: "search"},
&fakeToolClient{},
RegisterOptions{Autonomy: AutonomyLow, AdvertiseInAuto: true},
)
safety := tool.Safety()
if safety.Permission != tools.PermissionPrompt || !safety.AdvertiseInAuto {
t.Fatalf("MCP safety = %#v", safety)
}
}
// TestRegistryToolReportsMCPServerName verifies the registryTool reports its true
// configured server name (not the sanitized tool-name token) so the deferred-tools
// discovery label names a multi-token server correctly via tools.DeferredSource.
+64 -7
View File
@@ -5,16 +5,24 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
const fileLockTimeout = 5 * time.Second
const (
fileLockTimeout = 5 * time.Second
fileLockStaleAfter = 30 * time.Second
)
// acquireFileLock takes a cross-process advisory lock through a stable lock
// file. It retries with a short backoff until a timeout. Kernel lock lifetime
// provides crash recovery, so the path is never renamed or removed.
var lockSeq atomic.Uint64
// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
// store). Release is ownership-aware: it removes the lock only if it still holds
// our token, so a stale-broken holder cannot delete a newer holder's lock.
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if now == nil {
now = time.Now
@@ -22,15 +30,64 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
deadline := now().Add(fileLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
return func() { _ = lock.Release() }, nil
// A partial/failed write would leave a lock file without our token, so
// the releaser below could never delete it — stranding the lock for
// other processes. Fail closed: remove the file and surface the error.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if now().After(deadline) {
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
}
-108
View File
@@ -1,108 +0,0 @@
package providers
import (
"context"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction summarization model selection.
//
// Compaction summaries used to run on the session's main model — near a 200k
// window one summarization is a ~100k-token input call at full price, the
// single most expensive recurring event in a long run. A cheap model handles
// the "dense factual summary" task fine, so summarization is routed to one
// whenever the target is knowable.
//
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
// main model). The literal value "main" at any level forces the main model.
// A wrong choice is never fatal: the agent falls back to the main provider on
// the first summarizer failure for the rest of the run.
// CompactionModelEnv overrides the compaction summarization model.
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
// compactionMainSentinel disables the dedicated summarizer explicitly.
const compactionMainSentinel = "main"
// Curated cheap summarizer defaults, applied only for provider kinds whose
// model catalog is guaranteed (official endpoints). Custom / *-compatible
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
// failed call per run is worse than main-model prices.
const (
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
defaultOpenAICompactionModel = "gpt-4.1-mini"
)
// CompactionModelID returns the model that compaction summarization calls
// should use for the given profile, or "" to use the session's main model.
// preference is the resolved config value (preferences.compactionModel).
func CompactionModelID(profile config.ProviderProfile, preference string) string {
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
if value == "" {
value = strings.TrimSpace(preference)
}
if value != "" {
if strings.EqualFold(value, compactionMainSentinel) {
return ""
}
return value
}
return defaultCompactionModel(profile)
}
func defaultCompactionModel(profile config.ProviderProfile) string {
metadata, err := ResolveRuntimeMetadata(profile, Options{})
if err != nil {
return ""
}
var candidate string
switch metadata.ProviderKind {
case config.ProviderKindAnthropic:
candidate = defaultAnthropicCompactionModel
case config.ProviderKindGoogle:
candidate = defaultGoogleCompactionModel
case config.ProviderKindOpenAI:
candidate = defaultOpenAICompactionModel
default:
return ""
}
// Session already runs on the cheap model: a dedicated summarizer buys
// nothing and would only add a second provider instance.
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
return ""
}
return candidate
}
// CompactionSummarizerFactory returns the lazy constructor the agent loop
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
// The compactor passes the main model in force at that moment — on the first
// paid compaction and again after a mid-run model switch — so the choice
// tracks escalations: a run that starts on the cheap model gets no dedicated
// summarizer, and gains one once it escalates to a pricier main model.
// Returning (nil, nil) means "summarize on the main provider". The factory
// itself is nil only when no provider builder exists. Shared by the exec and
// TUI front ends so the selection rules cannot drift between them.
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
if newProvider == nil {
return nil
}
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
profile := base
if strings.TrimSpace(mainModelID) != "" {
profile.Model = mainModelID
}
modelID := CompactionModelID(profile, preference)
if modelID == "" {
return nil, nil
}
profile.Model = modelID
return newProvider(profile)
}
}
-113
View File
@@ -1,113 +0,0 @@
package providers
import (
"context"
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactionModelIDResolutionOrder(t *testing.T) {
anthropic := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
}
t.Setenv(CompactionModelEnv, "my-env-model")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-env-model" {
t.Fatalf("env must win, got %q", got)
}
t.Setenv(CompactionModelEnv, "main")
if got := CompactionModelID(anthropic, "my-config-model"); got != "" {
t.Fatalf("env 'main' must force the main model, got %q", got)
}
t.Setenv(CompactionModelEnv, "")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-config-model" {
t.Fatalf("config preference must apply when env unset, got %q", got)
}
if got := CompactionModelID(anthropic, "main"); got != "" {
t.Fatalf("config 'main' must force the main model, got %q", got)
}
}
func TestCompactionModelIDCuratedDefaults(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
anthropic := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: "claude-sonnet-4.5"}
if got := CompactionModelID(anthropic, ""); got != defaultAnthropicCompactionModel {
t.Fatalf("anthropic default = %q, want %q", got, defaultAnthropicCompactionModel)
}
google := config.ProviderProfile{Name: "google", ProviderKind: config.ProviderKindGoogle, Model: "gemini-2.5-pro"}
if got := CompactionModelID(google, ""); got != defaultGoogleCompactionModel {
t.Fatalf("google default = %q, want %q", got, defaultGoogleCompactionModel)
}
openai := config.ProviderProfile{Name: "openai", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
if got := CompactionModelID(openai, ""); got != defaultOpenAICompactionModel {
t.Fatalf("openai default = %q, want %q", got, defaultOpenAICompactionModel)
}
// Custom/compatible endpoints: catalog unknowable, no default.
compatible := config.ProviderProfile{
Name: "opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opengateway.gitlawb.com/v1",
Model: "some-model",
}
if got := CompactionModelID(compatible, ""); got != "" {
t.Fatalf("openai-compatible must have no default, got %q", got)
}
}
func TestCompactionModelIDSkipsWhenMainIsAlreadyCheap(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
haiku := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: defaultAnthropicCompactionModel,
}
if got := CompactionModelID(haiku, ""); got != "" {
t.Fatalf("already-cheap session must not get a dedicated summarizer, got %q", got)
}
}
func TestCompactionSummarizerFactoryTracksMainModel(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
base := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: defaultAnthropicCompactionModel}
var built []string
newProvider := func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = append(built, profile.Model)
return nil, errors.New("not needed")
}
factory := CompactionSummarizerFactory(base, "", newProvider)
if factory == nil {
t.Fatal("factory must exist when a provider builder exists")
}
// Main model is the cheap default: no dedicated summarizer, nothing built.
if provider, err := factory(context.Background(), defaultAnthropicCompactionModel); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("cheap main model must yield (nil, nil) without building, got %v %v built=%v", provider, err, built)
}
// After escalating to an expensive model the cheap default is built.
if _, err := factory(context.Background(), "claude-sonnet-4-5"); err == nil || len(built) != 1 || built[0] != defaultAnthropicCompactionModel {
t.Fatalf("escalated main model must build the cheap summarizer, built=%v err=%v", built, err)
}
// An empty main model falls back to the base profile's model — the cheap
// default here, so again nothing is built.
built = nil
if provider, err := factory(context.Background(), ""); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("empty main model must use the base profile, got %v %v built=%v", provider, err, built)
}
// The explicit "main" preference disables the dedicated summarizer.
if provider, err := CompactionSummarizerFactory(base, "main", newProvider)(context.Background(), "claude-sonnet-4-5"); provider != nil || err != nil {
t.Fatalf("preference main must disable the summarizer, got %v %v", provider, err)
}
if CompactionSummarizerFactory(base, "", nil) != nil {
t.Fatal("no provider builder means no factory")
}
}
-2
View File
@@ -7,7 +7,6 @@ import (
type BackendOptions struct {
GOOS string
LookupExecutable func(string) (string, error)
DetectWSL func() WSLInfo
}
type Backend struct {
@@ -54,7 +53,6 @@ func SelectBackend(options BackendOptions) Backend {
return NewSandboxManager(SandboxManagerOptions{
GOOS: options.GOOS,
LookupExecutable: options.LookupExecutable,
DetectWSL: options.DetectWSL,
}).Backend()
}
-15
View File
@@ -45,7 +45,6 @@ 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)
@@ -55,20 +54,6 @@ 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",
+3 -7
View File
@@ -51,7 +51,6 @@ const (
type SandboxManagerOptions struct {
GOOS string
LookupExecutable func(string) (string, error)
DetectWSL func() WSLInfo
Backend Backend
}
@@ -97,7 +96,7 @@ func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
goos = runtime.GOOS
}
if backend.Name == "" {
backend = selectPlatformBackend(goos, options.LookupExecutable, options.DetectWSL)
backend = selectPlatformBackend(goos, options.LookupExecutable)
}
if backend.Platform == "" {
backend.Platform = goos
@@ -169,13 +168,10 @@ func lookupExecutable(name string) (string, error) {
return "", errors.New("executable file not found")
}
func selectPlatformBackend(goos string, lookup func(string) (string, error), detect func() WSLInfo) Backend {
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
if lookup == nil {
lookup = lookupExecutable
}
if detect == nil {
detect = detectWSL
}
switch goos {
case "linux":
if helper, err := lookup(LinuxSandboxHelperName); err == nil && helper != "" {
@@ -184,7 +180,7 @@ func selectPlatformBackend(goos string, lookup func(string) (string, error), det
}
return nativeBackend(goos, BackendLinuxBwrap, helper, "Linux sandbox helper available")
}
if info := detect(); info.IsWSL {
if info := detectWSL(); info.IsWSL {
return wslBackend(goos, info)
}
return unavailableBackend(goos, "Linux sandbox helper is not available")
+25 -239
View File
@@ -19,17 +19,6 @@ type Scope struct {
workspaceRoot string
readRoots []string
extraRoots []string
// tempReads / tempWrites count how many LIVE temporary grants depend on a
// root, so one holder's cleanup cannot revoke another's access.
//
// Without them a temporary grant was add-then-remove with no notion of who
// still needed it: the second caller to ask for a root already present got a
// NO-OP undo, and the first caller's cleanup removed the root out from under
// it. Two read-only tools in the same parallel batch, both blocked on the
// same directory, is exactly that shape — and read-only tools are precisely
// the ones the batch runs concurrently.
tempReads map[string]int
tempWrites map[string]int
}
// NewScope builds a scope for workspaceRoot plus the given extra roots. The
@@ -92,88 +81,15 @@ func (s *Scope) Add(path string) (string, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
// COVERED BY WHAT. extraRoots holds temporary write roots alongside
// permanent ones, so "something already covers this" was not the question
// worth asking: a permanent grant made over a path a temporary holder
// happened to cover recorded nothing, and vanished the moment that holder
// released. A session-scoped grant outliving the request that prompted it is
// the entire difference between Add and AddTemporaryWrite.
//
// Permanent coverage is looked for FIRST and across every root, because a
// path can be covered twice and the temporary cover must not decide the
// answer when a permanent one is also present.
if s.permanentWriteRootCoversLocked(root) {
return root, nil
}
for _, existing := range s.extraRoots {
// The same root, held temporarily: promote it in place. Its holders'
// releases become no-ops, which is what permanence means here.
if existing == root && s.temporaryWriteLocked(existing) {
delete(s.tempWrites, root)
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) {
return root, nil
}
}
// Either uncovered, or covered only by a BROADER temporary root that is
// going to be released. Recording the narrower root in its own right is what
// survives that release.
s.extraRoots = append(s.extraRoots, root)
return root, nil
}
// temporaryWriteLocked reports whether root is held by a temporary write grant
// rather than a session-scoped one. Callers must hold the lock.
func (s *Scope) temporaryWriteLocked(root string) bool {
_, temporary := s.tempWrites[root]
return temporary
}
// permanentWriteRootCoversLocked reports whether root sits under write authority
// that outlives every temporary holder: the workspace root, or a session-scoped
// grant. Coverage by a TEMPORARY write root deliberately does not count —
// it is going to be released, so nothing recorded on the strength of it
// survives. Callers must hold the lock.
//
// One helper rather than a copy per caller because Add, AddRead and
// AddTemporaryRead all turn on the same question, and three copies of "covered,
// but by what" is how one of them ends up answering it differently.
// permanentReadRootCoversLocked reports whether root is already readable by a
// grant that outlives every temporary holder.
//
// SLICE ORDER MUST NOT DECIDE THIS. The old check walked readRoots and stopped at
// the first entry covering the path, so a BROAD TEMPORARY root sitting earlier in
// the slice shadowed a narrower PERMANENT one later — and the caller was handed a
// reference on somebody else's grant when it needed no reference at all. Asking
// the whole question first, over both lists, makes the answer independent of
// insertion order.
//
// Write roots count: a grant that permits writing permits reading the same tree.
func (s *Scope) permanentReadRootCoversLocked(root string) bool {
if s.permanentWriteRootCoversLocked(root) {
return true
}
for _, existing := range s.readRoots {
if pathWithinRoot(existing, root) && !s.temporaryReadLocked(existing) {
return true
}
}
return false
}
func (s *Scope) permanentWriteRootCoversLocked(root string) bool {
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) && !s.temporaryWriteLocked(existing) {
return true
}
}
return false
}
// temporaryReadLocked is temporaryWriteLocked for read roots.
func (s *Scope) temporaryReadLocked(root string) bool {
_, temporary := s.tempReads[root]
return temporary
}
// AddRead grants read-only access under path. If the path is already covered by
// a writable root, no separate read root is stored.
func (s *Scope) AddRead(path string) (string, error) {
@@ -183,19 +99,11 @@ func (s *Scope) AddRead(path string) (string, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
// Same rule as Add: coverage that is going to be released is not coverage a
// permanent grant can rely on, whichever side of the read/write boundary it
// sits on. A permanent read covered only by a temporary WRITE root died with
// that root just as surely.
if s.permanentWriteRootCoversLocked(root) {
return root, nil
}
if s.permanentReadRootCoversLocked(root) {
if s.writeRootCoversLocked(root) {
return root, nil
}
for _, existing := range s.readRoots {
if existing == root && s.temporaryReadLocked(existing) {
delete(s.tempReads, root)
if pathWithinRoot(existing, root) {
return root, nil
}
}
@@ -210,63 +118,16 @@ func (s *Scope) AddTemporaryRead(path string) (string, func(), error) {
}
s.mu.Lock()
defer s.mu.Unlock()
// Genuinely permanent — the workspace root, or a session-scoped grant. It
// outlives this reader, so there is nothing to release.
if s.permanentWriteRootCoversLocked(root) {
if s.writeRootCoversLocked(root) {
return root, func() {}, nil
}
// A TEMPORARY write root covering this path is NOT borrowed, and this is the
// one place the distinction bites hardest.
//
// The dependency on that root is real — the write holder's cleanup must not
// revoke this reader, which is the defect the refcount exists to close,
// reached across the read/write boundary. But taking a reference on the
// WRITE root pays for the lifetime with the authority: the reference keeps
// the root in extraRoots, extraRoots is what Roots() feeds validate(), and
// validate() is WRITE authorization. So a reader outliving its writer went on
// writing anywhere under a write grant that had already ended — not merely
// under the path it asked to read.
//
// One reference cannot be both the lifetime and the capability. The lifetime
// is kept below, as a temporary READ root for the path this caller actually
// asked for. readRoots feeds ReadRoots(), which confers no write authority —
// validate() authorises writes from workspaceRoot plus extraRoots, and this
// path never enters that list. So the reader survives its writer with read
// authority and only read authority.
//
// That claim is the written justification for the whole design, so it has to
// stay exhaustive as the accessors change. It has already been wrong once, in
// both directions: an earlier draft said readRoots fed ReadRoots() and nothing
// else while ExtraReadRoots() also read it, and that accessor has since been
// removed as belonging to a different change.
if s.permanentReadRootCoversLocked(root) {
return root, func() {}, nil
for _, existing := range s.readRoots {
if pathWithinRoot(existing, root) {
return root, func() {}, nil
}
}
if s.tempReads == nil {
s.tempReads = map[string]int{}
}
// THE SAME ROOT, ANOTHER HOLDER: one entry, two references. Only an EXACT
// match shares — a narrower request must not, which is the whole point below.
if _, held := s.tempReads[root]; held {
s.tempReads[root]++
return root, oncePerHolder(func() { s.releaseTemporaryRead(root) }), nil
}
// A NARROWER REQUEST RECORDS ITS OWN ROOT, even under a broader temporary
// read that currently covers it.
//
// Borrowing the covering root's reference kept this reader alive by keeping
// the BROAD ROOT alive, and readRoots feeds validateRead and the native
// sandbox profile. So once the broad reader released, a caller that had asked
// to read one subdirectory was left able to read the whole tree its neighbour
// had asked for, siblings included. Reported by @jatmn, and it is the read
// twin of the write escalation fixed above: the refcount key decides not only
// WHEN an entry disappears but WHICH path stays authorised while it lives.
s.readRoots = append(s.readRoots, root)
if s.tempReads == nil {
s.tempReads = map[string]int{}
}
s.tempReads[root] = 1
return root, oncePerHolder(func() { s.releaseTemporaryRead(root) }), nil
return root, func() { s.removeReadRoot(root) }, nil
}
func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) {
@@ -276,107 +137,32 @@ func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) {
}
s.mu.Lock()
defer s.mu.Unlock()
// A PERMANENT grant is the only cover that needs no bookkeeping: it outlives
// every temporary holder, so there is nothing to keep alive and nothing to
// release.
if s.permanentWriteRootCoversLocked(root) {
if s.writeRootCoversLocked(root) {
return root, func() {}, nil
}
if s.tempWrites == nil {
s.tempWrites = map[string]int{}
}
// THE SAME ROOT, ANOTHER HOLDER: one entry, two references. Only an exact
// match shares an entry — a NARROWER request must not, which is the whole
// point below.
if _, held := s.tempWrites[root]; held {
s.tempWrites[root]++
return root, oncePerHolder(func() { s.releaseTemporaryWrite(root) }), nil
}
// A NARROWER REQUEST RECORDS ITS OWN ROOT, even when a broader temporary
// grant currently covers it.
//
// Borrowing a reference on the covering root kept this holder alive, which
// was the point, but it kept the COVERING ROOT alive to do it — and
// extraRoots is what validate() authorises writes from. So once the broad
// holder released, a caller that had asked to write one subdirectory was
// left holding write authority over the whole tree its neighbour had asked
// for, including siblings it never named. Reported by CodeRabbit, and it is
// the write-side twin of the read-side escalation @jatmn found: one
// reference cannot be both a lifetime and a capability, whichever side of
// the read/write boundary it sits on.
//
// Recording the requested root separately gives each holder exactly the
// authority it asked for and a lifetime of its own. The nesting costs an
// extra entry in extraRoots while both are live, which is redundant but not
// wrong — the broader root already permits everything the narrower one does.
s.extraRoots = append(s.extraRoots, root)
s.tempWrites[root] = 1
return root, oncePerHolder(func() { s.releaseTemporaryWrite(root) }), nil
return root, func() { s.removeWriteRoot(root) }, nil
}
// releaseTemporaryRead drops ONE holder's reference and removes the root only
// when the last one is gone.
//
// The count alone cannot make this safe, and an earlier comment here claimed it
// could — that each undo is called exactly once, and flooring at zero handled
// the rest. Flooring stops the count going negative; it does not stop one
// holder's second call consuming a DIFFERENT holder's reference. With two
// readers, calling the first's undo twice took the count 2 -> 1 -> 0 and removed
// a root the second was still using. Idempotency belongs to the closure, which
// is the thing that knows whose reference it is, so every undo handed out is
// wrapped in oncePerHolder.
func (s *Scope) releaseTemporaryRead(root string) {
func (s *Scope) writeRootCoversLocked(root string) bool {
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) {
return true
}
}
return false
}
func (s *Scope) removeReadRoot(root string) {
s.mu.Lock()
remaining, tracked := s.tempReads[root]
if !tracked {
s.mu.Unlock()
return
}
remaining--
if remaining > 0 {
s.tempReads[root] = remaining
s.mu.Unlock()
return
}
// Both mutations under ONE hold. Dropping the lock between them opened a
// window where the root was still in readRoots but no longer in tempReads:
// a concurrent AddTemporaryRead landing there reads it as a PERMANENT root,
// hands its caller a no-op undo, and then this call strips the root — so
// that caller believes it holds access it has already silently lost.
delete(s.tempReads, root)
defer s.mu.Unlock()
s.readRoots = removeScopeRoot(s.readRoots, root)
s.mu.Unlock()
}
// releaseTemporaryWrite is releaseTemporaryRead for write roots. Two functions
// rather than one generic helper because they guard different slices and the
// generic version would take the slice by name — which is how the wrong one
// gets passed.
func (s *Scope) releaseTemporaryWrite(root string) {
func (s *Scope) removeWriteRoot(root string) {
s.mu.Lock()
remaining, tracked := s.tempWrites[root]
if !tracked {
s.mu.Unlock()
return
}
remaining--
if remaining > 0 {
s.tempWrites[root] = remaining
s.mu.Unlock()
return
}
// Same single-hold rule as releaseTemporaryRead above.
delete(s.tempWrites, root)
defer s.mu.Unlock()
s.extraRoots = removeScopeRoot(s.extraRoots, root)
s.mu.Unlock()
}
// oncePerHolder makes one holder's undo safe to call more than once. The
// reference it drops is that holder's own, so a second call must do nothing
// rather than reach into the shared count and take somebody else's.
func oncePerHolder(release func()) func() {
var once sync.Once
return func() { once.Do(release) }
}
func removeScopeRoot(roots []string, root string) []string {
-56
View File
@@ -1,56 +0,0 @@
package sandbox
import (
"path/filepath"
"testing"
)
// THE ANTI-ESCALATION PROPERTY. A read grant (AddRead, where --add-read-dir lands)
// is READABLE but NOT WRITABLE. This is what makes propagating a parent's
// request_permissions read grant to a child safe: the child can audit the granted
// path but cannot modify it. Emitting the grant as a WRITE root (--add-dir) would
// break exactly this.
func TestAReadGrantIsReadableButNotWritable(t *testing.T) {
scope, granted := grantOutsideDefaults(t)
root, err := scope.AddRead(granted)
if err != nil {
t.Fatalf("AddRead: %v", err)
}
target := filepath.Join(root, "audit-me.go")
if block := scope.validateRead(target); block != nil {
t.Fatalf("a read grant did not allow READING %q: %v — the audit would fail 'outside the workspace'", target, block)
}
if block := scope.validate(target); block == nil {
t.Fatalf("a read grant ALLOWED WRITING %q — a read grant escalated to write", target)
}
}
// grantOutsideDefaults returns a scope and a directory that scope does not
// already cover, both under test-owned storage.
//
// THE CAUSE, NOT THE SYMPTOM. NewScope seeds the system temporary directory as a
// permanent write root, so a plain t.TempDir() grant is writable before any
// grant exists and the anti-escalation assertions below prove nothing. The first
// fix placed the fixture under os.UserHomeDir() to find ground the defaults do
// not own. That worked and bought two problems: a hermetic runner may expose
// HOME read-only, so these tests failed at MkdirTemp before asserting anything,
// and on an ordinary machine they wrote into the developer's real home and left
// zero-scope-* debris whenever a run was interrupted. Reported by @jatmn.
//
// Building the Scope directly removes the seeded defaults, which is what made
// HOME necessary in the first place. t.TempDir() is then outside by
// construction, the fixture is hermetic, and nothing touches HOME.
func grantOutsideDefaults(t *testing.T) (*Scope, string) {
t.Helper()
workspace := resolvedFixturePath(t, t.TempDir())
granted := resolvedFixturePath(t, t.TempDir())
scope := &Scope{workspaceRoot: workspace}
// PROVED AGAINST THIS SCOPE. The point of the helper is that the grant is
// what changes access, so the target has to begin unauthorised — asserted
// here once rather than trusted in every caller.
if scope.validate(filepath.Join(granted, "probe.txt")) == nil {
t.Fatalf("%s is writable before any grant exists; the assertions below would prove nothing", granted)
}
return scope, granted
}
@@ -1,595 +0,0 @@
package sandbox
import (
"os"
"path/filepath"
"slices"
"sync"
"testing"
)
// scopeOutsideDefaults builds a Scope directly instead of through NewScope.
//
// NewScope adds the system temp directory as a write root, so a target under
// t.TempDir() is already covered and AddTemporaryRead returns before it ever
// touches the refcount. A test built that way exercises none of this and passes
// against the bug — which is how the first version of this test passed.
func scopeOutsideDefaults(t *testing.T) (*Scope, string) {
t.Helper()
workspace := t.TempDir()
target := filepath.Join(t.TempDir(), "shared")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatalf("mkdir target: %v", err)
}
// Built directly, NOT via NewScope: real directories are needed because
// normalizeScopeRoot requires them to exist, but NewScope's default temp
// write root would then cover the target and short-circuit the path.
return &Scope{workspaceRoot: workspace}, target
}
// The refcount and the root list have to move together.
//
// Release used to delete the refcount, drop the lock, then strip the root in a
// second acquisition. In that window the root was still in readRoots but no
// longer in tempReads, so a concurrent AddTemporaryRead read it as a PERMANENT
// root and handed its caller a no-op undo — then the release stripped it and
// that caller silently lost access it believed it held.
func TestReleasingATemporaryReadCannotStripALiveGrant(t *testing.T) {
scope, target := scopeOutsideDefaults(t)
// First holder establishes the root.
first, releaseFirst, err := scope.AddTemporaryRead(target)
if err != nil {
t.Fatalf("AddTemporaryRead: %v", err)
}
if len(scope.tempReads) == 0 {
t.Fatal("precondition: the grant should be refcounted, not covered by a default root")
}
// Second holder takes a reference on the same root.
second, releaseSecond, err := scope.AddTemporaryRead(target)
if err != nil {
t.Fatalf("AddTemporaryRead (second): %v", err)
}
// The first holder leaving must not revoke the second's access.
releaseFirst()
if block := scope.validateRead(second); block != nil {
t.Fatalf("the second holder lost its grant when the first released: %v", block)
}
// Only the last release retires the root.
releaseSecond()
if block := scope.validateRead(first); block == nil {
t.Error("the root outlived its last holder")
}
}
// Under contention the same invariant has to hold: while a grant is live, its
// root is readable.
func TestTemporaryReadGrantsSurviveConcurrentReleases(t *testing.T) {
scope, target := scopeOutsideDefaults(t)
var wg sync.WaitGroup
var mu sync.Mutex
var failures []string
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
granted, undo, err := scope.AddTemporaryRead(target)
if err != nil {
mu.Lock()
failures = append(failures, "AddTemporaryRead: "+err.Error())
mu.Unlock()
return
}
if block := scope.validateRead(granted); block != nil {
mu.Lock()
failures = append(failures, "root not readable while a grant was live")
mu.Unlock()
}
undo()
}
}()
}
wg.Wait()
if len(failures) > 0 {
t.Fatalf("%d failures, first: %s", len(failures), failures[0])
}
}
// A read covered by a TEMPORARY WRITE root must take a reference too.
//
// permanentWriteRootCoversLocked scans extraRoots, and AddTemporaryWrite appends
// temporary write roots there — so "covered by a write root" does not mean
// "covered permanently". Without a reference the write holder's cleanup silently
// revoked the reader's access: the same defect the refcount closes, reached
// across the read/write boundary instead of within one side of it.
func TestAReadCoveredByATemporaryWriteSurvivesItsRelease(t *testing.T) {
workspace := t.TempDir()
outer := filepath.Join(t.TempDir(), "outer")
inner := filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
// Built directly for the same reason scopeOutsideDefaults is: NewScope's
// default temp write root would cover the fixture and short-circuit this.
scope := &Scope{workspaceRoot: workspace}
_, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
readRoot, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
covered := func() bool {
for _, root := range scope.ReadRoots() {
if pathWithinRoot(root, readRoot) {
return true
}
}
return false
}
if !covered() {
t.Fatal("the reader did not hold access before any release")
}
// The WRITE holder finishes first; the reader has not released.
releaseWrite()
if !covered() {
t.Errorf("the write holder's cleanup revoked a live read grant on %s", readRoot)
}
// And once the reader releases too, the root is genuinely gone.
releaseRead()
if covered() {
t.Errorf("the root outlived its last holder: %v", scope.ReadRoots())
}
}
func hasExtraRoot(scope *Scope, root string) bool {
for _, existing := range writeRootsBeyondWorkspace(scope) {
if existing == root {
return true
}
}
return false
}
// A PERMANENT GRANT MUST OUTLIVE THE TEMPORARY ONE IT LANDED ON TOP OF.
//
// extraRoots holds temporary write roots alongside permanent ones, so Add asked
// only "is this already covered" — and a session-scoped grant made while a
// temporary holder happened to cover the path recorded nothing, then vanished
// when that holder released. Outliving the request that prompted it is the
// entire difference between Add and AddTemporaryWrite.
func TestAPermanentGrantSurvivesTheTemporaryOneItCovered(t *testing.T) {
t.Run("write, same root", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseTemp, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.Add(outside); err != nil {
t.Fatal(err)
}
releaseTemp()
if !hasExtraRoot(scope, outside) {
t.Error("the temporary holder's release revoked a permanent write grant")
}
})
t.Run("read, same root", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseTemp, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
releaseTemp()
if !hasReadRoot(scope, outside) {
t.Error("the temporary holder's release revoked a permanent read grant")
}
})
// The nested shape: a BROAD temporary root covering a NARROW permanent one.
// Promoting in place cannot help here — the narrow root has to be recorded
// in its own right, or it goes when the broad one does.
t.Run("broad temporary over narrow permanent", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
inner := filepath.Join(outside, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
_, releaseBroad, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.Add(inner); err != nil {
t.Fatal(err)
}
releaseBroad()
covered := false
for _, existing := range writeRootsBeyondWorkspace(scope) {
if pathWithinRoot(existing, inner) {
covered = true
}
}
if !covered {
t.Error("a narrower permanent grant died with the broader temporary root it sat under")
}
})
// THE READ MIRROR OF BOTH SHAPES ABOVE. AddRead makes two claims that the
// subtests so far only made for Add: the loop over extraRoots skips coverage
// that is a temporary WRITE, and the loop over readRoots skips coverage that
// is a temporary READ. Each claim is a separate branch, and neither was
// exercised from the read side.
t.Run("permanent read under a temporary write", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseWrite, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
releaseWrite()
if !hasReadRoot(scope, outside) {
t.Error("a temporary write holder's release revoked a permanent read grant")
}
})
t.Run("broad temporary read over narrow permanent read", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
inner := filepath.Join(outside, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
_, releaseBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(inner); err != nil {
t.Fatal(err)
}
releaseBroad()
if block := scope.validateRead(filepath.Join(inner, "audit-me.go")); block != nil {
t.Errorf("a narrower permanent read died with the broader temporary read it sat under: %v", block)
}
})
}
// ONE HOLDER'S UNDO DROPS ONE HOLDER'S REFERENCE, HOWEVER OFTEN IT IS CALLED.
//
// The count flooring at zero stops it going negative; it does not stop a
// holder's second call consuming somebody else's reference. With two readers,
// calling the first's undo twice took the count 2 -> 1 -> 0 and removed a root
// the second was still using — the exact revocation this refcount exists to
// prevent, reached through a duplicate call rather than a sibling's cleanup.
// Deferred cleanups in a retry path are how a real caller does this by accident.
func TestOneHoldersUndoIsIdempotent(t *testing.T) {
scope, workspace, outside := scopeOutsideRoots(t)
_, undoFirst, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoSecond, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undoFirst()
undoFirst()
undoFirst()
if !hasReadRoot(scope, outside) {
t.Fatal("repeated calls to one holder's undo revoked a live holder's access")
}
undoSecond()
if hasReadRoot(scope, outside) {
t.Error("the root outlived its last holder")
}
// The write side takes the same rule. Built directly for the same reason the
// helper is: NewScope would seed the system temp directory as a permanent
// write root, which already covers this fixture and makes the grant vacuous.
writeScope := &Scope{workspaceRoot: workspace}
_, undoWriteFirst, err := writeScope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
_, undoWriteSecond, err := writeScope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
undoWriteFirst()
undoWriteFirst()
if !hasExtraRoot(writeScope, outside) {
t.Fatal("repeated calls to one write holder's undo revoked a live holder's access")
}
undoWriteSecond()
if hasExtraRoot(writeScope, outside) {
t.Error("the write root outlived its last holder")
}
}
// temporaryWriteFixture builds a nested pair of real directories and a scope
// that covers neither: an OUTER directory a temporary write grant will take,
// and an INNER one below it that a temporary read grant will ask for.
//
// Built directly rather than through NewScope, for the reason
// scopeOutsideDefaults documents: NewScope seeds the system temp directory as a
// PERMANENT write root, which would cover the whole fixture and send every
// branch under test down the "genuinely permanent" path instead. Both paths are
// returned symlink-resolved because normalizeScopeRoot resolves what it stores,
// and on macOS the fixture's /var spelling is a symlink to /private/var.
func temporaryWriteFixture(t *testing.T) (scope *Scope, workspace, outer, inner string) {
t.Helper()
outer = filepath.Join(t.TempDir(), "outer")
inner = filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
workspace = resolvedFixturePath(t, t.TempDir())
return &Scope{workspaceRoot: workspace}, workspace, resolvedFixturePath(t, outer), resolvedFixturePath(t, inner)
}
func resolvedFixturePath(t *testing.T, path string) string {
t.Helper()
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatalf("resolve fixture %s: %v", path, err)
}
return resolved
}
// A READER MUST NOT INHERIT ITS WRITER'S AUTHORITY.
//
// The reader's dependency on a covering TEMPORARY WRITE root was recorded as a
// reference on that root — one reference standing for both the lifetime and the
// capability. Release the WRITER first and the reference kept the path in
// extraRoots, which is the list Roots() feeds validate(), so the surviving
// read-only holder could WRITE anywhere below a write grant that had already
// ended — including paths it never asked to read.
//
// This is TestAReadGrantIsReadableButNotWritable's property for temporary
// grants: read authority stays read authority, whichever holder leaves first.
func TestAReaderOutlivingItsWriterKeepsReadAuthorityOnly(t *testing.T) {
t.Run("the writer releases first", func(t *testing.T) {
scope, workspace, outer, inner := temporaryWriteFixture(t)
writeRoot, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
if writeRoot != outer {
t.Fatalf("temporary write granted %q, want %q", writeRoot, outer)
}
readRoot, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
if readRoot != inner {
t.Fatalf("temporary read granted %q, want %q", readRoot, inner)
}
// The reader holds a read root of its own and NO reference on the write
// root. Both halves matter: the first is what survives the writer, the
// second is what stops the write root surviving with it.
//
// Errorf, not Fatalf: these say WHY the behavior below breaks, and a
// bookkeeping check that aborts the run hides the behavior it explains.
scope.mu.RLock()
writeHolders := scope.tempWrites[outer]
readHolders := scope.tempReads[inner]
scope.mu.RUnlock()
if writeHolders != 1 {
t.Errorf("tempWrites[%s] = %d, want 1: the reader took a reference on the WRITE root", outer, writeHolders)
}
if readHolders != 1 {
t.Errorf("tempReads[%s] = %d, want 1: the reader kept no read root of its own", inner, readHolders)
}
// The WRITE holder finishes; only the read-only holder is left.
releaseWrite()
if got := writeRootsBeyondWorkspace(scope); len(got) != 0 {
t.Errorf("ExtraRoots() = %v, want none: the reader held the write root open", got)
}
if got, want := scope.ReadRoots(), []string{workspace, inner}; !slices.Equal(got, want) {
t.Errorf("ReadRoots() = %v, want %v", got, want)
}
target := filepath.Join(inner, "audit-me.go")
if block := scope.validateRead(target); block != nil {
t.Errorf("the writer's cleanup revoked a live read grant on %q: %v", target, block)
}
block := scope.validate(target)
if block == nil {
t.Fatalf("a read-only holder may still WRITE %q after the write grant ended", target)
}
if block.Code != BlockOutsideWorkspace {
t.Errorf("write block code = %q, want %q", block.Code, BlockOutsideWorkspace)
}
if block.Path != target {
t.Errorf("write block path = %q, want %q", block.Path, target)
}
// The escalation was never confined to the path the reader asked for —
// a borrowed reference carries the whole covering root — so a sibling
// the reader never named has to be denied too.
sibling := filepath.Join(outer, "sibling.txt")
if block := scope.validate(sibling); block == nil {
t.Errorf("a read-only holder of %q may still WRITE %q, which it never asked to read", inner, sibling)
}
releaseRead()
if got, want := scope.ReadRoots(), []string{workspace}; !slices.Equal(got, want) {
t.Errorf("ReadRoots() = %v, want %v: the read root outlived its last holder", got, want)
}
})
// A CONTROL, NOT COVERAGE FOR THE FINDING. This subtest passes on the
// unfixed tree too — the escalation needs the WRITER to go first, which the
// subtest above exercises. It earns its place by pinning that the opposite
// release order was not broken while fixing the dangerous one, and it is
// labelled so nobody counts it as evidence the finding is closed.
t.Run("the reader releases first (control: passes unfixed)", func(t *testing.T) {
scope, _, outer, inner := temporaryWriteFixture(t)
_, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
_, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
// The other order, which the fix must not break: the writer keeps FULL
// authority for as long as it holds the grant.
releaseRead()
buildLog := filepath.Join(outer, "build.log")
if block := scope.validate(buildLog); block != nil {
t.Fatalf("the reader's cleanup revoked the live write grant on %q: %v", buildLog, block)
}
if got, want := writeRootsBeyondWorkspace(scope), []string{outer}; !slices.Equal(got, want) {
t.Errorf("ExtraRoots() = %v, want %v", got, want)
}
releaseWrite()
if block := scope.validate(buildLog); block == nil {
t.Errorf("the write root %q outlived its only holder", outer)
}
if block := scope.validateRead(filepath.Join(inner, "audit-me.go")); block == nil {
t.Errorf("the read root %q outlived its only holder", inner)
}
})
}
// A NARROWER WRITE HOLDER MUST NOT INHERIT ITS NEIGHBOUR'S TREE.
//
// The write-side twin of the read-side escalation above, and the same root
// cause: a nested holder used to take a reference on the BROADER temporary root
// that happened to cover it. That kept the nested holder alive, which was the
// point, but it kept the covering root alive to do it — and extraRoots is what
// validate() authorises writes from. Once the broad holder released, a caller
// that had asked for one subdirectory held write authority over the whole tree,
// siblings included. Reported by CodeRabbit.
func TestANestedWriteHolderKeepsOnlyItsOwnSubtree(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
outer := filepath.Join(outsideBase, "outer")
inner := filepath.Join(outer, "inner")
sibling := filepath.Join(outer, "sibling")
for _, dir := range []string{outer, inner, sibling} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, releaseOuter, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write on the broad root: %v", err)
}
_, releaseInner, err := scope.AddTemporaryWrite(inner)
if err != nil {
t.Fatalf("temporary write on the nested root: %v", err)
}
// THE BROAD HOLDER GOES FIRST — the nested holder outliving it is what
// exposes whose authority it is actually holding.
releaseOuter()
if block := scope.validate(filepath.Join(inner, "mine.txt")); block != nil {
t.Errorf("the nested holder lost the subtree it asked for: %v", block.Reason)
}
if block := scope.validate(filepath.Join(sibling, "not-mine.txt")); block == nil {
t.Error("the nested holder can write a SIBLING it never asked for: the broad holder's authority outlived the broad holder")
}
if block := scope.validate(filepath.Join(outer, "not-mine.txt")); block == nil {
t.Error("the nested holder can write the covering root itself after its holder released")
}
releaseInner()
if block := scope.validate(filepath.Join(inner, "mine.txt")); block == nil {
t.Error("the nested root outlived its only holder")
}
}
// The opposite order, as a control: the nested holder releasing first must not
// disturb the broad holder that is still live.
func TestANestedWriteHolderReleasingFirstLeavesTheBroadGrant(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
outer := filepath.Join(outsideBase, "outer")
inner := filepath.Join(outer, "inner")
for _, dir := range []string{outer, inner} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, releaseOuter, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatal(err)
}
_, releaseInner, err := scope.AddTemporaryWrite(inner)
if err != nil {
t.Fatal(err)
}
releaseInner()
if block := scope.validate(filepath.Join(inner, "still-ok.txt")); block != nil {
t.Errorf("the nested holder's release revoked the broad holder's coverage: %v", block.Reason)
}
releaseOuter()
if block := scope.validate(filepath.Join(inner, "gone.txt")); block == nil {
t.Error("the tree outlived every holder")
}
}
// TWO HOLDERS OF THE SAME ROOT SHARE ONE ENTRY. Only an exact match shares —
// a narrower request must not, which is what the escalation test above pins.
func TestTwoHoldersOfTheSameWriteRootShareOneEntry(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
target := filepath.Join(outsideBase, "shared")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatal(err)
}
_, releaseFirst, err := scope.AddTemporaryWrite(target)
if err != nil {
t.Fatal(err)
}
_, releaseSecond, err := scope.AddTemporaryWrite(target)
if err != nil {
t.Fatal(err)
}
releaseFirst()
if block := scope.validate(filepath.Join(target, "still-held.txt")); block != nil {
t.Errorf("one holder's release revoked the other's grant on the same root: %v", block.Reason)
}
releaseSecond()
if block := scope.validate(filepath.Join(target, "gone.txt")); block == nil {
t.Error("the shared root outlived both holders")
}
}
// writeRootsBeyondWorkspace is Roots() without the workspace entry.
//
// These tests need to see which BEYOND-WORKSPACE write roots a scope currently
// holds. There was an ExtraRoots() accessor that answered exactly this, but it
// had no production caller — it belongs to #829's child-grant propagation, whose
// consumer side is not on this branch, and @jatmn was right that carrying it here
// leaves unrelated production surface in a PR scoped to refcounting. The
// inspection is a test concern, so it lives in the tests.
func writeRootsBeyondWorkspace(scope *Scope) []string {
all := scope.Roots()
if len(all) == 0 {
return nil
}
return append([]string(nil), all[1:]...)
}
-366
View File
@@ -1,366 +0,0 @@
package sandbox
import (
"os"
"path/filepath"
"sync"
"testing"
)
// scopeOutsideRoots builds a workspace and an unrelated directory that no
// default write root already covers.
//
// NOT t.TempDir(). /tmp and $TMPDIR are default write roots, so a temporary
// grant for a path under one is a no-op — every assertion here would pass
// against code that grants nothing at all. That is RULES §2.3 in its exact
// form, and it caught the first version of this test.
// scopeOutsideRoots builds a workspace and a directory outside it, both under
// test-owned storage, and returns a Scope that treats the second as genuinely
// outside.
//
// THE FIXTURE PROBLEM AND ITS ACTUAL CAUSE. NewScope seeds the system temporary
// directory as a permanent write root, so an ordinary t.TempDir() grant is
// covered before any temporary grant exists and every assertion built on it is
// vacuous. The first fix for that reached into os.UserHomeDir() to find ground
// the defaults do not already own — which worked, and bought two new problems:
// a hermetic runner may expose HOME read-only, so the tests failed at MkdirTemp
// before asserting anything; and on an ordinary machine they wrote fixtures into
// the developer's real home and left zeromax-scope-* debris behind whenever a
// run was interrupted. Reported by @jatmn.
//
// The cause is the seeded defaults, not the location, so the Scope is built
// DIRECTLY here — the same pattern scopeOutsideDefaults and temporaryWriteFixture
// already use. With no defaults seeded, t.TempDir() is outside by construction,
// the fixture is hermetic, and nothing touches HOME.
//
// Paths are symlink-resolved because the scope stores resolved roots and macOS
// hands out /var/... temp directories that resolve to /private/var/...; an
// unresolved fixture path compares unequal to the root the scope just recorded.
func scopeOutsideRoots(t *testing.T) (scope *Scope, workspace string, outside string) {
t.Helper()
workspace = resolvedFixturePath(t, t.TempDir())
outside = filepath.Join(resolvedFixturePath(t, t.TempDir()), "outside")
// FATAL, NOT SKIP. Every caller of this helper asserts an AUTHORIZATION
// boundary, so a skipped fixture means that assertion never ran and the build
// reported green anyway. t.TempDir() already failed the test if it could not
// provide storage, so a subdirectory failing under it is a broken machine.
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatalf("cannot prepare %s: %v", outside, err)
}
scope = &Scope{workspaceRoot: workspace}
// PROVED AGAINST THIS SCOPE, not against production's default list. The point
// of the helper is that a grant here changes something, and the only thing
// that can answer that is the scope the test will actually use. Checking
// defaultTempWriteRoots() instead asks about a scope nobody builds here — and
// says "prove nothing" for a t.TempDir() path that this scope, seeded with no
// defaults, genuinely does not cover.
//
// Asserting the NEGATIVE up front is also what keeps every caller honest: it
// establishes that the target begins unauthorised, so a later success is the
// grant's doing and not the fixture's placement.
if scope.validate(filepath.Join(outside, "probe.txt")) == nil {
t.Fatalf("%s is writable before any grant exists, so a temporary grant here would prove nothing", outside)
}
if scope.validateRead(filepath.Join(outside, "probe.txt")) == nil {
t.Fatalf("%s is readable before any grant exists, so a temporary read grant here would prove nothing", outside)
}
return scope, workspace, outside
}
func hasReadRoot(scope *Scope, root string) bool {
for _, existing := range scope.ReadRoots() {
if existing == root {
return true
}
}
return false
}
// THE DEFECT: one holder's cleanup revoked another's access. Two read-only
// tools in the same parallel batch, both blocked on the same directory, is
// exactly this — and read-only tools are the ones the batch runs concurrently.
func TestATemporaryReadSurvivesASiblingsCleanup(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
// The probe that the fix must make unnecessary: without it this reads
// true -> false.
if hasReadRoot(scope, outside) {
t.Fatal("the outside root is already covered; this test proves nothing")
}
_, undoA, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoB, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if !hasReadRoot(scope, outside) {
t.Fatal("the grant did not take effect")
}
undoA()
if !hasReadRoot(scope, outside) {
t.Fatal("A's cleanup revoked the root while B still held a grant")
}
undoB()
if hasReadRoot(scope, outside) {
t.Fatal("the root outlived its last holder")
}
}
// A NARROWER REQUEST KEEPS ITS OWN SUBTREE AND NOTHING WIDER.
//
// THIS TEST USED TO ASSERT THE OPPOSITE, and the earlier version is worth
// recording because it is how the escalation survived a review round. It was
// written to prove a LIFETIME property — the broad holder's cleanup must not
// revoke a live narrow holder — and it proved that by asserting the BROAD root
// was still present afterwards. That is the defect stated as a requirement: the
// narrow holder was keeping its neighbour's whole tree alive in order to keep
// itself alive, so the surviving reader could read siblings it never asked for.
//
// Lifetime and capability are two properties and one reference cannot carry
// both. The assertions below name them separately: the requested path stays
// readable while its holder lives, AND a sibling under the released broad root
// is denied as soon as the holder that authorised it exits. Reported by @jatmn.
func TestANarrowerReaderKeepsOnlyItsOwnSubtree(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
sibling := filepath.Join(outside, "sibling")
for _, dir := range []string{nested, sibling} {
// FATAL, NOT SKIP. These directories are the fixture, not a precondition
// the environment might reasonably withhold — skipping on them reports a
// broken setup as a passing run.
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoNarrow, err := scope.AddTemporaryRead(nested)
if err != nil {
t.Fatal(err)
}
// THE BROAD HOLDER GOES FIRST. The narrow holder outliving it is what exposes
// whose authority it is actually holding.
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block != nil {
t.Errorf("the broad holder's cleanup revoked the subtree the narrow holder asked for: %v", block.Reason)
}
if block := scope.validateRead(filepath.Join(sibling, "not-mine.txt")); block == nil {
t.Error("the narrow reader can read a SIBLING it never asked for: the broad holder's authority outlived the broad holder")
}
if block := scope.validateRead(filepath.Join(outside, "not-mine.txt")); block == nil {
t.Error("the narrow reader can read the covering root itself after its holder released")
}
undoNarrow()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block == nil {
t.Error("the nested root outlived its only holder")
}
}
// The opposite order, as a control: the narrow reader releasing first must not
// disturb the broad reader that is still live.
func TestANarrowerReaderReleasingFirstLeavesTheBroadGrant(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoNarrow, err := scope.AddTemporaryRead(nested)
if err != nil {
t.Fatal(err)
}
undoNarrow()
if block := scope.validateRead(filepath.Join(nested, "still-ok.txt")); block != nil {
t.Errorf("the narrow holder's release revoked the broad holder's coverage: %v", block.Reason)
}
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "gone.txt")); block == nil {
t.Error("the tree outlived every holder")
}
}
// TWO READERS OF THE SAME ROOT SHARE ONE ENTRY. Only an exact match shares; a
// narrower one never does, which is what the escalation test above pins.
func TestTwoReadersOfTheSameRootShareOneEntry(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, undoFirst, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoSecond, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undoFirst()
if block := scope.validateRead(filepath.Join(outside, "still-held.txt")); block != nil {
t.Errorf("one reader's release revoked the other's grant on the same root: %v", block.Reason)
}
undoSecond()
if block := scope.validateRead(filepath.Join(outside, "gone.txt")); block == nil {
t.Error("the shared root outlived both holders")
}
}
// A NARROW PERMANENT READ IS NOT BORROWED FROM A BROAD TEMPORARY ONE. The old
// scan walked readRoots in order and stopped at the first entry covering the
// path, so a broad TEMPORARY root earlier in the slice shadowed a narrower
// PERMANENT one later — and the caller took a reference it did not need on a
// grant that was about to end.
func TestAPermanentReadIsNotShadowedByABroadTemporaryOne(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
// The broad TEMPORARY grant is taken first, so it sits earlier in readRoots.
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(nested); err != nil {
t.Fatal(err)
}
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block != nil {
t.Errorf("a PERMANENT read grant died with a broad temporary one that merely preceded it: %v", block.Reason)
}
}
// A PERMANENT root is not refcounted and must never be removed by a temporary
// holder's cleanup — the undo for a request it already covers is genuinely
// nothing.
func TestATemporaryGrantNeverRevokesAPermanentRoot(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
_, undo, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undo()
if !hasReadRoot(scope, outside) {
t.Fatal("a temporary holder's cleanup removed a permanent read root")
}
}
// The same rule for WRITE roots, or a write grant keeps the bug reads no longer
// have.
func TestATemporaryWriteSurvivesASiblingsCleanup(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
covered := func() bool {
for _, existing := range scope.Roots() {
if existing == outside {
return true
}
}
return false
}
_, undoA, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
_, undoB, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if !covered() {
t.Fatal("the write grant did not take effect")
}
undoA()
if !covered() {
t.Fatal("A's cleanup revoked the write root while B still held a grant")
}
undoB()
if covered() {
t.Fatal("the write root outlived its last holder")
}
}
// UNDER REAL CONCURRENCY, which is the shape the parallel tool batch produces:
// eight holders taking and releasing the same root in overlapping windows. The
// root must be present the whole time at least one holder has it, and gone once
// the last releases.
func TestConcurrentHoldersOfOneRoot(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
const holders = 8
var wg sync.WaitGroup
start := make(chan struct{})
release := make(chan struct{})
// TWO PER HOLDER, because each one can report at both hasReadRoot checks. At
// one slot per holder the channel fills after eight reports, the ninth send
// blocks forever, wg.Done() is never reached and wg.Wait() hangs — so the
// test would DEADLOCK rather than fail in exactly the case it exists to
// catch, since nothing drains failures until after the wait.
failures := make(chan string, 2*holders)
// Signalled by EVERY holder once its own AddTemporaryRead has returned,
// failure included: the gate below waits for all of them, so a holder that
// returned early without signalling would hang this test rather than fail it.
acquired := make(chan struct{}, holders)
for i := 0; i < holders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, undo, err := scope.AddTemporaryRead(outside)
acquired <- struct{}{}
if err != nil {
failures <- err.Error()
return
}
// Every holder must SEE its own grant for as long as it holds it.
if !hasReadRoot(scope, outside) {
failures <- "a holder could not see the root it had just been granted"
}
<-release
if !hasReadRoot(scope, outside) {
failures <- "a holder lost the root while still holding it"
}
undo()
}()
}
close(start)
// EVERY holder, not the first one. Spinning on hasReadRoot only waited for
// somebody to hold the root, and the spin is tight enough to win that race
// against the goroutines still being scheduled: measured over 200 runs, 194
// of them peaked at a single simultaneous holder and none ever reached
// eight. A test named for concurrent holders was testing one holder at a
// time, and would have passed against an implementation with no refcount at
// all. Counting the acquisitions makes the overlap real instead of hoped for.
for i := 0; i < holders; i++ {
<-acquired
}
close(release)
wg.Wait()
close(failures)
for failure := range failures {
t.Fatal(failure)
}
if hasReadRoot(scope, outside) {
t.Fatal("the root outlived every holder")
}
// AND THE BOOKKEEPING IS EMPTY, not merely invisible. hasReadRoot answers
// what a caller can see; a refcount entry left behind at zero is not visible
// that way and would still leak, one map entry per acquire/release cycle.
scope.mu.Lock()
leftover, stillCounted := scope.tempReads[outside]
scope.mu.Unlock()
if stillCounted {
t.Fatalf("the refcount table kept an entry for %q after every holder released: %v", outside, leftover)
}
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !windows
package swarm
import (
"errors"
"os"
)
// isLockContended reports whether an O_EXCL lock-create error means the lock is
// currently held (so the caller should retry/wait) rather than a hard failure.
// On POSIX an existing lock surfaces only as os.ErrExist.
func isLockContended(err error) bool {
return errors.Is(err, os.ErrExist)
}
@@ -1,18 +0,0 @@
//go:build !windows
package swarm
import (
"os"
"path/filepath"
"testing"
)
func makeRedirectedLockPath(t *testing.T, root, target string) string {
t.Helper()
lockPath := filepath.Join(root, "redirected.lock")
if err := os.Symlink(target, lockPath); err != nil {
t.Fatalf("create redirected lock path: %v", err)
}
return lockPath
}
@@ -1,20 +0,0 @@
//go:build windows
package swarm
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
func makeRedirectedLockPath(t *testing.T, root, target string) string {
t.Helper()
linkDir := filepath.Join(root, "redirected")
out, err := exec.Command("cmd", "/c", "mklink", "/J", linkDir, filepath.Dir(target)).CombinedOutput()
if err != nil {
t.Fatalf("create redirected lock junction: %v %s", err, strings.TrimSpace(string(out)))
}
return filepath.Join(linkDir, filepath.Base(target))
}
+30
View File
@@ -0,0 +1,30 @@
//go:build windows
package swarm
import (
"errors"
"os"
"syscall"
)
// Windows error codes that an O_EXCL create (or a racing delete) can surface
// when another writer holds the lock file open, instead of ERROR_FILE_EXISTS.
// Treating them as "contended" makes the lock retry/wait rather than fail the
// whole send/read spuriously under contention.
const (
errorAccessDenied = syscall.Errno(5) // ERROR_ACCESS_DENIED
errorSharingViolation = syscall.Errno(32) // ERROR_SHARING_VIOLATION
)
// isLockContended reports whether an O_EXCL lock-create error means the lock is
// currently held (so the caller should retry/wait) rather than a hard failure.
// On Windows a held lock can appear as ERROR_FILE_EXISTS (mapped to os.ErrExist)
// but also as ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION when the file is
// concurrently open or pending deletion.
func isLockContended(err error) bool {
if errors.Is(err, os.ErrExist) {
return true
}
return errors.Is(err, errorAccessDenied) || errors.Is(err, errorSharingViolation)
}
+58 -12
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/fsutil"
@@ -16,10 +17,10 @@ import (
// Mailbox is a per-agent, per-team message inbox persisted as a JSON array on
// disk at <baseDir>/<team>/inboxes/<agent>.json, written atomically under a
// advisory lock. It is hardened for untrusted input:
// lock file. It is hardened for untrusted input:
//
// - inbox files and their parent dirs are owner-only (0600/0700);
// - every write takes an exclusive advisory lock (bounded retry);
// - every write takes an exclusive lock file (bounded retry, stale-break);
// - message bodies and whole inboxes are size-capped (fail closed on oversize);
// - team/agent names are sanitized so a name can never escape the base dir;
// - malformed inbox JSON is rejected rather than silently reset (fail closed).
@@ -43,6 +44,7 @@ const (
defaultMaxMessageBytes = 64 * 1024 // 64 KiB per message
defaultMaxMessages = 1000 // per inbox
defaultLockTimeout = 5 * time.Second
lockStaleAfter = 30 * time.Second
inboxFileMode = 0o600
inboxDirMode = 0o700
)
@@ -204,7 +206,7 @@ func (m *Mailbox) Send(team, recipient string, msg Message) error {
if err := m.ensureInboxDir(path); err != nil {
return err
}
release, err := acquireLock(m.BaseDir, path+".lock", m.lockTimeout())
release, err := acquireLock(path+".lock", m.lockTimeout())
if err != nil {
return err
}
@@ -231,12 +233,12 @@ func (m *Mailbox) ReadAndConsume(team, recipient string) ([]Message, error) {
if err != nil {
return nil, err
}
// The stable lock file lives beside the inbox; create the dir so it can be taken
// The lock file lives beside the inbox; create the dir so it can be taken
// even on first read of a not-yet-existing inbox.
if err := m.ensureInboxDir(path); err != nil {
return nil, err
}
release, err := acquireLock(m.BaseDir, path+".lock", m.lockTimeout())
release, err := acquireLock(path+".lock", m.lockTimeout())
if err != nil {
return nil, err
}
@@ -332,19 +334,63 @@ func (m *Mailbox) atomicWriteJSON(path string, data any) error {
return nil
}
// acquireLock takes an exclusive advisory lock through a stable lock file. It
// retries with a short backoff until timeout. The kernel releases the lock when
// a holder exits, so crash recovery never moves or deletes the canonical path.
func acquireLock(root, lockPath string, timeout time.Duration) (func(), error) {
// lockSeq makes each lock token unique within the process even when two
// goroutines acquire in the same nanosecond.
var lockSeq atomic.Uint64
// acquireLock takes an exclusive lock by creating lockPath with O_EXCL. It
// retries with a short backoff until timeout, breaking a lock whose file is
// older than lockStaleAfter (so a crashed holder cannot deadlock the swarm).
//
// Each holder writes a unique token into the lock file and release is
// OWNERSHIP-AWARE: it removes the lock only if it still holds our token. After a
// stale-break another writer may legitimately own the lock; unconditionally
// removing it would let a third writer in and create split-brain writers that
// can corrupt or drop mailbox updates.
func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), lockSeq.Add(1))
deadline := time.Now().Add(timeout)
for {
lock, err := lockutil.TryAcquireFileLockAt(root, lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, inboxFileMode)
if err == nil {
return func() { _ = lock.Release() }, nil
// Record our ownership token; the exclusive create already holds the
// lock, so a write error only weakens diagnostics, not safety.
_, _ = f.WriteString(token)
f.Close()
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
if !isLockContended(err) {
return nil, fmt.Errorf("swarm: acquire lock: %w", err)
}
// Lock held: break it if stale via the atomic rename-with-verify in
// lockutil.ReclaimStaleLock (AUDIT-M13), otherwise wait.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= lockStaleAfter
})
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("swarm: reclaim stale 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.
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("swarm: timed out acquiring lock %s", filepath.Base(lockPath))
}
+27 -38
View File
@@ -219,75 +219,64 @@ func TestMailboxRejectsSymlinkedInboxDir(t *testing.T) {
}
}
func TestMailboxLockReleaseKeepsStablePath(t *testing.T) {
func TestMailboxLockReleaseIsOwnershipAware(t *testing.T) {
mb := newTestMailbox(t)
path, _ := mb.inboxPath("team", "bob")
if err := mb.ensureInboxDir(path); err != nil {
t.Fatalf("ensureInboxDir: %v", err)
}
lockPath := path + ".lock"
releaseA, err := acquireLock(mb.BaseDir, lockPath, time.Second)
// Writer A acquires the lock.
releaseA, err := acquireLock(lockPath, time.Second)
if err != nil {
t.Fatalf("acquire A: %v", err)
}
// Simulate a stale-break + takeover by writer B: overwrite the lock content
// with B's token (as a fresh acquire after a break would).
if err := os.WriteFile(lockPath, []byte("writer-B-token"), 0o600); err != nil {
t.Fatalf("simulate B takeover: %v", err)
}
// A's release must NOT delete B's lock (ownership-aware).
releaseA()
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("release removed stable advisory-lock path: %v", err)
t.Fatalf("A's release deleted B's lock (split-brain): %v", err)
}
// B's own release removes it.
os.Remove(lockPath)
}
func TestAcquireLockIgnoresOldUnlockedFile(t *testing.T) {
// A crashed holder can leave an old file behind, but the kernel releases its
// advisory lock. Acquisition must succeed without renaming the stable path.
func TestAcquireLockReclaimsStaleLock(t *testing.T) {
// A crashed holder's stale lock (old mtime) must be reclaimed via the atomic
// rename-with-verify path, leaving no sidelined .stale.* file (AUDIT-M13).
lockPath := filepath.Join(t.TempDir(), "x.lock")
if err := os.WriteFile(lockPath, []byte("dead-holder"), 0o600); err != nil {
t.Fatalf("seed old lock metadata: %v", err)
t.Fatalf("seed stale lock: %v", err)
}
old := time.Now().Add(-time.Hour)
old := time.Now().Add(-2 * lockStaleAfter)
if err := os.Chtimes(lockPath, old, old); err != nil {
t.Fatalf("chtimes: %v", err)
}
release, err := acquireLock(filepath.Dir(lockPath), lockPath, time.Second)
release, err := acquireLock(lockPath, time.Second)
if err != nil {
t.Fatalf("acquire over old unlocked file: %v", err)
t.Fatalf("acquireLock should reclaim a genuinely stale lock, got %v", err)
}
release()
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("stable lock path missing after release: %v", err)
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("reclaim left sidelined files: %v", matches)
}
}
func TestAcquireLockDoesNotBreakHeldLock(t *testing.T) {
// File age is irrelevant while the kernel reports an active holder.
func TestAcquireLockDoesNotBreakFreshLock(t *testing.T) {
// A fresh, held lock (recent mtime) must never be broken — the stale-break must
// not steal a live lock (AUDIT-M13).
lockPath := filepath.Join(t.TempDir(), "x.lock")
release, err := acquireLock(filepath.Dir(lockPath), lockPath, time.Second)
release, err := acquireLock(lockPath, time.Second)
if err != nil {
t.Fatalf("first acquire: %v", err)
}
defer release()
if _, err := acquireLock(filepath.Dir(lockPath), lockPath, 50*time.Millisecond); err == nil {
t.Fatal("acquireLock admitted a second holder")
}
}
func TestAcquireLockRefusesRedirectedPath(t *testing.T) {
root := t.TempDir()
target := filepath.Join(t.TempDir(), "target")
const sentinel = "do not overwrite"
if err := os.WriteFile(target, []byte(sentinel), 0o600); err != nil {
t.Fatal(err)
}
lockPath := makeRedirectedLockPath(t, root, target)
if release, err := acquireLock(root, lockPath, time.Second); err == nil {
release()
t.Fatal("acquireLock followed a redirected lock path")
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(data) != sentinel {
t.Fatalf("redirected target was modified: %q", data)
if _, err := acquireLock(lockPath, 50*time.Millisecond); err == nil {
t.Fatal("acquireLock broke a fresh, held lock (split-brain risk)")
}
}
+8 -20
View File
@@ -140,6 +140,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
return errorResult(fileUnseenMessage(relativePath))
}
previouslySeenWhole := options.FileTracker.SeenWhole(absolutePath)
updated := strings.Replace(content, oldString, newString, 1)
replacedCount := 1
if replaceAll {
@@ -164,28 +165,15 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
// Re-baseline to the content we just wrote so subsequent edits in this session
// compare against the current on-disk state, not the pre-edit version.
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
if updated == modelKnownContent {
// OUR edit, so we know precisely which lines moved: RecordEdit carries
// across the reads this edit did not disturb instead of dropping them.
//
// Record would drop all of them, and did — a file read in three pieces
// lost every piece to a single two-line edit, and the next six edits into
// regions that had been read were refused as unseen. See RecordEdit.
//
// This SUBSUMES the previouslySeenWhole special-case #956 added here.
// That branch re-recorded 1..total when the file had been read whole;
// RecordEdit does the same thing one level down (its seenWhole arm
// re-baselines as a single covering observation) and additionally keeps
// the partial reads the old else-branch discarded. Two copies of the
// rule would drift, and only one of them sees the pre-edit observation.
options.FileTracker.RecordEdit(absolutePath, []byte(content), []byte(updated), newInfo)
for _, span := range editedSpans {
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
if previouslySeenWhole {
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(updated), trackedLineTotal(updated))
} else {
for _, span := range editedSpans {
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
}
}
} else {
// A formatter rewrote the file after us. We no longer know which line
// holds what was read, so the conservative drop is the right answer here.
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
}
suffix := ""
-296
View File
@@ -1,296 +0,0 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// editTrackerFixture writes a numbered file and returns its real path plus a
// tracker that has already recorded the whole content as the current version.
//
// EvalSymlinks matters: on macOS t.TempDir() hands back /var/..., the tools
// resolve it to /private/var/..., and a tracker keyed on the unresolved path
// silently misses every lookup — which makes an edit fail for the very reason
// this file is about, and for entirely the wrong cause.
func editTrackerFixture(t *testing.T, lines []string) (string, *FileTracker, Tool) {
t.Helper()
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "index.go")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
content, _ := os.ReadFile(path)
info, _ := os.Stat(path)
tracker.Record(path, content, info)
return path, tracker, NewScopedEditFileTool(root, nil)
}
func numberedLines(total int, special map[int]string) []string {
lines := make([]string, 0, total)
for i := 1; i <= total; i++ {
if text, ok := special[i]; ok {
lines = append(lines, text)
continue
}
lines = append(lines, "filler")
}
return lines
}
func runTrackedEdit(t *testing.T, tool Tool, tracker *FileTracker, path, oldString, newString string) Result {
t.Helper()
return tool.(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": path, "old_string": oldString, "new_string": newString,
}, RunOptions{FileTracker: tracker})
}
// ONE EDIT MUST NOT ERASE EVERY OTHER READ, and this is the run that made the
// point: a 371-line file read in three pieces (40-45, 85-260, 260-371), one
// two-line edit at line 92, and then six consecutive refusals to edit regions
// that HAD been read and that the edit never touched — until the
// repeated-failure guard halted the run. The error told the model to re-read,
// which its next successful edit would have undone again.
func TestASuccessfulEditKeepsTheReadsItDidNotDisturb(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(371, map[int]string{
92: "cmp := keyCmp(k, n.split)",
200: "prio: l.prio, split: l.split,",
}))
tracker.RecordSeenRange(path, 40, 45, 371)
tracker.RecordSeenRange(path, 85, 260, 371)
tracker.RecordSeenRange(path, 260, 371, 371)
if !tracker.SeenRange(path, 200, 200) {
t.Fatal("setup: line 200 sits inside the 85-260 read and must start out seen")
}
first := runTrackedEdit(t, tool, tracker, path, "cmp := keyCmp(k, n.split)", "cmp := keyCmp(k, n.pivot)")
if strings.Contains(first.Output, "Error") {
t.Fatalf("the first edit failed, so the test never reaches its subject: %s", first.Output)
}
if !tracker.SeenRange(path, 200, 200) {
t.Error("line 200 was read, the edit at line 92 did not touch it, and the file still has 371 lines — but it is no longer considered seen")
}
// The assertion that matters: the SECOND edit is what the run could never do.
second := runTrackedEdit(t, tool, tracker, path, "prio: l.prio, split: l.split,", "prio: l.prio, pivot: l.pivot,")
if strings.Contains(second.Output, "not been read exactly in this session") {
t.Fatalf("a second edit into an already-read region was refused as unseen: %s", second.Output)
}
if strings.Contains(second.Output, "Error") {
t.Fatalf("the second edit failed: %s", second.Output)
}
}
// LINE NUMBERS MOVE WHEN AN EDIT CHANGES THE LINE COUNT, so a range after the
// edit has to be carried across SHIFTED, not left where it was.
func TestReadsAfterAnEditAreShiftedByTheLineDelta(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(100, map[int]string{
10: "short",
80: "target line",
}))
tracker.RecordSeenRange(path, 5, 20, 100)
tracker.RecordSeenRange(path, 70, 90, 100)
// Replace one line with three: everything after line 10 moves down by two.
res := runTrackedEdit(t, tool, tracker, path, "short", "one\ntwo\nthree")
if strings.Contains(res.Output, "Error") {
t.Fatalf("setup edit failed: %s", res.Output)
}
// "target line" was line 80 and is now line 82. It was read either way.
content, _ := os.ReadFile(path)
moved := strings.Count(strings.SplitN(string(content), "target line", 2)[0], "\n") + 1
if moved != 82 {
t.Fatalf("fixture moved the target to line %d, expected 82", moved)
}
if !tracker.SeenRange(path, moved, moved) {
t.Errorf("the read at lines 70-90 was not shifted with the content: line %d reads as unseen", moved)
}
second := runTrackedEdit(t, tool, tracker, path, "target line", "target changed")
if strings.Contains(second.Output, "Error") {
t.Fatalf("editing a line that was read and merely moved was refused: %s", second.Output)
}
}
// THE GUARD STILL GUARDS. Carrying reads across must not credit the model with
// content it never saw — that is the whole reason the check exists.
func TestAnUnreadRegionIsStillRefusedAfterAnEdit(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(300, map[int]string{
50: "seen line",
250: "never read line",
}))
// Only 40-60 is read. Line 250 is not.
tracker.RecordSeenRange(path, 40, 60, 300)
if res := runTrackedEdit(t, tool, tracker, path, "seen line", "seen line edited"); strings.Contains(res.Output, "Error") {
t.Fatalf("setup edit failed: %s", res.Output)
}
res := runTrackedEdit(t, tool, tracker, path, "never read line", "sneaky")
if !strings.Contains(res.Output, "not been read exactly in this session") {
t.Fatalf("an edit into a region that was NEVER read was allowed: %s", res.Output)
}
}
// A RANGE THE EDIT SPANS IS SPLIT, NOT DROPPED — and getting this wrong is not
// hypothetical: dropping on overlap was my first attempt, and it left the
// original defect exactly in place. A read almost always CONTAINS the line it
// is about to edit, because containing it is why it was read.
//
// So the flanks survive and only the rewritten lines stop being known.
func TestARangeTheEditSpansIsSplitAroundIt(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(100, map[int]string{
20: "alpha",
21: "beta",
22: "gamma",
}))
tracker.RecordSeenRange(path, 15, 30, 100)
// Replace a three-line span with one line, entirely inside the read range.
if res := runTrackedEdit(t, tool, tracker, path, "alpha\nbeta\ngamma", "merged"); strings.Contains(res.Output, "Error") {
t.Fatalf("setup edit failed: %s", res.Output)
}
// Before the edit: still known, still at the same line numbers.
if !tracker.SeenRange(path, 15, 19) {
t.Error("lines 15-19 sit before the edit and did not move, but were forgotten")
}
// After it: still known, shifted up by the two lines the edit removed.
if !tracker.SeenRange(path, 21, 28) {
t.Error("lines 23-30 were read and merely moved to 21-28, but were forgotten")
}
// The rewritten span itself is no longer described by that read.
if tracker.SeenRange(path, 15, 30) {
t.Error("the whole original range still reads as seen, so the model is credited with content the edit replaced")
}
}
// AN EXTERNAL CHANGE STILL INVALIDATES EVERYTHING. RecordEdit is only for edits
// we made; a file changed behind our back is exactly the case the blanket drop
// is right for, and it must keep working.
func TestAnExternalChangeStillDropsEveryRead(t *testing.T) {
path, tracker, _ := editTrackerFixture(t, numberedLines(100, map[int]string{50: "hello"}))
tracker.RecordSeenRange(path, 40, 60, 100)
if !tracker.SeenRange(path, 50, 50) {
t.Fatal("setup: line 50 should be seen")
}
// Something outside Zero rewrites the file.
changed := strings.Repeat("different\n", 100)
if err := os.WriteFile(path, []byte(changed), 0o644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(path)
tracker.Record(path, []byte(changed), info)
if tracker.SeenRange(path, 50, 50) {
t.Error("an external rewrite left the old read ranges in place")
}
}
// A file read in FULL stays read in full: an edit of ours does not make that
// untrue, and re-reading a whole file after every edit is the cost this avoids.
func TestAWhollyReadFileStaysWhollyRead(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(40, map[int]string{5: "one"}))
tracker.RecordSeenRange(path, 1, 40, 40)
if !tracker.SeenWhole(path) {
t.Fatal("setup: the file should read as wholly seen")
}
if res := runTrackedEdit(t, tool, tracker, path, "one", "one\ntwo"); strings.Contains(res.Output, "Error") {
t.Fatalf("edit failed: %s", res.Output)
}
if !tracker.SeenWhole(path) {
t.Error("a wholly-read file stopped being wholly read after an edit")
}
}
// The span helpers, directly: these decide what survives, so their edges are
// worth pinning independently of the tool.
func TestChangedSpanHelpers(t *testing.T) {
t.Run("a single line replaced in place", func(t *testing.T) {
first, last, delta := changedLineSpan("a\nb\nc", "a\nB\nc")
if first != 2 || last != 2 || delta != 0 {
t.Fatalf("got (%d,%d,%d), want (2,2,0)", first, last, delta)
}
})
t.Run("one line becomes three", func(t *testing.T) {
first, last, delta := changedLineSpan("a\nb\nc", "a\nx\ny\nz\nc")
if first != 2 || last != 2 || delta != 2 {
t.Fatalf("got (%d,%d,%d), want (2,2,2)", first, last, delta)
}
})
t.Run("lines removed", func(t *testing.T) {
first, last, delta := changedLineSpan("a\nb\nc\nd", "a\nd")
if first != 2 || last != 3 || delta != -2 {
t.Fatalf("got (%d,%d,%d), want (2,3,-2)", first, last, delta)
}
})
t.Run("bytes", func(t *testing.T) {
first, last, delta := changedByteSpan([]byte("abcdef"), []byte("abXYef"))
if first != 2 || last != 4 || delta != 0 {
t.Fatalf("got (%d,%d,%d), want (2,4,0)", first, last, delta)
}
})
}
// A file read whole IN PIECES must stay seen-whole after an edit. SeenWhole
// derives its answer from the ranges precisely because the raw flag is only set
// by a single covering read; RecordEdit branching on the flag instead sent this
// case down the split path, and the write_file refusal the derived answer exists
// to prevent came back within one edit.
func TestAFileReadWholeInPiecesStaysWholeAfterAnEdit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "chunked.go")
content := "l1\nl2\nl3\nl4\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
info, _ := os.Stat(path)
tracker.Record(path, []byte(content), info)
// Two reads that together cover the file, neither covering it alone.
tracker.RecordSeenRange(path, 1, 2, 4)
tracker.RecordSeenRange(path, 3, 4, 4)
if !tracker.SeenWhole(path) {
t.Fatal("two chunked reads did not add up to seen-whole")
}
updated := "l1\nl2X\nl3\nl4\n"
if err := os.WriteFile(path, []byte(updated), 0o600); err != nil {
t.Fatal(err)
}
newInfo, _ := os.Stat(path)
tracker.RecordEdit(path, []byte(content), []byte(updated), newInfo)
if !tracker.SeenWhole(path) {
t.Error("a file read whole in two chunks stopped being seen whole after one edit")
}
}
// observation.total is compared against read ranges, so it has to be the line
// count a READER would give. strings.Split leaves an empty final element for
// content ending in a newline, which counted one line too many — and since
// SeenWhole asks whether the ranges cover 1..total, that made full coverage
// unreachable for a file that had just been read in full.
func TestCountLinesMatchesWhatAReaderWouldSay(t *testing.T) {
for _, testCase := range []struct {
content string
want int
}{
{"a\nb\nc\n", 3}, // the common case: trailing newline opens no line
{"a\nb\nc", 3},
{"\n", 1},
{"", 0},
} {
if got := countLines([]byte(testCase.content)); got != testCase.want {
t.Errorf("countLines(%q) = %d, want %d", testCase.content, got, testCase.want)
}
}
}
+5 -255
View File
@@ -1,13 +1,11 @@
package tools
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"sort"
"strings"
"sync"
"time"
)
@@ -167,241 +165,6 @@ func (tracker *FileTracker) RecordSeenBytes(absPath string, start, end, total in
tracker.seen[absPath] = observation
}
// RecordEdit re-baselines absPath after an edit THIS SESSION made, keeping the
// reads the edit did not disturb.
//
// WHY THIS EXISTS RATHER THAN Record. RecordHash drops every recorded range when
// the content hash moves, which is right for a change we did not make: we cannot
// say which lines still hold what was read. After our own edit we can say
// exactly. The content before the first changed line is byte-identical and sits
// at the same line numbers; the content after the last changed line is
// byte-identical and has moved by a known delta. Only the lines the edit
// actually spans stop describing the file.
//
// Dropping the lot instead cost a real run. A 371-line file was read in three
// pieces (40-45, 85-260, 260-371) and one two-line edit at line 92 erased the
// credit for all three: the next six edits — into regions that had been read,
// that the edit did not touch, in a file whose line count had not changed — were
// each refused as content "not read in this session", and the repeated-failure
// guard halted the run. The error even told the model to re-read, which would
// have been undone by its next successful edit. The guard is right that a model
// must not edit what it has not seen; it was wrong about what it had seen.
func (tracker *FileTracker) RecordEdit(absPath string, before, after []byte, info os.FileInfo) {
if tracker == nil {
return
}
firstLine, lastLineBefore, lineDelta := changedLineSpan(string(before), string(after))
firstByte, lastByteBefore, byteDelta := changedByteSpan(before, after)
tracker.mu.Lock()
defer tracker.mu.Unlock()
version := FileVersion{Hash: HashContent(after)}
if info != nil {
version.Size = info.Size()
version.MTime = info.ModTime()
}
tracker.versions[absPath] = version
observation, tracked := tracker.seen[absPath]
if !tracked {
return
}
// The DERIVED answer, not the raw flag. Branching on observation.whole here
// sent a file read whole in two chunks — seenWhole true, flag false — into
// the split path below, where it stopped being seen whole after one edit.
if seenWhole(observation) {
// Still whole: every line was read, and an edit of ours does not make
// that untrue. Re-baseline as a single covering observation so the
// answer survives regardless of how the ranges shifted.
observation.whole = true
observation.ranges = nil
observation.byteRanges = nil
observation.total = countLines(after)
observation.totalBytes = len(after)
tracker.seen[absPath] = observation
return
}
// SPLIT AROUND THE EDIT, never drop the whole range. A read almost always
// SPANS the line it is about to edit — that is why it was read — so dropping
// on overlap would have thrown away 85-260 to change line 92 and left the
// original defect in place under a longer implementation.
kept := make([]lineRange, 0, len(observation.ranges)+1)
for _, seen := range observation.ranges {
if end := min(seen.end, firstLine-1); seen.start <= end {
kept = append(kept, lineRange{start: seen.start, end: end})
}
if start := max(seen.start, lastLineBefore+1); start <= seen.end {
kept = append(kept, lineRange{start: start + lineDelta, end: seen.end + lineDelta})
}
}
observation.ranges = kept
if observation.total != 0 {
observation.total = countLines(after)
}
// Same split, on half-open byte intervals.
keptBytes := make([]lineRange, 0, len(observation.byteRanges)+1)
for _, seen := range observation.byteRanges {
if end := min(seen.end, firstByte); seen.start < end {
keptBytes = append(keptBytes, lineRange{start: seen.start, end: end})
}
if start := max(seen.start, lastByteBefore); start < seen.end {
keptBytes = append(keptBytes, lineRange{start: start + byteDelta, end: seen.end + byteDelta})
}
}
observation.byteRanges = keptBytes
if observation.totalBytes != 0 {
observation.totalBytes = len(after)
}
tracker.seen[absPath] = observation
}
// changedLineSpan reports the 1-based first line that differs between before and
// after, the 1-based last line of BEFORE that differs, and the line-count delta.
//
// Computed from a common prefix and suffix rather than from the caller's
// replacement spans: one edit_file call with replace_all can rewrite many
// scattered occurrences, and the span between the outermost two is the only
// region that is honestly unknown afterwards.
// The span is derived by scanning BYTES, not by materialising lines.
//
// The first version split both versions into []string. That allocates one string
// header per line for each version — work that scales with the SIZE OF THE FILE
// rather than the size of the edit, and it ran before RecordEdit had even checked
// whether there was a tracked observation to update. @jatmn measured 32,036,640
// bytes for two 2 MB versions of a million short lines, and extrapolated roughly
// 1.6 GB of headers for a 100 MB file before counting the content and hashes
// already live alongside them.
//
// That mattered beyond memory pressure: edit_file writes the updated bytes BEFORE
// calling RecordEdit, so an out-of-memory kill lands after the user's file has
// changed and before the tracker baseline catches up — the file and the record of
// it disagree, and nothing says so.
//
// Bytes give the same answer in bounded memory. Two identical byte prefixes share
// every line that ends inside them, so counting newlines in the common prefix
// counts the unchanged leading lines directly.
func changedLineSpan(before, after string) (firstChanged, lastChangedBefore, delta int) {
beforeCount := countTrackedLines(before)
afterCount := countTrackedLines(after)
prefix := commonLinePrefix(before, after)
// Never more leading lines than the shorter version has. The byte scan can
// otherwise claim a line that only one side possesses — two empty versions
// share a whole "line" that neither actually contains.
prefix = min(prefix, min(beforeCount, afterCount))
// The suffix may not reach back past the lines the prefix already claimed,
// exactly as the line-array loop bounded itself.
suffix := commonLineSuffix(before, after, min(beforeCount-prefix, afterCount-prefix))
return prefix + 1, beforeCount - suffix, afterCount - beforeCount
}
// commonLinePrefix counts the leading lines that are byte-identical in both.
func commonLinePrefix(before, after string) int {
limit := min(len(before), len(after))
at := 0
for at < limit && before[at] == after[at] {
at++
}
lines := strings.Count(before[:at], "\n")
// A LINE THAT ENDS EXACTLY WHERE THE SCAN STOPPED IS STILL SHARED. Scanning
// halts where the bytes diverge OR where the shorter version runs out, and in
// the second case the line containing that point can be complete and equal on
// both sides — "a\nb" against "a\nb\nc" diverges only because the first ended,
// and "b" is a whole matching line in each.
if lineBoundary(before, at) && lineBoundary(after, at) {
lines++
}
return lines
}
// commonLineSuffix counts the trailing lines that are byte-identical in both, up
// to the ceiling the prefix leaves.
func commonLineSuffix(before, after string, ceiling int) int {
if ceiling <= 0 {
return 0
}
limit := min(len(before), len(after))
back := 0
for back < limit && before[len(before)-1-back] == after[len(after)-1-back] {
back++
}
beforeAt, afterAt := len(before)-back, len(after)-back
// Each newline inside the shared tail closes a line that lies wholly within
// it. The line the tail STARTS in is shared only when the tail begins on a
// line boundary in both versions — otherwise its opening bytes differ and it
// is a changed line that merely ends the same way.
lines := strings.Count(before[beforeAt:], "\n")
if lineStart(before, beforeAt) && lineStart(after, afterAt) {
lines++
}
return min(lines, ceiling)
}
// lineBoundary reports whether at is the end of a line: the end of the text, or
// the position of the newline that closes it.
func lineBoundary(text string, at int) bool { return at == len(text) || text[at] == '\n' }
// lineStart reports whether at begins a line.
func lineStart(text string, at int) bool { return at == 0 || text[at-1] == '\n' }
// changedByteSpan is changedLineSpan in bytes: the first differing offset, the
// end offset of the changed region in BEFORE, and the size delta.
func changedByteSpan(before, after []byte) (firstChanged, lastChangedBefore, delta int) {
prefix := 0
for prefix < len(before) && prefix < len(after) && before[prefix] == after[prefix] {
prefix++
}
suffix := 0
for suffix < len(before)-prefix && suffix < len(after)-prefix &&
before[len(before)-1-suffix] == after[len(after)-1-suffix] {
suffix++
}
return prefix, len(before) - suffix, len(after) - len(before)
}
// countLines reports the line count a READER would give the content, which is
// what observation.total is compared against.
//
// The trailing newline does not open a line. strings.Split leaves an empty final
// element for "a\nb\nc\n" and so counted 4 where a read reports 3 — and because
// SeenWhole asks whether the ranges cover 1..total, an inflated total made full
// coverage unreachable for the very file that had just been read in full. Almost
// every text file ends in a newline, so this was the common case rather than an
// edge one.
//
// countTrackedLines keeps the OTHER rule deliberately: the span arithmetic has to
// match the indices strings.Split produced, so it counts the empty element after
// a trailing newline where this one does not. Two rules, two names, both stated —
// they were one function with an if, and that is how they were confused.
func countLines(content []byte) int {
// Counted, not split. This only ever needed a number, and building a slice of
// every line to take its length was the third full per-line allocation in the
// same call path.
if len(content) == 0 {
return 0
}
lines := bytes.Count(content, []byte{'\n'})
if content[len(content)-1] != '\n' {
lines++
}
return lines
}
// countTrackedLines is countLines' rule for the string form: the number of
// elements strings.Split would produce, which counts the empty element after a
// trailing newline. The two differ deliberately — observation.total compares
// against what a READER reports, while the span arithmetic has to match the
// indices the split produced.
func countTrackedLines(text string) int {
if text == "" {
return 0
}
return strings.Count(text, "\n") + 1
}
// coversFully reports whether ranges together cover every line in [start, end].
//
// Ranges are merged rather than scanned line by line: a caller asking about a
@@ -511,27 +274,14 @@ func (tracker *FileTracker) SeenWhole(absPath string) bool {
if !ok {
return false
}
return seenWhole(observation)
}
// seenWhole is the DERIVED answer to "has every line been seen", and the single
// place that decides it. Callers must hold the lock.
//
// Derived from the ranges rather than read off the flag, because the flag is
// only ever set by a SINGLE read covering the file: a file read in two halves
// stayed "not seen whole" forever even though SeenRange agreed every line had
// been seen, and write_file, which gates on this, refused the overwrite with
// advice to read the file that could not change the answer.
//
// IT HAS TO BE ONE FUNCTION. RecordEdit branched on the raw flag while this
// derived the answer, so the very case the derivation exists to rescue fell into
// the wrong arm and lost: a file read whole in two chunks reported SeenWhole
// true, then reported false after a single edit, putting the write_file refusal
// back within one edit of where it was fixed.
func seenWhole(observation fileObservation) bool {
if observation.whole {
return true
}
// Derived from the ranges rather than tracked as its own flag. The flag was
// only ever set by a SINGLE read covering the file, so a file read in two
// halves stayed "not seen whole" forever even though SeenRange agreed every
// line had been seen — and write_file, which gates on this, then refused the
// overwrite with advice to read the file that could not change the answer.
if observation.total > 0 && coversFully(observation.ranges, 1, observation.total) {
return true
}
-83
View File
@@ -3,7 +3,6 @@ package tools
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -154,85 +153,3 @@ func TestNilFileTrackerCreatedFilesIsANoop(t *testing.T) {
t.Fatalf("CreatedFiles() on nil tracker = %v, want nil", got)
}
}
// THE SPAN IS DERIVED IN BOUNDED MEMORY, whatever the file size.
//
// changedLineSpan used to split both versions into []string, so it allocated one
// string header per line for each — work scaling with the SIZE OF THE FILE rather
// than the size of the edit, and it ran before RecordEdit had even checked whether
// there was an observation to update. @jatmn measured 32,036,640 bytes for two
// 2 MB versions of a million short lines.
//
// The cost was not only memory. edit_file writes the updated bytes BEFORE calling
// RecordEdit, so an out-of-memory kill lands after the user's file has changed and
// before the tracker baseline catches up — file and record disagree, silently.
//
// This asserts an absolute ceiling rather than a ratio, so a future rewrite that
// reintroduces per-line slices fails here rather than merely getting slower.
func TestTheChangedSpanDoesNotAllocatePerLine(t *testing.T) {
before := strings.Repeat("x\n", 200_000)
after := before[:len(before)-2] + "y\n"
spanBytes := testing.Benchmark(func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
changedLineSpan(before, after)
}
}).AllocedBytesPerOp()
if spanBytes > 4096 {
t.Errorf("changedLineSpan allocated %d bytes for a %d-line pair; per-line slices are back", spanBytes, 200_000)
}
content := []byte(before)
countBytes := testing.Benchmark(func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
countLines(content)
}
}).AllocedBytesPerOp()
if countBytes > 4096 {
t.Errorf("countLines allocated %d bytes to return a number", countBytes)
}
}
// AND IT GIVES THE SAME ANSWERS THE LINE-ARRAY VERSION DID. The rewrite is only
// safe if it is behaviour-preserving, so the replaced implementation is kept here
// as an oracle and both are asked the same questions. This caught 206 mismatches
// in the first attempt at the byte scan — empty versions and partially shared
// trailing lines, neither of which the hand-written cases below would have found.
func TestTheByteScanAgreesWithTheLineSplit(t *testing.T) {
oracle := func(before, after string) (int, int, int) {
split := func(text string) []string {
if text == "" {
return nil
}
return strings.Split(text, "\n")
}
beforeLines, afterLines := split(before), split(after)
prefix := 0
for prefix < len(beforeLines) && prefix < len(afterLines) && beforeLines[prefix] == afterLines[prefix] {
prefix++
}
suffix := 0
for suffix < len(beforeLines)-prefix && suffix < len(afterLines)-prefix &&
beforeLines[len(beforeLines)-1-suffix] == afterLines[len(afterLines)-1-suffix] {
suffix++
}
return prefix + 1, len(beforeLines) - suffix, len(afterLines) - len(beforeLines)
}
versions := []string{
"", "a", "\n", "a\n", "a\nb", "a\nb\n", "a\nb\nc", "a\nb\nc\n",
"\n\n", "x\n\ny", "a\nb\nc\nd\ne", "same\nsame\nsame\n", "ab\nc", "b\nc",
}
for _, before := range versions {
for _, after := range versions {
wantFirst, wantLast, wantDelta := oracle(before, after)
gotFirst, gotLast, gotDelta := changedLineSpan(before, after)
if wantFirst != gotFirst || wantLast != gotLast || wantDelta != gotDelta {
t.Errorf("changedLineSpan(%q, %q) = (%d, %d, %d), want (%d, %d, %d)",
before, after, gotFirst, gotLast, gotDelta, wantFirst, wantLast, wantDelta)
}
}
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ var commandDefinitions = []commandDefinition{
name: "/voice",
usage: "/voice",
group: commandGroupRuntime,
description: "Toggle voice mode (" + voiceCaptureUsage + ").",
description: "Toggle voice mode (hold Space to dictate).",
kind: commandVoice,
},
{
+14 -9
View File
@@ -88,7 +88,7 @@ type dictationController struct {
waveBars []int
waveTick int
// voiceModeEnabled repurposes Ctrl+Space into hold-to-record (§13.9).
// voiceModeEnabled repurposes Space into hold-to-record (§13.9).
voiceModeEnabled bool
// eventTypesSupported records whether the terminal confirmed key-release
// reporting (Kitty protocol); eventTypesKnown gates it until the terminal has
@@ -173,7 +173,7 @@ func firstNonEmptyStr(a, b string) string {
func (d dictationController) active() bool { return d.phase != dictIdle }
// toggleDictation starts a recording when idle and stops-and-transcribes when
// recording. Invoked by the voice-mode Ctrl+Space gesture; a call during
// recording. Invoked by the voice-mode Space gesture; a call during
// startup/transcription is ignored (the machine is mid-transition).
func (m model) toggleDictation() (model, tea.Cmd) {
if !m.dictation.available() {
@@ -193,15 +193,15 @@ func (m model) toggleDictation() (model, tea.Cmd) {
}
}
// toggleVoiceMode flips the /voice hold-to-record gesture. While on,
// Ctrl+Space records and ordinary Space continues to type normally.
// toggleVoiceMode flips the /voice hold-to-record gesture — the dictation
// trigger. While on, Space records; run /voice again to type spaces normally.
func (m model) toggleVoiceMode() (model, tea.Cmd) {
if !m.dictation.available() {
return m.appendSystemNotice("Dictation is not configured. See docs/dictation.md to set up a local engine or a Groq/OpenAI key."), nil
}
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
if m.dictation.voiceModeEnabled {
return m.showTransientNoticeInline("Voice mode on — "+voiceCaptureUsage+". Run /voice again to turn it off.", transientNoticeSuccess), nil
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
}
// Turning voice off is the "done dictating" signal, so release the warm sherpa
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
@@ -374,7 +374,7 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd)
if m.dictation.phase == dictStarting {
m.dictation.phase = dictRecording
}
// A voice-mode Ctrl+Space release that arrived mid-startup asked us to stop as soon
// A voice-mode Space release that arrived mid-startup asked us to stop as soon
// as recording began.
if m.dictation.voiceStopPending && m.dictation.phase == dictRecording {
m.dictation.voiceStopPending = false
@@ -480,8 +480,12 @@ func needsLeadingSpace(state composerState) bool {
func (m model) dictationStatusChip() string {
switch m.dictation.phase {
case dictStarting, dictRecording:
stop := "release Space to stop"
if !m.dictation.eventTypesSupported {
stop = "press Space to stop" // press-to-toggle fallback
}
wave := zeroTheme.amber.Render("● " + renderWaveBars(m.dictation.waveBars) + " REC")
return wave + zeroTheme.muted.Render(" · "+m.voiceCaptureStatus()+", Esc to cancel")
return wave + zeroTheme.muted.Render(" · "+stop+", Esc to cancel")
case dictTranscribing:
return zeroTheme.accent.Render("●") + " " + zeroTheme.muted.Render("transcribing…")
}
@@ -597,12 +601,13 @@ func (m model) handleRecTick() (model, tea.Cmd) {
return m, nil
}
// voiceModeIndicator renders the current voice gesture or transcription state.
// voiceModeIndicator renders the persistent "voice mode on" hint shown while
// idle so the user knows Space is repurposed to hold-to-record (§10).
func (m model) voiceModeIndicator() string {
if !m.dictation.voiceModeEnabled {
return ""
}
return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · "+m.voiceCaptureStatus()+" · "+m.dictation.currentModelLabel())
return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · "+m.dictation.currentModelLabel())
}
// dictationErrorText renders a dictation error for the transcript: a missing-
+10 -41
View File
@@ -4,19 +4,17 @@ import (
tea "charm.land/bubbletea/v2"
)
const voiceCaptureUsage = "hold Ctrl+Space to record; press it to toggle where key releases are unavailable"
// Voice mode's Ctrl+Space gesture — the (only) dictation trigger. Two terminal
// Voice mode's Space-hold gesture — the (only) dictation trigger. Two terminal
// tiers:
//
// 1. The terminal confirms key-release reporting (Ghostty/Kitty/WezTerm, …):
// Ctrl+Space press starts recording and release stops it — true
// hold-to-record, with ordinary Space left available for typing.
// 2. The terminal does not confirm release events: Ctrl+Space falls back to
// Space press starts recording, Space release stops it — true hold-to-record,
// no ambiguity.
// 2. The terminal does not confirm release events: Space falls back to
// press-to-toggle (press to start, press again to stop) — a deliberately
// simpler, robust fallback than inferring release from key-repeat timing
// (racy in a terminal). Legacy NUL input is decoded as Ctrl+Space by the
// terminal input layer, so the same matcher covers both tiers.
// (racy in a terminal). This works on ANY terminal, so voice mode is always
// usable once enabled.
//
// Only active while voice mode is on; the rest of dispatch is built on
// KeyPressMsg and is untouched.
@@ -29,37 +27,8 @@ func (m model) handleKeyboardEnhancements(msg tea.KeyboardEnhancementsMsg) model
return m
}
func voiceCaptureKey(msg tea.KeyMsg) bool {
mod := msg.Key().Mod &^ (tea.ModCapsLock | tea.ModNumLock)
return keyIs(msg, tea.KeySpace) && mod == tea.ModCtrl
}
// voiceCaptureReleaseKey matches either half of the held Ctrl+Space chord. A
// terminal may report Ctrl releasing first, or report the later Space release
// without ModCtrl because Ctrl is no longer down.
func voiceCaptureReleaseKey(msg tea.KeyMsg) bool {
return keyIs(msg, tea.KeySpace) || keyIs(msg, tea.KeyLeftCtrl) || keyIs(msg, tea.KeyRightCtrl)
}
func (m model) voiceCaptureStatus() string {
switch m.dictation.phase {
case dictStarting, dictRecording:
if m.dictation.eventTypesSupported {
return "release Ctrl+Space to stop"
}
return "press Ctrl+Space to stop"
case dictTranscribing:
return "transcribing…"
default:
if m.dictation.eventTypesSupported {
return "hold Ctrl+Space to record"
}
return "press Ctrl+Space to record"
}
}
// handleVoiceCapturePress handles Ctrl+Space while voice mode is on.
func (m model) handleVoiceCapturePress(msg tea.KeyMsg) (model, tea.Cmd) {
// handleVoiceSpacePress handles a Space press while voice mode is on.
func (m model) handleVoiceSpacePress(msg tea.KeyMsg) (model, tea.Cmd) {
if !m.dictation.eventTypesSupported {
// Tier 2: no release events — press-to-toggle.
return m.toggleDictation()
@@ -76,8 +45,8 @@ func (m model) handleVoiceCapturePress(msg tea.KeyMsg) (model, tea.Cmd) {
return m, nil // already recording; the release will stop it
}
// handleVoiceCaptureRelease stops a hold-to-record session when Ctrl+Space is released.
func (m model) handleVoiceCaptureRelease() (model, tea.Cmd) {
// handleVoiceSpaceRelease stops a hold-to-record session when Space is released.
func (m model) handleVoiceSpaceRelease() (model, tea.Cmd) {
if !m.dictation.spaceHeld {
return m, nil
}
+15 -200
View File
@@ -2,7 +2,6 @@ package tui
import (
"context"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
@@ -37,7 +36,7 @@ func TestToggleVoiceModeFlips(t *testing.T) {
if !next.dictation.voiceModeEnabled {
t.Fatal("first /voice should enable voice mode")
}
if next.transientNotice.text != "Voice mode on — hold Ctrl+Space to record; press it to toggle where key releases are unavailable. Run /voice again to turn it off." {
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
}
if transcriptHasText(next, "Voice mode on") {
@@ -69,27 +68,15 @@ func TestKeyboardEnhancementsRecorded(t *testing.T) {
}
}
func TestVoiceCaptureKeyIgnoresLockModifiers(t *testing.T) {
for _, mod := range []tea.KeyMod{
tea.ModCtrl | tea.ModCapsLock,
tea.ModCtrl | tea.ModNumLock,
tea.ModCtrl | tea.ModCapsLock | tea.ModNumLock,
} {
if !voiceCaptureKey(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: mod})) {
t.Fatalf("Ctrl+Space with lock modifiers %v was not recognized", mod)
}
}
}
func TestVoiceCaptureHoldStartsRecording(t *testing.T) {
func TestVoiceSpaceHoldStartsRecording(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl})
next, cmd := m.handleVoiceCapturePress(press)
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})
next, cmd := m.handleVoiceSpacePress(press)
if next.dictation.phase != dictStarting {
t.Fatalf("Ctrl+Space press should start recording (phase=%d)", next.dictation.phase)
t.Fatalf("Space press should start recording (phase=%d)", next.dictation.phase)
}
if !next.dictation.spaceHeld {
t.Error("spaceHeld should be set in hold mode")
@@ -99,28 +86,28 @@ func TestVoiceCaptureHoldStartsRecording(t *testing.T) {
}
}
func TestVoiceCaptureHoldIgnoresRepeat(t *testing.T) {
func TestVoiceSpaceHoldIgnoresRepeat(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
repeat := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl, IsRepeat: true})
next, _ := m.handleVoiceCapturePress(repeat)
repeat := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, IsRepeat: true})
next, _ := m.handleVoiceSpacePress(repeat)
if next.dictation.phase != dictRecording {
t.Error("auto-repeat while held must not restart or change phase")
}
}
func TestVoiceCaptureReleaseStops(t *testing.T) {
func TestVoiceSpaceReleaseStops(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
next, _ := m.handleVoiceCaptureRelease()
next, _ := m.handleVoiceSpaceRelease()
if next.dictation.phase != dictTranscribing {
t.Errorf("release should stop recording → transcribing (phase=%d)", next.dictation.phase)
}
@@ -136,7 +123,7 @@ func TestVoiceReleaseDuringStartupDefersStop(t *testing.T) {
m.dictation.phase = dictStarting
m.dictation.spaceHeld = true
next, _ := m.handleVoiceCaptureRelease()
next, _ := m.handleVoiceSpaceRelease()
if !next.dictation.voiceStopPending {
t.Error("a release during startup should defer the stop")
}
@@ -147,186 +134,14 @@ func TestVoiceReleaseDuringStartupDefersStop(t *testing.T) {
}
}
func TestVoiceCaptureToggleFallback(t *testing.T) {
func TestVoiceSpaceToggleFallback(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = false // no release events → toggle fallback
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl})
next, _ := m.handleVoiceCapturePress(press)
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})
next, _ := m.handleVoiceSpacePress(press)
if next.dictation.phase != dictStarting {
t.Errorf("toggle-mode Ctrl+Space should start recording (phase=%d)", next.dictation.phase)
}
}
func TestVoiceModeCtrlSpaceReleaseStopsHoldRecording(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
updated, _ := m.Update(tea.KeyReleaseMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl}))
next := updated.(model)
if next.dictation.phase != dictTranscribing || next.dictation.spaceHeld {
t.Fatalf("Ctrl+Space release did not stop hold recording: phase=%d held=%v", next.dictation.phase, next.dictation.spaceHeld)
}
}
func TestVoiceModeCaptureStopsWhenEitherChordKeyIsReleased(t *testing.T) {
for _, test := range []struct {
name string
key tea.Key
}{
{name: "left Ctrl first", key: tea.Key{Code: tea.KeyLeftCtrl}},
{name: "right Ctrl first", key: tea.Key{Code: tea.KeyRightCtrl}},
{name: "Space after Ctrl", key: tea.Key{Code: tea.KeySpace}},
} {
t.Run(test.name, func(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
updated, _ := m.Update(tea.KeyReleaseMsg(test.key))
next := updated.(model)
if next.dictation.phase != dictTranscribing || next.dictation.spaceHeld {
t.Fatalf("release did not stop hold recording: phase=%d held=%v", next.dictation.phase, next.dictation.spaceHeld)
}
})
}
}
func TestVoiceModeIndicatorDescribesTierAndPhase(t *testing.T) {
for _, test := range []struct {
name string
releaseEvents bool
phase dictationPhase
spaceHeld bool
want string
}{
{name: "toggle idle", want: "press Ctrl+Space to record"},
{name: "hold idle", releaseEvents: true, want: "hold Ctrl+Space to record"},
{name: "toggle recording", phase: dictRecording, want: "press Ctrl+Space to stop"},
{name: "hold recording", releaseEvents: true, phase: dictRecording, spaceHeld: true, want: "release Ctrl+Space to stop"},
{name: "transcribing", phase: dictTranscribing, want: "transcribing"},
} {
t.Run(test.name, func(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = test.releaseEvents
m.dictation.phase = test.phase
m.dictation.spaceHeld = test.spaceHeld
if got := m.voiceModeIndicator(); !strings.Contains(strings.ToLower(got), strings.ToLower(test.want)) {
t.Fatalf("voice indicator = %q, want %q", got, test.want)
}
})
}
}
func TestDictationStatusChipUsesVoiceCaptureShortcut(t *testing.T) {
for _, test := range []struct {
name string
releaseEvents bool
want string
}{
{name: "toggle", want: "press Ctrl+Space to stop"},
{name: "hold", releaseEvents: true, want: "release Ctrl+Space to stop"},
} {
t.Run(test.name, func(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = test.releaseEvents
m.dictation.phase = dictRecording
if got := m.dictationStatusChip(); !strings.Contains(got, test.want) {
t.Fatalf("dictation status = %q, want %q", got, test.want)
}
})
}
}
func TestVoiceCommandDescriptionExplainsBothTerminalTiers(t *testing.T) {
for _, command := range commandDefinitions {
if command.kind == commandVoice {
for _, want := range []string{"hold Ctrl+Space", "press it to toggle", "key releases"} {
if !strings.Contains(command.description, want) {
t.Fatalf("voice command description = %q, want %q", command.description, want)
}
}
return
}
}
t.Fatal("voice command definition not found")
}
func TestVoiceModePlainSpaceTypesNormally(t *testing.T) {
for _, releaseEvents := range []bool{false, true} {
t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = releaseEvents
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace}))
next := updated.(model)
if got := next.composerValue(); got != " " {
t.Fatalf("plain Space composer = %q, want one space", got)
}
if next.dictation.phase != dictIdle || cmd != nil {
t.Fatalf("plain Space started dictation: phase=%d cmd=%v", next.dictation.phase, cmd != nil)
}
})
}
}
func TestVoiceModeCtrlSpaceStartsAcrossTerminalTiers(t *testing.T) {
for _, releaseEvents := range []bool{false, true} {
t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = releaseEvents
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl}))
next := updated.(model)
if next.dictation.phase != dictStarting || cmd == nil {
t.Fatalf("Ctrl+Space did not start dictation: phase=%d cmd=%v", next.dictation.phase, cmd != nil)
}
if got := next.composerValue(); got != "" {
t.Fatalf("Ctrl+Space typed into composer: %q", got)
}
})
}
}
func TestVoiceModeTypingPreservesSpacesAcrossTerminalTiers(t *testing.T) {
for _, releaseEvents := range []bool{false, true} {
t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = releaseEvents
for _, key := range []tea.Key{
{Code: 'f', Text: "f"},
{Code: 'i', Text: "i"},
{Code: 'x', Text: "x"},
{Code: tea.KeySpace},
{Code: 't', Text: "t"},
{Code: 'h', Text: "h"},
{Code: 'e', Text: "e"},
} {
updated, _ := m.Update(tea.KeyPressMsg(key))
m = updated.(model)
}
if got := m.composerValue(); got != "fix the" {
t.Fatalf("composer = %q, want %q", got, "fix the")
}
if m.dictation.phase != dictIdle {
t.Fatalf("typing started dictation: phase=%d", m.dictation.phase)
}
})
t.Errorf("toggle-mode Space should start recording (phase=%d)", next.dictation.phase)
}
}
+9 -23
View File
@@ -30,7 +30,6 @@ import (
"github.com/Gitlawb/zero/internal/peermsg"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
@@ -548,7 +547,6 @@ type model struct {
recapIdleArmed bool
recapIdleRunID int
idleRecap string
compactionModel string // preferences.compactionModel (see providers.CompactionModelID)
modelPickerLoading bool
modelPickerLoadingProviderID string
modelPickerLoadError string
@@ -980,7 +978,6 @@ func newModel(ctx context.Context, options Options) model {
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recentModels: normalizeRecentModelEntries(options.RecentModels),
compactionModel: options.CompactionModel,
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
@@ -1539,10 +1536,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyboardEnhancementsMsg:
return m.handleKeyboardEnhancements(msg), nil
case tea.KeyReleaseMsg:
// Voice mode's hold-to-record ends on Ctrl+Space release; every other
// release event is ignored (dispatch elsewhere is press-based).
if m.dictation.voiceModeEnabled && voiceCaptureReleaseKey(msg) {
return m.handleVoiceCaptureRelease()
// Voice mode's hold-to-record ends on Space release; every other release
// event is ignored (dispatch elsewhere is press-based).
if m.dictation.voiceModeEnabled && keyIs(msg, tea.KeySpace) {
return m.handleVoiceSpaceRelease()
}
return m, nil
case tea.KeyPressMsg:
@@ -1676,10 +1673,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
}
return m.showTransientNoticeInline("Mouse interaction re-enabled.", transientNoticeSuccess), nil
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && voiceCaptureKey(msg) && m.noBlockingModal():
// Voice mode reserves Ctrl+Space for recording; ordinary Space continues
// through the composer path below.
return m.handleVoiceCapturePress(msg)
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && keyIs(msg, tea.KeySpace) && !keyHasMod(msg, tea.ModCtrl) && !keyAlt(msg) && m.noBlockingModal():
// Voice mode (/voice) repurposes Space into the record gesture — the only
// dictation trigger — so it must not also type a space. Turn voice mode
// off (/voice) to type normally.
return m.handleVoiceSpacePress(msg)
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
@@ -5484,18 +5482,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
// compaction (proactive + reactive) is enabled for every model, not just
// catalogued ones.
options.ContextWindow = modelregistry.AgentContextWindow(m.modelContextWindow(m.modelName))
// Route compaction summarization to a cheap model when one applies
// (env > config > curated default). The factory is lazy, re-evaluated
// against the model in force when the compactor asks (so an escalation
// away from the cheap model gains a summarizer), and the agent falls
// back to the main provider on any summarizer failure. Resolved against
// the ACTIVE profile so it tracks mid-session model switches.
options.Summarizer = providers.CompactionSummarizerFactory(m.providerProfile, m.compactionModel, m.newProvider)
// Let compaction re-derive its threshold after a mid-run escalate_model
// switch instead of keeping the original model's window.
options.ContextWindowFor = func(modelID string) int {
return modelregistry.AgentContextWindow(m.modelContextWindow(modelID))
}
// Post-edit self-correction is on by default in the TUI but kept FAST: it
// runs LSP diagnostics over the changed files only — cheap, change-scoped,
+12 -16
View File
@@ -22,22 +22,18 @@ import (
// Options configures the reusable Zero terminal UI shell.
type Options struct {
Cwd string
Version string // CLI build version, shown on the home screen; empty hides it
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecentModels []config.RecentModelEntry
RecapsEnabled bool
// CompactionModel is the resolved preferences.compactionModel value; see
// providers.CompactionModelID for how it combines with the env override
// and the curated cheap defaults.
CompactionModel string
Cwd string
Version string // CLI build version, shown on the home screen; empty hides it
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecentModels []config.RecentModelEntry
RecapsEnabled bool
Provider zeroruntime.Provider
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
NewTurnSessionProvider func(config.ProviderProfile, zeroruntime.Provider) zeroruntime.TurnSessionProvider
+1 -1
View File
@@ -123,7 +123,7 @@ func (m model) finalizeSTTModelFromKey(p *sttKeyPromptState, prefix string) (tea
if modelID != "" {
label += " " + modelID
}
return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice; "+voiceCaptureUsage+".", transientNoticeSuccess), nil
return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice, then hold Space to dictate.", transientNoticeSuccess), nil
}
// sttKeyPromptOverlay renders the API-key prompt as a centered modal, matching
+1 -1
View File
@@ -226,7 +226,7 @@ func (m model) handleSTTModelSelection(value string) (model, string) {
// instead); this is the manual-setup path (e.g. Termux).
hint += " Set stt.localModelPath to a model directory (see docs/dictation.md)."
} else {
hint += " Run /voice; " + voiceCaptureUsage + "."
hint += " Run /voice, then hold Space to dictate."
}
return m, hint
}