Compare commits

..
Author SHA1 Message Date
Kevin CodexandClaude Fable 5 adb3350ea5 fix(agent): branch on a dedicated-summarizer flag instead of comparing providers
Review follow-ups for #532:

- summarizeProvider now reports whether the dedicated summarizer was
  selected and summarizeClosure branches on that flag; the previous
  `chosen == provider` compared Provider interface values, which panics
  when the dynamic type is not comparable. Regression test uses a provider
  with a slice field for both the summarizer and the main provider.
- CompactionModelID's curated-default test covers the official OpenAI
  profile alongside Anthropic and Google.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
2026-08-25 20:49:40 +08:00
Kevin Codex 865bd5c22b perf(agent): cheap compaction — dedicated summarizer model, input caps, free reactive prune, window re-derive
Compaction summarization was the single most expensive recurring event
in a long run: the full elided middle — verbatim tool bodies included —
went to the session's main model at full price (a ~100k-token call near
a 200k window, recursively multiplied on overflow).

Four changes, each independently safe:

- Dedicated summarizer model. Compaction summaries route to a cheap
  model resolved as ZERO_COMPACTION_MODEL > preferences.compactionModel
  > a curated default on official endpoints (Haiku 4.5 / Gemini
  Flash-Lite / gpt-4.1-mini; custom-endpoint catalogs are unknowable so
  they get none). Built lazily on the first paid compaction; ANY
  failure permanently falls back to the main provider for the run, so a
  misconfigured summarizer can never break compaction. "main" at any
  level forces the old behavior.

- Summarizer input caps. renderTranscript clamps tool-result bodies to
  2,000 chars and tool-call arguments to 500 (head+tail, rune-safe) —
  the summary needs what was done and decided, not raw payloads. Cuts
  summarizer input 5-10x on tool-heavy sessions.

- Free reactive prune. recover() now runs the same prune-first stage as
  the proactive path: a context-limit error is first answered by
  pruning stale tool bodies at zero cost, without consuming the
  one-shot reactive budget; the paid summarizer only runs when a later
  error finds nothing left to prune.

- Window re-derivation on model switch. Options.ContextWindowFor
  (wired to the model registry in exec and the TUI) lets the loop
  re-derive the compaction threshold after escalate_model, closing the
  documented KNOWN LIMITATION where a switch to a smaller-window model
  could overflow and a larger one over-compacted.

## Rebase notes (2026-08-25, onto 2749447c)

Rebased across 214 commits. main now summarizes through the context planner
(#879) and switches models via turn sessions, so the agent side was reworked
rather than merged textually:

- Summarizer selection lives on compactionState and is resolved lazily per
  main model through summarizeWithPlanner (planner + trace preserved); a
  failing dedicated summarizer is marked broken and the same slice is
  retried once on the main provider.
- Options.Summarizer now receives the main model in force:
  `func(ctx, mainModelID string) (Provider, error)`, and may return
  (nil, nil) when no dedicated model applies. After a successful
  escalate_model switch the loop calls compactor.switchModel, which
  re-derives the window and re-resolves the summarizer — a run that started
  on the cheap model and escalated regains cheap summaries (review P2).
- recover()'s free prune stage runs before the one-shot reactive guard
  (pruning is free and cannot loop) and mirrors maybeCompact's two prune
  passes; the free retry is taken only when the pruned history fits the
  window the provider enforces (with a 5% estimate margin), otherwise the
  same call falls through to the paid summarizer. Gating on the 70%
  proactive threshold would have sent histories in the 70-95% band to the
  summarizer unnecessarily (review P1 and the follow-up question).
- ContextWindowFor is wired through the turn-session switch path; the
  planner's window is updated alongside the compactor's.

Tests: the PR's suite is ported to the new seams, plus insufficient-prune
fall-through, free prune after the budget is spent, window-vs-threshold
gating, and summarizer re-resolution across a model switch.
2026-08-25 20:37:14 +08:00
Kevin CodexandClaude Fable 5 2749447cdc fix(tools): make apply_patch tolerant and re-expose edit_file (#956)
* fix(tools): make apply_patch tolerant and re-expose edit_file

Head-to-head benchmarking against Grok Build on grok-4.6 (3 tasks × 3 runs)
showed Zero solving every task but spending 2.7× the tokens and 2.3× the
time on the multi-file feature task. Every one of the 20 apply_patch calls
in those runs failed, after which the model degraded to whole-file
write_file rewrites and read→write verification loops.

Root causes and fixes:

- The sandbox and the tool both rejected an absolute path that lies inside
  the workspace ("must stay inside the workspace"). Models routinely echo
  the absolute path read_file showed them. Absolute paths now flow through
  the normal workspace-scope validation (inside → allowed, outside → still
  denied); unified-diff headers are rewritten to a/ b/ relative form before
  git apply, and platform symlink prefixes (/var → /private/var) are
  normalised the same way the other file tools do.
- The structured parser demanded a byte-exact "*** Begin Patch" /
  "*** End Patch"; grok-4.6 writes "*** Begin Patch ***". Decorated marker
  spellings are accepted, and a unified range after "@@" ("-12,4 +12,6",
  optionally with a heading) no longer becomes a literal context search.
- Parse errors now carry a one-line format reminder so a failed call is
  recoverable in one retry instead of a format-guessing loop.
- edit_file (exact search/replace with uniqueness guard, CRLF and fuzzy
  fallbacks) is advertised to the model again alongside apply_patch, and
  the apply_patch description shows the structured format.
- Writers (edit_file, write_file, apply_patch) recorded a file's line total
  with lineCount, which is one higher than read_file's count for a file
  ending in a newline. RecordSeenRange resets every observation when the
  total changes, so a partial read_file after a successful edit silently
  discarded whole-file knowledge and the next edit was refused with
  "content that has not been read exactly in this session". All writers
  now record the same total read_file reports (trackedLineTotal).
- read_file's padded "  N | " line prefix was ~19% of every read (about
  1.5k tokens per 800-line file) and was re-sent on every later call. It is
  now the compact "N→" prefix models already know; nothing parsed the old
  form.
- The editing-discipline prompt steers: edit_file for a targeted change,
  apply_patch for multi-hunk/multi-file changes, write_file only for new
  files or full rewrites; a successful edit result is confirmation, so the
  file is not re-read to verify it.

Tests: regression tests for decorated markers, writer/reader line-total
agreement (edit → partial read → edit far away still succeeds), range hunk headers,
absolute in-workspace paths (structured + unified), outside-workspace
rejection, header-line-only relativisation, and tool visibility; the
prompt and tool-budget tests updated to the new contract.

Benchmark (grok-4.6, 3 tasks x 3 runs, harness in demo/bench-zero-vs-grok):
Zero 0.8.0 617 s / 1,527k tokens -> with this change 346 s / 493k tokens;
Grok Build 407 s / 713k. Every run of every configuration solved its task.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): keep CRLF line endings when relativizing patch headers

relativizeUnifiedPatchPaths split on a normalized "\n" and rejoined with
"\n", so any CRLF patch that needed a header rewrite lost its line endings
everywhere; git apply could then fail to match a CRLF file. Split on "\n"
only and carry each line's trailing "\r" through, including on rewritten
headers.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(sandbox): share the structured-patch classifier with apply_patch

Review follow-ups for #956:

- The tool accepted decorated structured markers ("***Begin Patch") that
  the sandbox's applyPatchPaths did not recognise, so for that spelling the
  boundary scanned the patch as a unified diff, extracted no targets and
  validated nothing. The marker classifier now lives in the sandbox package
  (StructuredPatchMarker / IsStructuredPatch) and the tool delegates to it,
  so both sides accept exactly the same set; tests cover inside, traversal
  and outside targets for the no-space spelling at the boundary.
- Document the containment model at the apply site: structured patches are
  applied through an opened os.Root (descriptor-relative, no-follow);
  unified diffs keep the pre-existing git-apply pathname flow, which the
  absolute-path rewrite does not widen (they become the same root-relative
  form relative paths always used).
- OpenAI addendum: the one-file-per-call rule names read_file, edit_file and
  write_file explicitly and allows a single multi-file apply_patch.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(sandbox): compare slash-normalised patch paths so the check passes on Windows

structuredPatchHeaderPaths normalises separators to "/", so the absolute
outside-workspace path in the boundary test must be compared in its slash
form; on Windows the backslash original never matched and the Smoke job
failed.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(sandbox): validate the parsed patch path at the scope boundary

For each accepted marker spelling, pass the exact path applyPatchRequestPaths
produced to scope.validate: an outside path must be denied and an inside
path accepted, so a separator-handling regression between the parser and the
scope cannot slip past the test.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): apply unified diffs in-process through the workspace root

Review follow-up for #956. Unified diffs were validated by pathname and then
handed to git apply, which reopened the targets by name — a check-to-use
window a symlink swap could exploit — and the absolute-path rewrite added in
this PR normalised `diff --git` headers with strings.Fields, which splits a
git C-quoted path containing whitespace.

Both go away by not shelling out at all: parseUnifiedPatch translates a
unified diff into the same operations a structured patch produces, and both
formats are applied by applyPatchOperations through os.OpenRoot — every
stat, read, create and write is descriptor-relative and refuses to follow a
link out of the root. git is no longer required by apply_patch.

Unified-diff support: modifications, creations (--- /dev/null), deletions
(+++ /dev/null), multiple hunks per file, git "rename from/to" (a move,
hunks optional) and "copy from/to" (a new copy operation whose destination
inherits only the tracker credit the source had), "\ No newline at end of
file" on either side, a/ b/ prefixes, C-quoted paths, CRLF patches, and
git's diff/index/mode lines. Hunks are placed at their "@@ -a,b" range when
the expected lines match there and located by context otherwise (so a
stale range still applies and duplicate blocks are disambiguated by the
range). Binary patches are rejected.

Removed: the git apply flow, relativizeUnifiedPatchPaths,
recheckPatchWriteTargets, missingPatchTargets, completeCreatedPatchTargets
and recordCreatedPatchTargets. validatePatchPaths/changedFilesFromPatch stay
for mutation-target listing.

Tests: the existing unified-diff suite now runs through the in-process
applier unchanged; new coverage for quoted whitespace paths (inside applies,
outside is refused untouched), a workspace path swapped for a symlink that
escapes the root (both formats refused, outside file unmodified), create +
delete + no-newline markers, range hint vs context fallback, rename with a
hunk, and binary/empty rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): harden unified-diff parsing and cover the copy operation

Review follow-ups for #956 (in-process unified applier):

- A hunk that ends before its declared line counts is reported as
  malformed instead of silently absorbing the next file's ---/+++ header
  pair (which then failed later against the wrong file). Both shapes are
  caught: leftover counts at a header or at end of input, and a --- line
  directly followed by +++ inside a hunk. The element after a patch's final
  newline is no longer counted as an empty context line, so a truncated
  hunk cannot look complete.
- "\ No newline at end of file" may only follow a file's final hunk; a
  marker before another hunk is rejected rather than leaking into that
  hunk's result.
- Zero-context insertions (git diff -U0) are documented as
  position-trusting by design, matching git apply without fuzz; hunks with
  context are verified at the range before it is used.

Tests: copy from/to (source kept, destination created with the hunk
applied and tracked with the source's whole-file state; existing
destination refused untouched; copy onto itself refused), over-declared and
truncated hunk counts, and the no-newline marker before vs after the final
hunk.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): require an @@ line before treating ---/+++ inside a hunk as a file boundary

A removed "-- x" line directly followed by an added "++ y" line is valid
hunk content that also matches the ---/+++ header shape; the boundary check
now additionally requires the following line to be a hunk header, and a
regression test keeps such a pair intact. End-of-input strictness for a hunk
cut short is documented as deliberate: applying a truncated hunk would make
a partial change (removals without their replacements).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* refactor(tools): share one line-count implementation between reader and writer

sourceLineCount and trackedLineTotal computed the same count; keeping two
copies invites exactly the reader/writer drift this PR fixed. The reader now
delegates to trackedLineTotal, which carries the tracker contract.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): verify a unified deletion's expected content before removing the file

A unified diff's "+++ /dev/null" hunks state the content being removed, but
the in-process applier dropped them and deleted unconditionally, so a stale
deletion removed a file whose current content the patch never described.
Unified deletions now keep their hunks, require at least one, and run them
through the same verifier as updates: the hunks must match the current file
and leave nothing behind, otherwise the deletion is refused and the file is
untouched. A structured "*** Delete File" keeps its unconditional meaning.

Hardening from the same review: every non-create change re-reads its source
through the opened root immediately before commit and refuses to proceed if
the bytes differ from what was planned.

Tests: matching deletion, stale deletion (file byte-for-byte unchanged),
partial deletion, header-only deletion, multi-hunk deletion with one stale
hunk (nothing removed) and all-good hunks, structured delete unchanged, and
a source changed between planning and commit for update and delete.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tools): a move with a missing source is refused before publishing

The pre-commit recheck now refuses a move whose source vanished after
planning instead of publishing the destination and then failing; update the
test to the safer contract (error names the recheck, nothing is created).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): byte-exact unified deletions and git's header-only empty-file forms

Review follow-ups for #956:

- A unified deletion is verified byte for byte: the removed lines (with the
  removed side's "\ No newline at end of file" honoured) must equal the
  current file exactly, with only the file's own line-ending style
  normalised. None of the whitespace tolerance used to locate update hunks
  applies, and a whitespace-only remainder no longer passes; a deletion is
  not recoverable, so it fails closed.
- git describes an empty file's deletion or creation with header lines only
  ("diff --git", "deleted file mode"/"new file mode", "index") and no
  hunks. Both forms are recognised: the deletion is applied only when the
  current file is empty, the creation writes an empty file, and they compose
  with hunk-carrying files in the same patch.
- The pre-commit recheck test also covers the copy operation and asserts no
  destination is created on refusal.

Tests: whitespace/indentation/blank-line/space-only/missing-newline
mismatches all refused with the file unchanged, matching no-newline marker
and CRLF file accepted, header-only deletion of a non-empty file refused,
multi-file patch with header-only delete + create + a normal hunk.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tools): remove the move source after planning via a deterministic hook

The refused-move test now creates source.txt, plans the move, and removes
the file through a test-only pre-commit hook (structuredPatchBeforeCommit)
so it exercises the post-planning disappearance path rather than a source
that was never there. Assertions unchanged: refused before publishing, no
destination created.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tui): read_file fixtures use the compact N→ prefix

Three TUI fixtures still built fake read_file output in the legacy
"  N | text" shape; they pass either way but pinned an outdated contract.
They now mirror what appendReadFileRange emits, and the shape comment and
the expanded-card assertion are updated.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): name the committed files when a patch is partially applied

Review follow-up for #956. When a later change in a multi-file patch fails
(for example the pre-commit recheck refuses a file that changed after
planning), the error now lists exactly which files were already committed
and states that the rest are unchanged, instead of a generic "partially
applied" warning. The model can then re-read only what actually changed.

Test: a three-file structured patch whose second file is altered after
planning fails with "already committed: first.txt", first.txt holds the
patched content, and the other two files are untouched.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-25 20:23:48 +08:00
github-actions[bot] ad34dc8d81 chore(main): release 0.8.0 (#896)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-22 00:08:57 +08:00
beardthelion 6edf9a8b78 fix(sandbox): normalize launcher names before the command-prefix denylist (#934)
* fix(sandbox): retire recorded approvals through a policy bump

Ahead of tightening which command prefixes may be approved, make the
upgrade path safe. A grant already on disk that the narrower rules
reject fails validation on read, and that path rejects the entire grants
file rather than the one entry. Every caller treats a read error as "no
grant" (engine.go LookupCommandPrefix, ApprovedCommandPrefixes, and the
Lookup in Evaluate), so one stale prefix would silently disable the
user's persisted deny grants too, with the error text never reaching
them.

Bump the approval policy version instead and let the store's existing
migration handle it: it backs the file up, keeps deny grants,
invalidates prior approvals, and reports the counts once through the
startup notice that both frontends already print. That is what the
mechanism was built for.

The v1-migration test pinned the policy version as a literal, so it now
derives the expected value from the constant and will not need editing
on the next bump.

* fix(sandbox): normalize launcher names before the command-prefix denylist

unsafeCommandPrefixLauncher matched the program name as written, so
python3.11, python2.7, python.exe, node.exe and git.exe all validated as
ordinary commands and could be persisted as command-prefix grants.
Lookup is a string-prefix match, so a single approval then auto-allowed
every later invocation: granting [python3.11 -c] covers
`python3.11 -c "import os; os.system(...)"` with no further prompt.

internal/agent's commandName already normalizes case and Windows
executable extensions for the allow side of the same decision; the deny
side did not, and the two disagreeing is the bug. Reduce the program to
the launcher it actually runs and match on that: executable extension,
Windows' trailing dots and spaces (python. starts python.exe), CPython
ABI flags, then a trailing version, testing the digit-stripped form as
well so python3.11 and python3 both arrive as python. A name is only
ever narrowed toward an existing entry, so the failure direction is an
extra permission prompt, never a silent grant. Digits without a
separator stay part of the name, which keeps base64, 7z and sha256sum
grantable.

Two Windows spellings cannot be resolved by name at all, so the shape is
refused alongside a path: 8.3 short names truncate the stem, which makes
POWERS~1.EXE unrecognizable, and python.exe::$DATA reaches the same
executable through its default stream. Normalizing the first token
before the banned-suggestion comparison keeps git.exe at parity with
git.

The list also gains launchers of the same class it already covers and
that normalization cannot reach: cmd, wsl, the remaining POSIX shells,
busybox, uv and uvx. Entries normalization now covers (/bin/bash,
/bin/zsh, powershell.exe, python3, pypy3) are gone; the first two were
already unreachable behind the path check above them.

Covers the persisted store, the session-scoped grants the agent takes
during a turn, the legacy-schema migration that prunes such a grant
per entry, and the Windows spellings hermetically.

* fix(sandbox): treat free-threaded CPython as a launcher too

The ABI flags came from the pre-3.13 set, so "python3.13t" and
"python3.13t.exe" normalized to themselves and stayed grantable as
command prefixes while the GIL build "python3.13" was refused. A
free-threaded interpreter runs arbitrary code just the same.

Add "t" to the flags trimmed under the existing digit guard, which is
what keeps ordinary names intact: "zstd" and "cat" have no digit beneath
the trimmed letters, so they keep their own names and stay grantable.

Reported by CodeRabbit on #934.

* fix(sandbox): refuse the remaining spellings of listed launchers

Same defect as the free-threaded build, found by sweeping the families
already on the list for names it cannot reach:

- nodejs is node on Debian and Ubuntu, and was grantable.
- python3-dbg, python3.11-dbg and python3.13t-dbg are interpreters that
  run arbitrary code; so are bash-static, pwsh-preview and
  node-nightly. A closed set of build-channel suffixes now normalizes
  them. The set stays closed deliberately: a general "-word" strip would
  also swallow node-gyp, python3-config and ruby-lsp, which are ordinary
  tools a user should still be able to approve, and those are pinned as
  must-stay-grantable.
- pnpm, yarn and bunx run package scripts exactly as npm and npx do, and
  npm and npx are already refused.
- sudoedit escalates like sudo.

The launcher-wrapper family (winpty, chroot, unshare, nsenter, parallel,
script, unbuffer, taskset, flock) has the same inconsistency against the
env/nohup/timeout/setsid entries already on the list, and is left for a
separate decision rather than widened here.

* test(sandbox): cover the -debug and -beta launcher suffixes

Four of the six build-channel suffixes had regression cases; -debug and
-beta did not, so removing either from the set left every test green.
Both now fail RED when their entry is dropped.

Reported by CodeRabbit on #934.
2026-08-21 12:50:11 +08:00
Indra Gunawan 90dcfd127e fix(modelregistry): expose reasoning effort for DeepSeek V4 models (#931)
deepseek-v4-flash / deepseek-v4-pro expose thinking-mode effort control
through the OpenAI-compatible reasoning_effort parameter (low/high/max;
medium/xhigh map onto high). reasoningEffortsForModelName did not recognize
the deepseek family, so /effort only offered 'auto' for these models.

Advertise {low, high, max} so the /effort picker and Ctrl+T ring work, and
add fallback coverage for deepseek-v4-flash/pro/reasoner/chat.
2026-08-21 12:48:12 +08:00
Vasanth T 01c3498205 feat(update): recognise Homebrew installs and add mise, go install docs (#910)
* feat(update): recognise Homebrew installs and add mise, go install docs

* fix(update): match the keg shape, correct the docs, cover Apply and cross-target

* test(update): build the release fixture from the running platform

* fix(update): require a Homebrew receipt before refusing self-update

Review found the keg check was shape-only, and it was right: a directory
tree a user built themselves can have exactly a keg's shape. Classifying
one as Homebrew refuses a `zero upgrade` that would have worked
perfectly, on a machine Homebrew has never touched.

Two changes, cheap one first. The shape rule now requires the full
<prefix>/Cellar/<formula>/<version>/bin/<binary> depth rather than one
segment fewer, which already excludes the exact path the review raised.
Then, only for a path that still looks like a keg, one Stat for
INSTALL_RECEIPT.json — the file Homebrew writes into every keg and
nothing else does.

The receipt is what the old comment argued against, on the grounds that
it would put filesystem I/O on a path that runs at every version check.
That is no longer true of it: the Stat happens only after the shape test
has already matched, so an ordinary install still reaches no filesystem
here at all.

A directory named INSTALL_RECEIPT.json is not a receipt, which a bare
Stat would have accepted silently.

NOT taken: the same review asked for the Homebrew branch to be moved
ahead of the cross-target one in upgradeGuidance. That ordering is
deliberate and already pinned by
TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew, whose comment
predicted exactly this reading — "it reads like an ordering bug until you
see which question is being answered". Homebrew is a property of the
binary on THIS machine; when the check was asked about a different
target the answer belongs to that other machine, and `brew upgrade zero`
would change this one instead. The reasoning is now recorded at the
branch as well as in the test, so the next reader finds it in both
places.

The install docs claimed detection was the Cellar path alone, which
undersold it, and stated the self-update rule absolutely in one paragraph
before excepting mise in the next. Both say the same thing now.

* test(update): give the Homebrew fixtures a receipt, and stop gating the receipt rule on the host

The previous commit made a receipt the thing that distinguishes a keg from any
directory sitting under a path containing "Cellar". Two fixtures still built a
keg without one, so detection correctly answered standalone and both failed:

  TestDetectInstallMethodRecognisesAHomebrewKeg  keg path: got "standalone", want "homebrew"
  TestApplyRefusesToUpdateAHomebrewKeg           refusal does not name the command that works:
                                                 download release archive: dial tcp: lookup example.test

The second is worth reading twice: with detection returning standalone, Apply
stopped refusing and went to the network, so the failure surfaced as a DNS error
rather than as a missing refusal. Both fixtures write a receipt now.

The reason this reached CI at all is the second half. Every test of the receipt
rule passed runtime.GOOS to isHomebrewPath, which already takes the target as a
parameter, so all of them skipped on Windows and the rule could only ever break
somewhere else. They pass an explicit target now and run everywhere. The two
that exercise DetectInstallMethod stay host-gated, because those are about the
wired entry point rather than the rule, but the rule underneath them is no
longer invisible here.

Verified the receipt path arithmetic on both non-Windows targets rather than
trusting the host-gated tests: isHomebrewPath is false before the receipt is
written and true after, with the receipt at the keg directory hasHomebrewReceipt
actually reads.
2026-08-21 12:33:21 +08:00
1ec7219aee feat: use Exa as default search provider (#926)
* feat: use Exa as default search provider

Amp-Thread-ID: https://ampcode.com/threads/T-01a01522-a5f1-7139-8a55-5e417cba2751
Co-authored-by: Amp <[email protected]>

* fix(config): carry a legacy default's disable across the Exa migration

A user who ran `zero mcp disable firecrawl` chose not to open that outbound
connection. Renaming the built-in default to exa re-opened it under the new
name on upgrade, and because the replacement looked like an untouched default
the startup warning stayed quiet too.

Carry an explicit user-level disable of a retired default onto its successor,
but only when the user never declared the successor themselves — an explicit
exa entry wins either way. The carried-over disable is recorded as a user-level
decision, so the lower-trust project layer cannot lift it while
`zero mcp enable exa` still can.

Regression coverage: the legacy-disabled upgrade path, the explicit-exa
override, a configured-but-enabled legacy entry, the CLI re-enable, the
project-layer re-enable attempt, and the resulting startup behavior
(NormalizeConfig must not hand back an exa server to connect to).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(config): keep a retired default's transport for partial user entries

A user who added an API key to the firecrawl default wrote only the header —
the seeded default supplied the http type and URL. Once the default stops being
seeded that entry resolves with no type, url, or command, and NormalizeConfig
rejects it, taking down startup for every other server too rather than just
that one.

Record what Zero used to ship for a retired default and re-seed it under the
user's own fields. Only for an entry that named no transport of its own: an
entry with a self-hosted url or a stdio command is a complete definition the
user owns, and grafting the retired http default onto a command produces a
server that is rejected outright.

Regression coverage at both ResolveMCP and NormalizeConfig for the header-only
entry and its failure path, the self-hosted stdio entry that must be left
alone, and a disabled retired entry keeping a usable transport so a later
re-enable resolves.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 20:26:24 +08:00
Eli Lap 1a81292d3f Fix LM studio (local) model discovery. (#918)
* Fix LM studio (local) model discovery.

* fix(openai): handle typed-nil properties in normalizeToolParameters

The `properties == nil` interface comparison misses typed-nil
`map[string]any` values, which JSON-serialize as `null` instead of
`{}`. Strict OpenAI-compatible servers (LM Studio) reject the request.

Type-assert first, then compare the underlying map, so a typed-nil
properties field is rewritten to an empty object. Adds a regression
test that exercises the typed-nil path.

* test(openai): cover non-map properties in normalizeToolParameters

CodeRabbit follow-up on the typed-nil fix: lock in the behavior
when `properties` is a non-map value (e.g., empty slice) so the
type-assert branch normalizes it to an empty object on the wire.
2026-08-20 20:26:03 +08:00
Vasanth T a78c6c357e fix(acp): three defects a desktop ACP client hits (#915)
* fix(acp): four defects a desktop ACP client hits

Found by driving `zero acp` from ZeroApp and comparing both sides of the
wire. None of them fail loudly — they produce a blank transcript, a
silent denial, a spurious crash, or two buttons nobody can tell apart.

session/load restored the conversation into the agent's own memory and
sent nothing. The turns were read from the store and used to build every
later prompt, but not one notification went out, so a client that resumed
opened an empty transcript against a model that remembered everything.
Ask a follow-up and ZERO answers correctly, about a question no longer on
screen. It now replays the loaded turns as user/agent message chunks
before returning, so a client rendering on the reply already has them.
Whole messages rather than per-token deltas: this is a transcript, not a
stream.

A permission option we offered could not be accepted. The fallback list
for a request with no AvailableDecisions lived inside
buildPermissionOptions, while requestPermission validated the client's
answer against the raw field — so whenever ZERO did not enumerate, the
client was sent Allow and Reject and its reply was checked against an
empty slice. Every button it could possibly show failed closed to deny
with "permission option was not offered". The user clicked Allow and ZERO
recorded a denial, and nothing surfaced it, because a denial is a
legitimate answer. One resolver now feeds both, so they agree by
construction. The narrowing this validation exists for is unchanged: an
unoffered broader grant is still refused.

Cancelling a permission prompt came back as an internal error. Only
context.Canceled was recognised, so errPermissionApprovalCanceled fell
through to -32603 carrying the sentinel text, and clients render that as
a failed turn — declining a tool looked like ZERO falling over. For
apply_patch, dismissing the dialog is the only refusal a client is
offered. It maps to StopCancelled now. The sentinel is exported so the
surfaces can recognise it; a genuine failure is still an error.

request_permissions labelled two different decisions "Allow". The label
is the only thing an ACP client shows, and plain allow and strict-review
allow are offered together, so the panel had two identical buttons where
one silently enabled strict auto-review of what was granted. The
distinction cannot live in the option id, which is opaque to the client,
so it is in the name.

Eleven tests, each confirmed to fail without its fix.

* refactor(acp): drop the session/load replay, superseded by #914

gnanam's #914 lands the same fix and does more with it: the replayed
messages carry stable ids derived from the store's event ids, session/load
replays while session/resume deliberately does not, and both are
capability-gated. Keeping a second, weaker replay here would have meant a
conflict in handleSessionLoad and translate.go for no gain.

translate.go is back to its state on main. What remains are the three
defects #914 does not touch: the permission option that could not be
accepted, the cancel that arrived as an internal error, and the two
options labelled the same.

* fix(acp): validate a permission reply against the options that were sent

CodeRabbit caught a hole on the same seam this PR already fixes, in the
other direction.

buildPermissionOptions drops PermissionDecisionCancel, because ACP
expresses cancellation through the outcome rather than an option. But
ZERO enumerates cancel in AvailableDecisions for shell commands and
apply_patch — the two commonest prompts — so validating against the
decisions accepted {"outcome":"selected","optionId":"cancel"}: an
identifier no client was ever shown, aborting the whole turn.

Rather than reject cancel by name, decisionFromOutcome now takes the
[]PermissionOption that were actually sent, and requestPermission builds
that list once and uses it for both halves. "What we offered" and "what we
accept" become the same slice instead of two things kept in step, so
anything optionKindFor declines to render is excluded structurally — the
next such action is covered the day it is added.

The regression test is written over every option-less decision rather
than over cancel specifically, for the same reason. Cancelling through
the outcome still works, which is asserted separately: closing the
selected-id route must not close the real one.

Also adds the cancellation coverage CodeRabbit asked for on the two
sandbox-retry paths. Both wrap ErrPermissionApprovalCanceled and each is
reached by a different denial, and the ACP mapping in this PR depends on
them producing something recognisable. They also assert the escalated
retry does not run: a cancelled prompt that still ran it would run the
command the user had just declined.
2026-08-20 20:24:02 +08:00
euxaristiaandeuxaristia 8dd33ec39e docs(agents): Record four recurring review blockers (#899)
* docs(agents): Record four recurring review blockers

These classes come up across unrelated pull requests, are not caught by CI,
and nothing in AGENTS.md currently prevents them, so each is rediscovered by a
reviewer at the cost of a round trip.

Host tool version floors is the clearest case. Zero shells out to git and the
project declares no minimum version anywhere, so adding a modern flag raises
that floor silently and an older host fails with a usage error instead of a
clear message. Prove-the-test-fails addresses a recurring shape where the
problem is not a missing test but one that passes either way, because an
earlier guard rejects the input before the behavior under test runs. Hermetic
tests covers isolation helpers applied to some tests but not others reaching
the same storage. Platform error codes extends the existing platform-truth
rule, which covers path canonicalization but not errno and NTSTATUS values.

Advisory lint findings are deliberately not included: deadcode and lint-static
both run with continue-on-error in ci.yml, and section 3 already requires
fixing findings the change introduces.

Refs #898

* docs(agents): Tighten version-floor, isolation, and errno guidance

Three corrections from review, two of them to claims that were too strong.

Git version floors are per subcommand, not per flag. --end-of-options reached
the shared parser in 2.24 but git rev-parse only accepted it from 2.30, so
recording the flag's general debut would still let a caller ship something
that fails on 2.24 through 2.29.

The hermetic-tests bullet named config, cache, and state but gave a procedure
covering only config. It now covers each root, including %LocalAppData% for
the cache root, which os.UserCacheDir resolves without consulting XDG.

The errno bullet asserted ENOTDIR on Linux and Darwin. Only Linux was actually
measured; Darwin was assumed, in a bullet whose whole point is not to assume.
It now says the call may report either code and both should be accepted, which
is what the guidance was trying to teach.

Refs #898

* docs(agents): Clarify macOS and Windows roots for hermetic test isolation.

os.UserConfigDir and os.UserCacheDir derive from HOME on macOS and AppData/LocalAppData on Windows rather than reading XDG variables.

Refs #899

---------

Co-authored-by: euxaristia <[email protected]>
2026-08-20 20:23:31 +08:00
Anandan d7ac85cbf9 Polish Zero's TUI and harden patch, model, and background workflows (#916)
* Checkpoint UI polish

* Fix structured apply patch handling

* Restore tool previews when resuming sessions

* Improve theme-aware code rendering

* Replace persistent sidebar with run details

* Remove inert transcript interactions

* Cover resumed edit previews

* Smooth active run status feedback

* Preserve semantic colors behind overlays

* Normalize ChatGPT model aliases

* Render staged image attachments in terminal

* Polish transcript and tool feedback

* Resolve rebased diff renderer conflict

* Address UI polish review findings

* Fix smoke test regressions

* Harden patch and transcript edge cases

* Bind structured patches to workspace root

* Use unique structured patch temp files

* Retry structured patch temp allocation

* Reuse rooted patch temp allocator

* Fix Windows CI process checks

* Fix structured patch safety boundaries

Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files.

* Resolve plan test seam after rebase

Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes.

* Address final review correctness gaps

Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility.

* Harden patch transactions and review edges

Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture.

* Bind patch rollback to committed versions

Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races.

* Align structured patch failure semantics

Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation.

* Preserve patch context bytes

Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
2026-08-19 22:18:47 +05:30
74 changed files with 4635 additions and 456 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.7.0"}
{".":"0.8.0"}
+39
View File
@@ -111,3 +111,42 @@ These classes drive multi-round reviews. Fix them before requesting review:
shipped. Wire advertised entry points or shrink the claim. Do not bundle
unrelated fixes. Do not widen security allowlists by name alone; check
classification and side effects too.
- **Host tool version floors:** Zero shells out to `git` and other host tools,
and the project does not declare a minimum version for any of them. A flag,
subcommand, or config key that is not long-established therefore raises that
floor silently, and the failure on an older host is an unhelpful usage error
rather than a clear message. Before adding one, check when it landed and say
so where a caller would look. Record the floor for the exact subcommand you
call, not for the flag in general: `--end-of-options` reached Git's shared
parser in 2.24, but `git rev-parse` only accepted it from 2.30, and
`git checkout` and `git reset` later still. Likewise `--show-current` is
`git branch` 2.22, and `branch.autoSetupMerge=inherit` is 2.35. Then either
keep a fallback for older versions or gate the call. A test that depends on a
newer feature must skip below its floor instead of failing, and say which
version it needs.
- **Prove the test fails without the change:** A regression test earns its
place only if it fails on the unfixed code, for the reason it claims. Run it
both ways and quote the failure in the PR. The recurring miss is not a
missing test but a test that never reaches the behavior it names, because an
earlier guard rejects the input first, so it passes with and without the fix.
When a test targets a specific layer, call that layer directly rather than
relying on a public entry point that may refuse earlier.
- **Hermetic tests:** Tests must not read or write the developer's real config,
cache, or state directories. Redirect every root the code under test
resolves, not just config. `os.UserConfigDir` and `os.UserCacheDir` read
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` only on Linux and the BSDs. On macOS
they derive from `HOME`, and on Windows from `%AppData%` and `%LocalAppData%`.
Setting the XDG variables alone still leaves a macOS run writing to the real
home, so set `HOME` as well, or take the directory as a parameter. Apply it to
every test that reaches the same storage, not only the one where it was first
needed; a test that passes on a clean machine and pollutes a real one is a
blocker.
- **Error codes vary by platform and by flag combination:** A check written
against the code one target returns can be silently inert on another while
still looking correct. `openat(O_DIRECTORY|O_NOFOLLOW)` on a symlink may
report `ENOTDIR` or `ELOOP` depending on the platform, because the kernel can
reject the directory mismatch before it ever resolves the link; accept both
rather than assuming the one POSIX names. On Windows, `\\?\` and `\\.\`
prefixes are not UNC paths, and an `NtCreateFile` information class must match
the struct actually passed. Confirm the code each supported target returns
instead of reasoning from the man page.
+21
View File
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
### Features
* **security:** path-jail primitive, credential-store locking, worktree git hardening ([#891](https://github.com/Gitlawb/zero/issues/891)) ([060d38c](https://github.com/Gitlawb/zero/commit/060d38ce716feb7c5b6657b082cd35cb8dda3d75))
* **update:** recognise Homebrew installs and add mise, go install docs ([#910](https://github.com/Gitlawb/zero/issues/910)) ([01c3498](https://github.com/Gitlawb/zero/commit/01c3498205e69d26e5f929225f9455b276444f39))
* use Exa as default search provider ([#926](https://github.com/Gitlawb/zero/issues/926)) ([1ec7219](https://github.com/Gitlawb/zero/commit/1ec7219aee7b2840481ab0905f131644e27d2a4e))
* **zerogit:** auto-create a conventional branch before push/pr on default branch ([#671](https://github.com/Gitlawb/zero/issues/671)) ([d065467](https://github.com/Gitlawb/zero/commit/d065467cd3ec3ed91c5dc60554c36fc4b4d6de4c))
### Bug Fixes
* **acp:** three defects a desktop ACP client hits ([#915](https://github.com/Gitlawb/zero/issues/915)) ([a78c6c3](https://github.com/Gitlawb/zero/commit/a78c6c357e1d21146d7cd2e8030a754e06f7f1f5))
* bump Go to 1.26.6 for stdlib vulnerability fixes ([#903](https://github.com/Gitlawb/zero/issues/903)) ([dc15e82](https://github.com/Gitlawb/zero/commit/dc15e822987d8d7de467c62c375518f435b74d8c))
* **daemon:** clean up child after startup timeout ([#774](https://github.com/Gitlawb/zero/issues/774)) ([9c29209](https://github.com/Gitlawb/zero/commit/9c292092a26c9092fd873d072b0af1b0d9d39ef0))
* **dictation:** redact API keys from streaming transcriber errors ([#852](https://github.com/Gitlawb/zero/issues/852)) ([64a783b](https://github.com/Gitlawb/zero/commit/64a783b08caa2c7b21f188033b9ed84a14e00e84))
* **modelregistry:** expose reasoning effort for DeepSeek V4 models ([#931](https://github.com/Gitlawb/zero/issues/931)) ([90dcfd1](https://github.com/Gitlawb/zero/commit/90dcfd127e8a6d9902ec9f4e72f6d03fef1a0fc6))
* **providers:** discover ChatGPT capabilities ([#890](https://github.com/Gitlawb/zero/issues/890)) ([2d2450e](https://github.com/Gitlawb/zero/commit/2d2450e9a744349f0d01b1d4e9ba29c24ba5650d))
* **sandbox:** normalize launcher names before the command-prefix denylist ([#934](https://github.com/Gitlawb/zero/issues/934)) ([6edf9a8](https://github.com/Gitlawb/zero/commit/6edf9a8b78dc030dc44598919db1c7fa9d4f809a))
## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.26.5+.
Source builds require Go 1.26.6+.
```bash
git clone https://github.com/Gitlawb/zero.git
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.26.5+。
源码构建需要 Go 1.26.6+。
```bash
git clone https://github.com/Gitlawb/zero.git
+1 -1
View File
@@ -33,4 +33,4 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to the documented Firecrawl endpoint — see the README).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
+83 -8
View File
@@ -1,14 +1,30 @@
# Installing Zero
Zero is distributed as:
Pick whichever fits how you already install things. None of these is the
blessed one.
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.26.5+
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
`zero upgrade` follows whatever owns the binary, with one exception it cannot
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
keg and names `brew upgrade zero`, and replaces the binary in place for
everything else — including a mise-managed one, which is the exception.
mise is the one case it cannot detect: a mise-managed binary is an ordinary
standalone install on disk, so `zero upgrade` will replace it and leave mise
describing a version that is no longer there. Use `mise upgrade` instead.
Release archives are the substrate for most of the above: the install scripts
and the npm fallback both download a platform archive from a published GitHub
Release and verify its checksum.
## npm
@@ -111,6 +127,45 @@ Defaults:
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
## mise
[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release
archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
mise-managed binary is an ordinary standalone install, but then mise's records
describe a version that is no longer on disk. Prefer `mise upgrade`.
## go install
```bash
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.6+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
macOS and Windows need no extra helper.
## From Source
```bash
@@ -125,7 +180,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.5+.
Source builds require Go 1.26.6+.
### Sandbox Helpers For Source Builds
@@ -242,3 +297,23 @@ zero upgrade
```
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
`zero upgrade` behaves differently depending on how Zero was installed, because
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
- **everything else**: downloads the verified release archive and replaces the
binary in place.
Homebrew is detected by the binary living inside a Cellar keg that carries
Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a
directory tree you built yourself can have exactly that shape, and refusing to
update an install Homebrew never touched would be a real cost for a made-up
reason. The receipt is the thing only Homebrew writes.
An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac
where that is also the Homebrew prefix.
+16 -2
View File
@@ -296,6 +296,16 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
if errors.Is(err, context.Canceled) {
return StopCancelled, nil
}
// A CLIENT THAT CANCELLED A PERMISSION PROMPT DID NOT FAIL. Only
// context.Canceled was matched here, so dismissing a permission dialog —
// a deliberate action, and for apply_patch the only refusal a client is
// offered — came back as JSON-RPC -32603 carrying the internal sentinel
// text. Editors and the desktop app both render that as a crashed turn,
// so declining a tool looked like ZERO falling over. It is a
// cancellation, and StopCancelled is what ACP has for saying so.
if errors.Is(err, agent.ErrPermissionApprovalCanceled) {
return StopCancelled, nil
}
return "", err
}
if result.FinishReason == "length" {
@@ -311,10 +321,14 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
// session/request_permission request and maps the outcome back. Failure to reach
// the client fails closed to deny.
func (a *Agent) requestPermission(ctx context.Context, sessionID string, req agent.PermissionRequest) (agent.PermissionDecision, error) {
// Built ONCE and used for both halves. Sending one list and validating the
// reply against another is the defect this whole path had; see
// decisionFromOutcome.
options := buildPermissionOptions(req)
params := RequestPermissionParams{
SessionID: sessionID,
ToolCall: permissionToolCall(req),
Options: buildPermissionOptions(req),
Options: options,
}
var result RequestPermissionResult
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
@@ -323,7 +337,7 @@ func (a *Agent) requestPermission(ctx context.Context, sessionID string, req age
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission request failed: " + err.Error()}, nil
}
return decisionFromOutcome(result.Outcome, req.AvailableDecisions), nil
return decisionFromOutcome(result.Outcome, options), nil
}
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
+217
View File
@@ -0,0 +1,217 @@
package acp
// desktopinterop_test.go covers three defects found by driving the real `zero
// acp` binary from a desktop ACP client. Each is a case where ZERO and a
// conforming client disagreed about the wire, and none of them failed loudly —
// they produced a silent denial, a spurious crash, or two buttons the user
// could not tell apart.
//
// A fourth, session/load restoring history without replaying it, is fixed by
// #914 rather than here: that one also gives the replayed messages stable ids
// and keeps session/resume replay-free, which this did not.
import (
"errors"
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
// The list the options were BUILT from and the list the answer was CHECKED
// against were different whenever ZERO did not enumerate: the client was sent
// Allow and Reject, and its reply was validated against an empty slice, so
// every button it could possibly show failed closed to deny. The user clicked
// Allow and ZERO recorded a denial, with nothing on screen to say so.
func TestAnOfferedOptionIsAccepted(t *testing.T) {
// No AvailableDecisions: the fallback path, which is every permission event
// that is not a prompt.
req := agent.PermissionRequest{ToolName: "bash"}
options := buildPermissionOptions(req)
if len(options) == 0 {
t.Fatal("no options were offered")
}
for _, option := range options {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
buildPermissionOptions(req),
)
if string(decision.Action) != option.OptionID {
t.Fatalf("option %q was answered with %q (%s) — an offered option must be accepted",
option.OptionID, decision.Action, decision.Reason)
}
}
}
// The narrowing this validation exists for still holds: a client must not be
// able to return a broader grant than it was shown.
func TestAnOptionThatWasNotOfferedIsStillRefused(t *testing.T) {
req := agent.PermissionRequest{ToolName: "bash"}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAlwaysAllow)},
buildPermissionOptions(req),
)
if decision.Action != agent.PermissionDecisionDeny {
t.Fatalf("an unoffered broader grant was accepted as %q", decision.Action)
}
}
// The resolver is the single source of truth for both sides, which is what
// makes the two agree by construction rather than by being kept in step.
func TestOfferedDecisionsMatchWhatWasSent(t *testing.T) {
for _, req := range []agent.PermissionRequest{
{ToolName: "bash"},
{ToolName: "bash", AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionDeny,
}},
} {
offered := buildPermissionOptions(req)
for _, option := range buildPermissionOptions(req) {
if !actionOffered(option.OptionID, offered) {
t.Fatalf("option %q was sent but is not in the offered set %v", option.OptionID, offered)
}
}
}
}
// ---- two options the user can tell apart ----
// request_permissions offers plain allow and strict-review allow together, and
// both were labelled "Allow". The label is the only thing an ACP client shows,
// so the panel presented two identical buttons where one silently enabled
// strict auto-review of what was granted.
func TestEveryOfferedOptionHasADistinctLabel(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "request_permissions",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowStrict,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionDeny,
},
}
seen := map[string]string{}
for _, option := range buildPermissionOptions(req) {
if previous, clash := seen[option.Name]; clash {
t.Fatalf("options %q and %q share the label %q — a user cannot tell them apart",
previous, option.OptionID, option.Name)
}
seen[option.Name] = option.OptionID
}
}
// The two remain distinct decisions: the label changed, the round trip did not.
func TestStrictAllowStillRoundTrips(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "request_permissions",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowStrict,
},
}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAllowStrict)},
buildPermissionOptions(req),
)
if decision.Action != agent.PermissionDecisionAllowStrict {
t.Fatalf("strict allow round-tripped as %q", decision.Action)
}
}
// ---- cancelling a permission is a cancellation, not a crash ----
// Only context.Canceled was recognised, so dismissing a permission dialog came
// back as JSON-RPC -32603 carrying the internal sentinel text. Clients render
// that as a failed turn, so declining a tool looked like ZERO falling over —
// and for apply_patch, dismissing is the ONLY refusal a client is offered.
func TestCancellingAPermissionEndsTheTurnAsCancelled(t *testing.T) {
canceled := fmt.Errorf("%w for apply_patch", agent.ErrPermissionApprovalCanceled)
reason, err := stopReasonFor(agent.Result{}, canceled)
if err != nil {
t.Fatalf("stopReasonFor returned an error for a cancellation: %v", err)
}
if reason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", reason, StopCancelled)
}
}
// A genuine failure must still be an error. Mapping too much to cancelled would
// hide real faults behind a clean ending, which is the opposite mistake.
func TestARealFailureIsStillAnError(t *testing.T) {
failure := errors.New("provider exploded")
if _, err := stopReasonFor(agent.Result{}, failure); !errors.Is(err, failure) {
t.Fatalf("stopReasonFor(%v) = %v, want the failure preserved", failure, err)
}
}
// The sentinel is matched through errors.Is, so it survives the wrapping every
// return site applies — each one adds the tool name.
func TestPermissionCancellationSurvivesWrapping(t *testing.T) {
if !errors.Is(fmt.Errorf("%w for bash", agent.ErrPermissionApprovalCanceled), agent.ErrPermissionApprovalCanceled) {
t.Fatal("a wrapped permission cancellation was not recognised")
}
if errors.Is(errors.New("something else"), agent.ErrPermissionApprovalCanceled) {
t.Fatal("an unrelated error was reported as a permission cancellation")
}
}
// A decision that never became an option cannot be selected.
//
// PermissionDecisionCancel is the case that exists today: optionKindFor drops
// it because ACP expresses cancellation through the outcome, yet ZERO
// enumerates it in AvailableDecisions for shell commands and apply_patch. While
// the reply was validated against the DECISIONS rather than the sent options, a
// client could return {"outcome":"selected","optionId":"cancel"} — an id it was
// never shown — and abort the whole turn.
//
// Written over every option-less decision rather than over "cancel", so a
// future action that optionKindFor declines to render is covered the day it is
// added rather than the day someone remembers this test.
func TestADecisionThatIsNotAnOptionCannotBeSelected(t *testing.T) {
for _, toolName := range []string{"bash", "apply_patch"} {
req := agent.PermissionRequest{
ToolName: toolName,
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
agent.PermissionDecisionCancel,
},
}
options := buildPermissionOptions(req)
for _, action := range req.AvailableDecisions {
if actionOffered(string(action), options) {
continue // it was sent; selecting it is legitimate
}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(action)},
options,
)
if decision.Action != agent.PermissionDecisionDeny {
t.Fatalf("%s: %q was never sent as an option but was accepted as %q",
toolName, action, decision.Action)
}
}
}
}
// Cancelling is still reachable — through the outcome, which is where ACP puts
// it. Closing the selected-id route must not close the real one.
func TestCancellingThroughTheOutcomeStillWorks(t *testing.T) {
options := buildPermissionOptions(agent.PermissionRequest{ToolName: "apply_patch"})
decision := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, options)
if decision.Action != agent.PermissionDecisionCancel {
t.Fatalf("outcome=cancelled gave %q, want cancel", decision.Action)
}
}
+57 -18
View File
@@ -15,14 +15,7 @@ import (
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
actions := req.AvailableDecisions
if len(actions) == 0 {
// Sensible default if ZERO didn't enumerate: allow once / reject.
actions = []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
}
}
actions := offeredDecisions(req)
options := make([]PermissionOption, 0, len(actions))
for _, action := range actions {
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
@@ -38,6 +31,32 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
return options
}
// offeredDecisions resolves what this request actually offers the client.
//
// ONE RESOLVER, USED BY BOTH SIDES, and that is the entire point of it existing.
// The fallback below used to live inside buildPermissionOptions while
// requestPermission validated the client's answer against the raw
// req.AvailableDecisions. When ZERO did not enumerate — which is every
// permission event that is not a prompt, and includes a shell call whose sandbox
// decision came back deny — the two disagreed completely: the client was sent
// "Allow" and "Reject", and validation was performed against an EMPTY list, so
// every option the client could possibly click failed closed to deny with
// "permission option was not offered".
//
// The user clicked Allow and ZERO recorded a denial. Nothing surfaced it,
// because a denial is a legitimate answer — the tool was simply refused and the
// turn carried on as though the user had rejected it.
func offeredDecisions(req agent.PermissionRequest) []agent.PermissionDecisionAction {
if len(req.AvailableDecisions) > 0 {
return req.AvailableDecisions
}
// Sensible default if ZERO didn't enumerate: allow once / reject.
return []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
}
}
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
// human label. Returns an empty kind for actions that ACP expresses through the
// outcome rather than an option (cancel).
@@ -49,8 +68,16 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
// disclosure cannot be TUI-only.
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
switch action {
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
case agent.PermissionDecisionAllow:
return PermAllowOnce, "Allow"
case agent.PermissionDecisionAllowStrict:
// SPLIT FROM PLAIN ALLOW because the label is the only thing an ACP
// client shows. Both were labelled "Allow", and request_permissions
// offers them together — so the panel presented two identical buttons,
// and whichever the user picked looked the same while one of them
// silently turned on strict auto-review of what was granted. The option
// id carries no room for the distinction, so it has to be in the name.
return PermAllowOnce, "Allow with strict review"
case agent.PermissionDecisionAllowForSession:
return PermAllowAlways, "Allow for this session"
case agent.PermissionDecisionAllowPrefix:
@@ -70,19 +97,31 @@ func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind,
// decisionFromOutcome maps the client's permission outcome back to a ZERO
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
// action verbatim (validated against what was offered); anything unrecognized
// fails closed to deny.
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
// action verbatim (validated against what was actually sent); anything
// unrecognized fails closed to deny.
//
// IT TAKES THE OPTIONS THAT WERE SENT, not the decisions the request offered,
// and the two are not the same list. Some actions never become options —
// PermissionDecisionCancel is dropped by optionKindFor because ACP expresses
// cancellation through the outcome instead — yet it IS enumerated in
// AvailableDecisions for shell commands and apply_patch, the two commonest
// prompts. Validating against the decisions therefore accepted
// {"outcome":"selected","optionId":"cancel"}: an identifier no client was ever
// shown, aborting the whole turn.
//
// Passing the sent options makes "what we offered" and "what we accept" the
// same slice rather than two things kept in step, so anything optionKindFor
// declines to render is excluded structurally instead of by name.
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []PermissionOption) agent.PermissionDecision {
switch outcome.Outcome {
case OutcomeCancelled:
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
case OutcomeSelected:
action := agent.PermissionDecisionAction(outcome.OptionID)
// Bind to what was actually offered for THIS call: a client must not be able
// to return a broader grant (always_allow / allow_for_session) that wasn't
// presented. Anything not offered fails closed to deny.
if actionOffered(action, offered) {
return agent.PermissionDecision{Action: action}
if actionOffered(outcome.OptionID, offered) {
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
default:
@@ -90,9 +129,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.Permi
}
}
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
for _, a := range offered {
if a == action {
func actionOffered(optionID string, offered []PermissionOption) bool {
for _, option := range offered {
if option.OptionID == optionID {
return true
}
}
+3 -2
View File
@@ -56,10 +56,11 @@ func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
// what was offered, so decorating it would fail every prefix approval closed.
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
request := escalationRequest(true)
for _, option := range buildPermissionOptions(request) {
options := buildPermissionOptions(request)
for _, option := range options {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
request.AvailableDecisions,
options,
)
if string(decision.Action) != option.OptionID {
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
+8 -5
View File
@@ -39,11 +39,14 @@ func TestBuildPermissionOptionsDefault(t *testing.T) {
}
func TestDecisionFromOutcome(t *testing.T) {
offered := []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
}
offered := buildPermissionOptions(agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
},
})
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
t.Errorf("cancelled -> %q, want cancel", d.Action)
}
+37
View File
@@ -99,6 +99,43 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
}
}
// The launcher denylist is only worth anything if it holds on the path a user
// actually sees. A model naming its own prefix_rule is that path: before
// launcher names were normalized, "python3.11" was offered as a one-token
// grant, and hasStringPrefix then matched every later python3.11 invocation.
func TestProposedCommandPrefixRejectsLauncherSpellingsInRequestedPrefix(t *testing.T) {
for _, testCase := range []struct {
command string
rule []any
}{
{"python3.11 script.py", []any{"python3.11"}},
{"python.exe script.py", []any{"python.exe"}},
{"node.exe app.js", []any{"node.exe"}},
{"cmd /c whoami", []any{"cmd"}},
{"busybox sh -c id", []any{"busybox"}},
{"uv run main.py", []any{"uv", "run"}},
{"python3.11 -c import_os", []any{"python3.11", "-c"}},
} {
got := proposedCommandPrefix("bash", map[string]any{
"command": testCase.command,
"prefix_rule": testCase.rule,
})
if got != nil {
t.Errorf("prefix_rule %v for %q was offered as %#v, want rejected", testCase.rule, testCase.command, got)
}
}
}
func TestProposedCommandPrefixStillOffersOrdinaryCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "cargo build --release",
"prefix_rule": []any{"cargo", "build"},
})
if len(got) != 2 || got[0] != "cargo" || got[1] != "build" {
t.Fatalf("ordinary prefix rule = %#v, want [cargo build]", got)
}
}
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
cases := []string{
"cat < in > out",
+172 -11
View File
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -390,6 +392,27 @@ type compactionState struct {
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
// (Options.Summarizer) for the current main model. summarizerProvider
// memoizes the built provider and summarizerResolved records that the
// factory ran (it may legitimately return no provider: the main model is
// already the cheap one). summarizerBroken is the sticky fallback: once a
// build or call fails, the run stays on the main provider until the next
// model switch — a misconfigured cheap model must never break (or
// repeatedly delay) compaction.
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
summarizerProvider Provider
summarizerResolved bool
summarizerBroken bool
// window is the context window the threshold was derived from. The
// reactive free-prune stage compares against it (the limit the provider
// enforces), not against the 70% proactive threshold — a pruned history
// between the two would still succeed on retry.
window int
// mainModel is the model the run currently streams on; the summarizer
// choice is made against it and remade after a mid-run switch.
mainModel string
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
@@ -424,17 +447,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
func newCompactionState(options Options, task *taskState) *compactionState {
state := &compactionState{
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
window: options.ContextWindow,
mainModel: options.Model,
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
summarizerFactory: options.Summarizer,
}
return state
}
// updateWindow re-derives the compaction threshold after a mid-run model
// switch. The low-water mark is reset: it was measured against the old
// threshold, and keeping it could suppress a compaction the smaller window
// now requires. <= 0 (unknown model) leaves everything unchanged.
func (state *compactionState) updateWindow(window int) {
if window <= 0 {
return
}
state.enabled = true
state.window = window
state.threshold = compactionThreshold(window)
state.lowWaterMark = 0
if state.planner != nil {
state.planner.config.contextWindow = window
}
}
// switchModel records a mid-run model switch: the window is re-derived
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
// the next compaction re-resolves it against the new main model. A run that
// started on the cheap model (no dedicated summarizer) and escalated to an
// expensive one therefore regains cheap summaries — the exact workflow a
// dedicated summarizer exists for.
func (state *compactionState) switchModel(modelID string, window int) {
state.mainModel = modelID
state.updateWindow(window)
state.summarizerProvider = nil
state.summarizerResolved = false
state.summarizerBroken = false
}
// summarizeProvider returns the provider summarization calls should use and
// whether it is the dedicated summarizer: that one when it applies to the
// current main model and is healthy, else the main provider. The factory runs
// at most once per main model; a build error marks the summarizer broken for
// that model. Callers branch on the boolean rather than comparing Provider
// values — interface comparison panics on a non-comparable dynamic type.
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
if state.summarizerFactory == nil || state.summarizerBroken {
return main, false
}
if !state.summarizerResolved {
state.summarizerResolved = true
built, err := state.summarizerFactory(ctx, state.mainModel)
if err != nil {
state.summarizerBroken = true
return main, false
}
state.summarizerProvider = built
}
if state.summarizerProvider == nil {
return main, false
}
return state.summarizerProvider, true
}
// fitsWindow reports whether a calibrated size is safely under the limit the
// provider enforces. The proactive threshold (70% of the window) is a trigger
// for the paid summarizer, not the hard limit; the reactive path compares
// against the window itself, with a small margin for estimate error, so a
// history pruned to, say, 85% of the window takes the free retry.
func (state *compactionState) fitsWindow(size int) bool {
if state.window <= 0 {
return size <= state.threshold
}
return size <= state.window-state.window/20
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
// when the history is under threshold, or when the history has not grown past
@@ -528,10 +622,34 @@ func (state *compactionState) recover(
// context-limit error never triggers an unexpected summarization call.
return messages, false, nil
}
if state.reactiveAttempted {
if !isContextLimitError(errorMessage) {
return messages, false, nil
}
if !isContextLimitError(errorMessage) {
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
// often be fixed for free by dropping superseded reads and pruning old
// tool-result bodies. It runs regardless of the one-shot reactive budget
// (it costs nothing and cannot loop: a second pass finds nothing left to
// prune). The free retry is taken only when the pruned history fits the
// window the provider enforces — otherwise a retry would fail the same
// way, so fall through to the paid summarizer in this same call.
toolTokens := estimateToolDefTokens(tools)
prunedAny := false
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if prunedAny {
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if state.fitsWindow(size) {
state.lowWaterMark = size
return messages, true, nil
}
}
if state.reactiveAttempted {
return messages, false, nil
}
@@ -571,11 +689,21 @@ func (state *compactionState) recover(
// provider call. The summary stream intentionally does NOT forward OnText (so
// compaction stays invisible on the user-facing surface), but it DOES forward
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
// The call goes to the dedicated (cheap) summarizer when one applies; if that
// call fails the summarizer is marked broken for the current model and the
// same slice is retried once on the main provider, so the caller sees at most
// one (main-provider-quality) error.
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
if state.planner == nil {
state.planner = newContextPlanner(contextPlannerConfig{})
}
return func(toSummarize []zeroruntime.Message) (string, error) {
chosen, dedicated := state.summarizeProvider(ctx, provider)
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
if err == nil || !dedicated {
return summary, err
}
state.summarizerBroken = true
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -659,8 +787,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
return summary, nil
}
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
// search results), yet the summary instructions only need their gist — the
// model that ACTED on the full output already turned it into decisions the
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
// head+tail of a result while cutting the summarizer input by 5-10x on
// tool-heavy sessions; the summarizer's own cost is the price being cut.
const summaryToolResultClamp = 2000
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
// a write_file call carries the whole file body in its arguments.
const summaryToolArgsClamp = 500
// clampForSummary head+tail-truncates text for the summarizer transcript,
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
func clampForSummary(text string, max int) string {
if max <= 0 || len(text) <= max {
return text
}
head := max * 3 / 4
tail := max - head
for head > 0 && !utf8.RuneStart(text[head]) {
head--
}
start := len(text) - tail
for start < len(text) && !utf8.RuneStart(text[start]) {
start++
}
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
}
// renderTranscript flattens messages into a plain-text transcript for the
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
// Tool-result bodies and tool-call arguments are clamped (see the constants
// above): the summary needs what was done and decided, not the raw payloads.
func renderTranscript(messages []zeroruntime.Message) string {
lines := make([]string, 0, len(messages))
for _, message := range messages {
@@ -670,13 +831,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
if len(message.ToolCalls) > 0 {
calls := make([]string, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
calls = append(calls, call.Name+"("+call.Arguments+")")
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
}
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
}
lines = append(lines, line)
case zeroruntime.MessageRoleTool:
lines = append(lines, "tool result: "+message.Content)
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
default:
lines = append(lines, string(message.Role)+": "+message.Content)
}
+469
View File
@@ -0,0 +1,469 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
// the proactive threshold for a 1000-token window.
func compactableHistory() []zeroruntime.Message {
return []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
}
}
// Summarization must run on the dedicated (cheap) summarizer when one is
// configured — the main provider sees no summarize traffic at all.
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
main := &mockProvider{}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return cheap, nil
},
}, nil)
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
if len(cheap.requests) == 0 {
t.Fatal("dedicated summarizer never received the summarize call")
}
if len(main.requests) != 0 {
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
}
if factoryCalls != 1 {
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
}
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
t.Fatal("compaction did not shrink the history")
}
found := false
for _, message := range compacted {
if strings.Contains(message.Content, "CHEAP SUMMARY") {
found = true
}
}
if !found {
t.Fatal("cheap summarizer's summary missing from the compacted history")
}
}
// A failing summarizer falls back to the main provider for the SAME slice and
// stays broken for the rest of the run — no repeated failed cheap calls.
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
summary, err := summarize(input)
if err != nil {
t.Fatalf("fallback must succeed via the main provider: %v", err)
}
if summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, want the main provider's", summary)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
}
if !state.summarizerBroken {
t.Fatal("a failed summarizer must be marked broken for the run")
}
// Second summarization: straight to main, no cheap retry.
if _, err := summarize(input); err != nil {
t.Fatal(err)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
}
if len(main.requests) != 2 {
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
}
}
// A factory that errors (misconfigured model) is equivalent to no summarizer:
// main provider used, factory not retried.
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return nil, errors.New("unknown model")
},
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
}
if factoryCalls != 1 {
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("after a factory failure the run must stay on the main provider")
}
if factoryCalls != 1 {
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
}
}
// Tool payloads are clamped in the summarizer transcript: results to
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
bigResult := strings.Repeat("r", 50_000)
bigArgs := strings.Repeat("w", 20_000)
transcript := renderTranscript([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "c1", Name: "write_file", Arguments: bigArgs},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
})
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
}
if !strings.Contains(transcript, "chars omitted for summarization") {
t.Fatal("clamped transcript must carry the omission marker")
}
if !strings.Contains(transcript, "keep going") {
t.Fatal("non-tool content must survive unclamped")
}
// Head AND tail of the clamped payloads survive.
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
}
}
// bigToolHistory returns a history with n large tool results (~40k estimated
// tokens each): the trailing protection window keeps the newest verbatim,
// the older ones are prunable.
func bigToolHistory(n int) []zeroruntime.Message {
bigBody := strings.Repeat("x", 160_000)
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
for i := 0; i < n; i++ {
id := string(rune('a' + i))
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
)
}
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
}
func summaryProvider(text string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
}}
}
// A context-limit error is first answered with the free prune stage; when the
// pruned history fits the window the retry is free (no summarizer call) and
// the one-shot reactive budget is untouched. The paid summarizer only runs
// when a later error finds nothing left to prune.
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
provider := summaryProvider("SUMMARY")
// The protected trailing tool output (~40k tokens) must fit the window.
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("prunable history that fits must produce a free retry")
}
if len(provider.requests) != 0 {
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
}
if state.reactiveAttempted {
t.Fatal("a free prune retry must not consume the reactive budget")
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// Second context-limit error on the pruned history: nothing meaningfully
// prunable remains, so the paid summarizer fires and consumes the budget.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("second recover must compact and retry")
}
if len(provider.requests) == 0 {
t.Fatal("second recover must reach the summarizer")
}
if !state.reactiveAttempted {
t.Fatal("a paid recover must consume the reactive budget")
}
}
// When pruning reclaims something but the pruned history still exceeds the
// window, recover falls through to the paid summarizer in the SAME call
// instead of spending a retry that would fail the same way.
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
provider := summaryProvider("SUMMARY")
// A 1,000-token window: even the protected trailing output overflows it.
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("insufficient prune must still end in a retry via summarization")
}
if len(provider.requests) == 0 {
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
}
if !state.reactiveAttempted {
t.Fatal("the paid summarizer consumes the reactive budget")
}
if estimateTokens(compacted) >= estimateTokens(messages) {
t.Fatal("recover did not shrink the history")
}
}
// The free prune stage is not gated behind the one-shot budget: after a paid
// reactive compaction, a later context-limit error still gets a free retry
// when pruning makes the history fit.
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
provider := summaryProvider("SUMMARY")
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
state.reactiveAttempted = true
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// With nothing left to prune and the budget spent, the caller keeps its
// original error — recover neither loops nor summarizes.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil || retried || len(provider.requests) != 0 {
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
}
}
// The free-retry gate compares against the window the provider enforces, not
// the 70% proactive threshold: a history pruned to ~80% of the window still
// takes the free retry.
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
provider := summaryProvider("SUMMARY")
messages := bigToolHistory(5)
pruned, _ := pruneStaleToolOutput(messages, 2)
prunedTokens := estimateTokens(pruned)
// Choose a window so the pruned size sits between 70% and 95% of it.
window := prunedTokens * 100 / 80
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
if prunedTokens <= state.threshold {
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
}
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
}
}
// After a mid-run model switch the summarizer is re-resolved against the new
// main model: a run that started on the cheap model (no dedicated summarizer)
// and escalated gets a cheap summarizer, and a previously broken one is
// retried for the new model.
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
cheap := summaryProvider("CHEAP SUMMARY")
main := summaryProvider("MAIN SUMMARY")
var factoryModels []string
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Model: "cheap-model",
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
factoryModels = append(factoryModels, mainModelID)
if mainModelID == "cheap-model" {
return nil, nil // main already is the cheap model
}
return cheap, nil
},
}, nil)
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("on the cheap model the main provider must summarize")
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
}
state.switchModel("big-model", 400_000)
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
}
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
}
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
}
// A broken summarizer is retried after the next switch.
state.summarizerBroken = true
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("a broken summarizer must fall back to main")
}
state.switchModel("other-model", 0)
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
}
}
func TestCompactionUpdateWindow(t *testing.T) {
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
state.lowWaterMark = 50_000
before := state.threshold
state.updateWindow(0) // unknown model: unchanged
if state.threshold != before || state.lowWaterMark != 50_000 {
t.Fatal("updateWindow(0) must be a no-op")
}
state.updateWindow(10_000)
if state.threshold >= before {
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
}
if state.lowWaterMark != 0 {
t.Fatal("low-water mark must reset on a window change")
}
if !state.enabled {
t.Fatal("a positive window must keep compaction enabled")
}
}
// End-to-end: after escalate_model switches providers, the loop re-derives the
// context window via ContextWindowFor, visible in the OnContext report.
func TestRunEscalationRederivesContextWindow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "big-model"})
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
}}}
var windows []int
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
ContextWindow: 100_000,
Model: "small-model",
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
ContextWindowFor: func(modelID string) int {
if modelID == "big-model" {
return 400_000
}
return 0
},
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(windows) < 2 {
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
}
if windows[0] != 100_000 {
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
}
if windows[len(windows)-1] != 400_000 {
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
}
}
// nonComparableProvider has a slice field, so comparing two Provider interface
// values holding it would panic; the summarizer path must never compare them.
type nonComparableProvider struct {
inner *mockProvider
tags []string
}
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return p.inner.StreamCompletion(ctx, request)
}
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
}, nil)
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
}
// And with no dedicated summarizer at all the closure still works.
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
t.Fatal(err)
}
}
+27 -9
View File
@@ -40,7 +40,15 @@ const (
toolResultControlSpecReview = "spec_review_required"
)
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
// ErrPermissionApprovalCanceled ends a run because the caller cancelled a
// permission prompt, rather than because anything failed.
//
// EXPORTED FOR THE SURFACES. A cancel is a normal ending and each surface says
// so its own way — the TUI unwinds, and ACP has a "cancelled" stop reason for
// exactly this. While it was unexported ACP could not distinguish it from a
// fault and reported it as an internal error, so declining a tool looked like
// the agent crashing.
var ErrPermissionApprovalCanceled = errors.New("permission approval cancelled")
// isImageRejectionError reports whether err is a provider 400 that rejects
// image/multimodal content. This is checked BEFORE the compaction-retry path
@@ -836,11 +844,21 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs the switcher to also
// report the new window — out of scope for this change.
// Re-derive the context window for the new model so compaction and
// the OnContext budget report track the model actually in force —
// without this a switch to a smaller-window model can overflow the
// target, and a switch to a larger one over-compacts. An unknown
// model (<= 0) keeps the original window. The compactor also
// re-resolves its dedicated summarizer against the new model.
window := 0
if options.ContextWindowFor != nil {
window = options.ContextWindowFor(turnRequestedModel)
}
if window > 0 {
options.ContextWindow = window
planner.config.contextWindow = window
}
compactor.switchModel(turnRequestedModel, window)
}
}
}
@@ -1364,7 +1382,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, decisionReason)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, decisionReason)
return deniedPermissionResult(call, decisionReason, requestEvent), nil
@@ -1548,7 +1566,7 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
@@ -1596,7 +1614,7 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
+93 -1
View File
@@ -1853,7 +1853,7 @@ func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
},
})
if !errors.Is(err, errPermissionApprovalCanceled) {
if !errors.Is(err, ErrPermissionApprovalCanceled) {
t.Fatalf("expected permission approval cancel error, got %v", err)
}
if result.FinalAnswer != "" {
@@ -4340,3 +4340,95 @@ func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
t.Fatalf("expected tool result to mention the beforeTool veto, got %q", combined)
}
}
// A cancelled permission on either sandbox-retry path must abort the run with a
// recognisable cancellation, and must not run the escalated retry.
//
// BOTH PATHS, because both wrap ErrPermissionApprovalCanceled and each is
// reached by a different kind of denial: one by an unrestricted platform denial
// asking for approval, the other by an external-network denial. Surfaces map
// this sentinel onto their own idea of a cancel — ACP returns stopReason
// "cancelled" for it — so a path that produced a bare error instead would show
// the user a crash where they had simply declined.
func TestCancellingASandboxRetryAbortsWithoutRetrying(t *testing.T) {
denial := func(capability execution.CapabilityKind, scope string) tools.Result {
return tools.Result{
Status: tools.StatusError,
ExecutionOutcome: &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: capability, Scope: scope},
Source: execution.DenialSourcePlatformSandbox,
Reason: "denied by the sandbox",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
},
},
}
}
for _, testCase := range []struct {
name string
result tools.Result
run func(context.Context, *tools.Registry, ToolCall, tools.Tool, map[string]any, tools.Result, Options) error
}{
{
name: "unsandboxed retry",
result: denial(execution.CapabilityUnrestricted, "host"),
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
_, _, _, _, _, _, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
return abortErr
},
},
{
name: "network retry",
result: denial(execution.CapabilityExternalNetwork, ""),
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
_, _, _, _, _, abortErr := maybeRetryWithNetworkAfterSandboxDenial(
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
return abortErr
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
retryTool := &sandboxNamespaceLimitedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
asked := 0
options := Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: sandbox.DefaultPolicy(),
}),
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
asked++
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "client cancelled"}, nil
},
}
call := ToolCall{ID: "call-1", Name: "bash", Arguments: `{"command":"curl example.com"}`}
args := map[string]any{"command": "curl example.com"}
abortErr := testCase.run(context.Background(), registry, call, retryTool, args, testCase.result, options)
if asked != 1 {
t.Fatalf("permission was requested %d times, want exactly 1", asked)
}
if !errors.Is(abortErr, ErrPermissionApprovalCanceled) {
t.Fatalf("abort error = %v, want it to match ErrPermissionApprovalCanceled", abortErr)
}
// The escalated retry is the thing the permission was gating. A
// cancelled prompt that still ran it would have run the command the
// user just declined.
if len(retryTool.calls) != 0 {
t.Fatalf("the escalated retry ran %d times after a cancel: %+v", len(retryTool.calls), retryTool.calls)
}
})
}
}
+4 -4
View File
@@ -68,7 +68,7 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
}
}
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
func TestAgentAdvertisesBothEditTools(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
@@ -79,9 +79,9 @@ func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
names[definition.Name] = true
}
if !names["apply_patch"] {
t.Fatal("agent must retain apply_patch for existing-file changes")
t.Fatal("agent must retain apply_patch for multi-hunk changes")
}
if names["edit_file"] {
t.Fatal("agent must not receive the ambiguous string-replacement tool")
if !names["edit_file"] {
t.Fatal("agent must receive edit_file for targeted exact replacements")
}
}
+13 -6
View File
@@ -52,7 +52,7 @@ work.
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, apply_patch - over shelling out to
edit_file, apply_patch, write_file - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
@@ -61,11 +61,18 @@ work.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
and enough unchanged context to identify the intended location. Match the
existing indentation, imports, and idioms. Match the file's comment density:
do not add explanatory comments unless the user asks or the code is already
- For edits to existing files, use edit_file for a targeted change (old_string
must match the file exactly and be unique, so include a few surrounding
lines) and apply_patch when one coherent change spans several hunks or
files. Use write_file only to create a file or when most of it changes; do
not rewrite a whole file to change a few lines. Match the existing
indentation, imports, and idioms. Match the file's comment density: do not
add explanatory comments unless the user asks or the code is already
comment-dense.
- A successful edit result already confirms the change; do not re-read a file
just to verify an edit that succeeded. If an edit fails, read the error, fix
the old_string or hunk, and retry the same tool rather than switching to a
full rewrite.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
@@ -105,7 +112,7 @@ work.
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/apply_patch for file changes.
with exec_command, or use edit_file/apply_patch/write_file for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
+7 -3
View File
@@ -60,14 +60,18 @@ const openAIPromptAddendum = `<model_guidance>
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
write_file, apply_patch) over shelling out to cat/sed/awk/python for
file work. Make one tool call per file; do not batch file writes into a script.
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python
for file work. read_file, edit_file and write_file take one file per call;
a single apply_patch may span several files when they form one coherent
change. Do not batch file writes into a script. Independent tool calls
(several reads, several edit_file calls to different files) belong in the
same turn.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
const geminiPromptAddendum = `<model_guidance>
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
+1 -1
View File
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"inspect the target file",
"plan then act",
"choose the narrowest tool",
"for edits to existing files, prefer apply_patch",
"for edits to existing files, use edit_file",
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
+20 -4
View File
@@ -348,10 +348,26 @@ type Options struct {
// CompactionPreserveLast is how many trailing messages compaction keeps
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
CompactionPreserveLast int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// Summarizer, when set, lazily builds the provider used for compaction
// summarization calls — typically a cheap/fast model, since summaries at
// main-model prices are the single most expensive recurring event in a
// long run. It receives the main model currently in force and may return
// (nil, nil) when no dedicated summarizer applies to it (the main model is
// already the cheap one). Built on the first paid compaction and again
// after a mid-run model switch. Any failure (build or call) falls back to
// the run's main provider until the next switch, so a misconfigured
// summarizer can never break compaction. nil keeps today's behavior.
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
// ContextWindowFor, when set, resolves a model ID to its context window so
// the compactor can re-derive its threshold after a mid-run model switch
// (escalate_model). Without it a switch keeps compacting against the
// original model's window — overflowing a smaller target or over-compacting
// a larger one. Return <= 0 when the model is unknown (window unchanged).
ContextWindowFor func(modelID string) int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// FileTracker records per-session file read/write versions so the write tools
// can detect a file changed on disk outside Zero since it was last read. nil
// disables the check. Created once per session and threaded into every tool run.
+2 -1
View File
@@ -856,7 +856,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// A server that could not be reached or validated is skipped, not fatal (one
// bad MCP server must not abort startup) — surface each so a missing tool set is
// explained rather than silently absent. A built-in default the user never
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
// configured (e.g. keyless Exa with no credentials) is the exception: it
// was never asked for, so its failure is not worth a startup warning — only
// servers the user actually configured warn on failure (issue #552).
for _, skipped := range mcpRuntime.Skipped() {
@@ -948,6 +948,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
CompactionModel: resolved.Preferences.CompactionModel,
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
+3 -3
View File
@@ -211,12 +211,12 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"exa": config.DefaultMCPServers()["exa"],
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
{Name: "exa", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
@@ -227,7 +227,7 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stderr.String(), "firecrawl") {
if strings.Contains(stderr.String(), "exa") {
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
}
}
+14 -10
View File
@@ -682,16 +682,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
ModelSessionSwitcher: modelSessionSwitcher,
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
Summarizer: summarizerFactory(resolved, deps.newProvider),
ContextWindowFor: func(modelID string) int {
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
},
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
+8 -8
View File
@@ -9,15 +9,15 @@ import (
"github.com/Gitlawb/zero/internal/config"
)
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
// `zero mcp disable exa` must work even though exa is a built-in
// default that is not written to the user's config file until overridden.
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
func TestRunMCPDisableSeededExaDefault(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
// A config with no firecrawl entry — the default lives in code, not the file.
// A config with no Exa entry — the default lives in code, not the file.
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
var out, errBuf bytes.Buffer
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
code := runWithDeps([]string{"mcp", "disable", "exa", "--json"}, &out, &errBuf, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
})
if code != exitSuccess {
@@ -31,8 +31,8 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
}
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
if payload.ServerName != "exa" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want exa disabled+changed", payload)
}
// End-to-end: resolving that config now turns the default off.
@@ -40,7 +40,7 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["firecrawl"].Disabled {
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
if !cfg.Servers["exa"].Disabled {
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
}
}
+17
View File
@@ -0,0 +1,17 @@
package cli
import (
"context"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// summarizerFactory adapts the resolved profile and the authenticated provider
// builder into agent.Options.Summarizer; the selection rules live in
// providers.CompactionSummarizerFactory, shared with the TUI.
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
}
+98 -13
View File
@@ -5,22 +5,20 @@ import (
"strings"
)
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
// search and scraping work out of the box with no setup and no API key. They are
// seeded before user/project config is merged (see ResolveMCP), so a user can
// override any field — for example point firecrawl at a self-hosted instance, or
// add an API-key header to lift the free-tier limit — or disable it entirely with
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
// web search and page fetching work out of the box with no setup and no API
// key. They are seeded before user/project config is merged (see ResolveMCP),
// so a user can override any field — for example add an API-key header to lift
// Exa's anonymous rate limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
// use. Zero only calls it over the network, so Firecrawl's license never reaches
// into Zero's own code.
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
// Exa API key for higher limits.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"firecrawl": {
"exa": {
Type: "http",
URL: "https://mcp.firecrawl.dev/v2/mcp",
URL: "https://mcp.exa.ai/mcp",
},
}
}
@@ -35,13 +33,13 @@ func IsDefaultMCPServer(name string) bool {
// IsUnconfiguredDefault reports whether server is one of Zero's built-in
// defaults that the user never wrote an entry for in their config — i.e. it is
// running with whatever Zero ships (e.g. keyless Firecrawl, no credentials).
// running with whatever Zero ships (e.g. keyless Exa, no credentials).
//
// Both conditions below must hold:
// - !server.configured: the user's JSON never declared an object for this
// server key at all (set by MCPServerConfig.UnmarshalJSON only when it
// actually ran for this key). Any explicit action — including a
// disable/enable toggle like `zero mcp enable firecrawl` that leaves the
// disable/enable toggle like `zero mcp enable exa` that leaves the
// resolved value unchanged — sets configured, so it always counts as
// user-configured, even though the value comparison below could not tell
// the difference on its own.
@@ -58,3 +56,90 @@ func IsUnconfiguredDefault(name string, server MCPServerConfig) bool {
def, ok := DefaultMCPServers()[strings.TrimSpace(name)]
return ok && !server.configured && reflect.DeepEqual(def, server)
}
// retiredDefaultMCPServer describes a built-in default Zero no longer ships.
type retiredDefaultMCPServer struct {
// successor is the default that replaced it.
successor string
// shipped is the value Zero used to seed for this name. A user entry
// written while the default was still shipped may name only an override
// (a header, say) and rely on these fields for the rest, so they have to
// outlive the default itself.
shipped MCPServerConfig
}
// retiredDefaultMCPServers maps a retired built-in default to what replaced it
// and to the value Zero used to ship for it.
var retiredDefaultMCPServers = map[string]retiredDefaultMCPServer{
"firecrawl": {
successor: "exa",
shipped: MCPServerConfig{
Type: "http",
URL: "https://mcp.firecrawl.dev/v2/mcp",
},
},
}
// migrateRetiredDefaultMCPServers keeps an upgrade across a default-provider
// swap from changing what the user's config means. It handles the two ways a
// rename can break a config written against the old default:
//
// - A user who ran `zero mcp disable <old default>` made an explicit choice
// not to open that outbound connection. Without carrying that decision to
// the successor, the upgrade re-opens it under the new name — and because
// the replacement looks like an untouched default, even the startup
// warning stays quiet (see IsUnconfiguredDefault and issue #552).
// - A user who customized the old default could name only the field they
// were changing, because the seeded default supplied the transport. Once
// the default stops being seeded that entry resolves to a server with no
// type, url, or command, which fails NormalizeConfig and takes the whole
// startup down rather than just that one server.
//
// It runs immediately after the user layer merges (see ResolveMCP), which
// scopes it to user-level decisions — the only scope whose disable is sticky.
// The carried-over disable applies only when the user never declared the
// successor themselves: an explicit `exa` entry wins whether it enables or
// disables. Because the disable is recorded as a user-level decision, the
// lower-trust project layer cannot lift it, while `zero mcp enable exa` still
// can (the CLI override scope merges with canReenable=true).
func migrateRetiredDefaultMCPServers(cfg *MCPConfig) {
for name, retired := range retiredDefaultMCPServers {
entry, ok := cfg.Servers[name]
if !ok {
continue
}
if inheritsRetiredTransport(entry) {
// Re-seed what Zero used to ship underneath the user's fields, so a
// partial entry still resolves to the complete server it named when
// it was written. Their own values still win — this only fills the
// gap the retired default used to cover.
entry = mergeMCPServer(retired.shipped, entry, true)
cfg.Servers[name] = entry
}
if !entry.disabledSet || !entry.Disabled {
continue
}
replacement, ok := cfg.Servers[retired.successor]
if !ok || replacement.configured {
continue
}
replacement.Disabled = true
replacement.disabledSet = true
cfg.Servers[retired.successor] = replacement
}
}
// inheritsRetiredTransport reports whether entry named no transport of its own
// — no type, url, command, or args — and so relied entirely on the retired
// default to supply one.
//
// Anything else is a complete definition the user owns (a self-hosted url, a
// stdio command) and must be left alone: grafting the retired default's http
// type onto an entry that names a command produces a server that is rejected
// outright, turning a working config into a startup failure.
func inheritsRetiredTransport(entry MCPServerConfig) bool {
return strings.TrimSpace(entry.Type) == "" &&
strings.TrimSpace(entry.URL) == "" &&
strings.TrimSpace(entry.Command) == "" &&
len(entry.Args) == 0
}
+197 -36
View File
@@ -7,10 +7,10 @@ import (
)
func TestIsDefaultMCPServer(t *testing.T) {
if !IsDefaultMCPServer("firecrawl") {
t.Fatal("firecrawl should be a built-in default")
if !IsDefaultMCPServer("exa") {
t.Fatal("exa should be a built-in default")
}
if IsDefaultMCPServer(" firecrawl ") == false {
if IsDefaultMCPServer(" exa ") == false {
t.Fatal("IsDefaultMCPServer should trim whitespace")
}
if IsDefaultMCPServer("not-a-default") {
@@ -18,45 +18,45 @@ func TestIsDefaultMCPServer(t *testing.T) {
}
}
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
func TestResolveMCPSeedsEnabledExaDefault(t *testing.T) {
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl, ok := cfg.Servers["firecrawl"]
exa, ok := cfg.Servers["exa"]
if !ok {
t.Fatal("expected the firecrawl default to be seeded with no user config")
t.Fatal("expected the exa default to be seeded with no user config")
}
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
if exa.Type != "http" || exa.URL != "https://mcp.exa.ai/mcp" {
t.Fatalf("unexpected exa default: %#v", exa)
}
if firecrawl.Disabled {
t.Fatal("the firecrawl default must be enabled out of the box")
if exa.Disabled {
t.Fatal("the exa default must be enabled out of the box")
}
}
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["firecrawl"].Disabled {
if !cfg.Servers["exa"].Disabled {
t.Fatal("a user must be able to disable the default by writing over it")
}
}
func TestIsUnconfiguredDefault(t *testing.T) {
if !IsUnconfiguredDefault("firecrawl", DefaultMCPServers()["firecrawl"]) {
t.Fatal("an untouched firecrawl default should be reported as unconfigured")
if !IsUnconfiguredDefault("exa", DefaultMCPServers()["exa"]) {
t.Fatal("an untouched exa default should be reported as unconfigured")
}
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "http://localhost:3002/mcp"}) {
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://example.com/mcp"}) {
t.Fatal("a server overriding the default URL is no longer unconfigured")
}
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "https://mcp.firecrawl.dev/v2/mcp", Auth: "bearer"}) {
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://mcp.exa.ai/mcp", Auth: "bearer"}) {
t.Fatal("a server with credentials added is no longer unconfigured")
}
if IsUnconfiguredDefault("not-a-default", MCPServerConfig{}) {
@@ -65,57 +65,173 @@ func TestIsUnconfiguredDefault(t *testing.T) {
}
func TestResolveMCPExplicitReenableIsNotUnconfiguredDefault(t *testing.T) {
// `zero mcp enable firecrawl` after a prior disable writes {"disabled":false}
// `zero mcp enable exa` after a prior disable writes {"disabled":false}
// explicitly. The resolved value is identical to the untouched default (both
// enabled, no credentials), but the user DID take an explicit action here, so
// IsUnconfiguredDefault must not treat it as untouched (issue #563 review).
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":false}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", firecrawl)
exa := cfg.Servers["exa"]
if exa.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", exa)
}
if IsUnconfiguredDefault("firecrawl", firecrawl) {
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("an explicit enable/disable toggle must count as user-configured, even though the resolved value matches the default")
}
}
func TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault(t *testing.T) {
// A user who copies firecrawl's exact default type/url into their config
// A user who copies Exa's exact default type/url into their config
// (e.g. from an example file, planning to add credentials later) produces a
// resolved value byte-identical to DefaultMCPServers()["firecrawl"] — the
// resolved value byte-identical to DefaultMCPServers()["exa"] — the
// same trap TestResolveMCPExplicitReenableIsNotUnconfiguredDefault covers for
// the disabled toggle. IsUnconfiguredDefault must still treat this as
// user-configured because the user's JSON declared an entry for it, even
// though a plain resolved-value comparison could not tell the difference.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"type":"http","url":"https://mcp.firecrawl.dev/v2/mcp"}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"type":"http","url":"https://mcp.exa.ai/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
want := DefaultMCPServers()["firecrawl"]
if firecrawl.Type != want.Type || firecrawl.URL != want.URL || firecrawl.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", firecrawl)
exa := cfg.Servers["exa"]
want := DefaultMCPServers()["exa"]
if exa.Type != want.Type || exa.URL != want.URL || exa.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", exa)
}
if IsUnconfiguredDefault("firecrawl", firecrawl) {
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("redeclaring the default's exact values is still an explicit user configuration, not an untouched default")
}
}
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
// Point firecrawl at a self-hosted instance; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
// Point Exa at a proxy; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"url":"https://example.com/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.URL != "https://example.com/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", exa)
}
if exa.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", exa)
}
}
func TestResolveMCPCarriesLegacyDefaultDisableToSuccessor(t *testing.T) {
// Upgrade path: the user disabled the firecrawl default Zero used to ship.
// Swapping the default to exa must not re-open an outbound connection they
// explicitly switched off.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatalf("a prior disable of the retired default must carry to its replacement: %#v", cfg.Servers["exa"])
}
}
func TestResolveMCPExplicitSuccessorBeatsLegacyDefaultDisable(t *testing.T) {
// The user disabled the old default but has since explicitly configured exa.
// Their newer, explicit choice wins — the migration must not override it.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true},"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.Disabled {
t.Fatalf("an explicit exa entry must survive the legacy-disable migration: %#v", exa)
}
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("an explicitly configured exa is not an untouched default")
}
}
func TestResolveMCPLegacyDefaultLeftEnabledDoesNotDisableSuccessor(t *testing.T) {
// A user who configured firecrawl without disabling it (e.g. added a header)
// never opted out of the default search server, so exa stays enabled.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["exa"].Disabled {
t.Fatal("only an explicit disable of the retired default should carry over")
}
}
func TestResolveMCPLegacyDefaultDisableIsLiftableByOverride(t *testing.T) {
// The carried-over disable is a user-level decision, not a permanent one:
// `zero mcp enable exa` merges through the CLI override scope, which is the
// one layer allowed to re-enable.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
enabled := MCPServerConfig{Disabled: false, disabledSet: true}
cfg, err := ResolveMCP(ResolveOptions{
UserConfigPath: path,
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"exa": enabled}}},
})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["exa"].Disabled {
t.Fatalf("an explicit enable must lift the carried-over disable: %#v", cfg.Servers["exa"])
}
}
func TestResolveMCPLegacyDisableSurvivesProjectReenable(t *testing.T) {
// The project layer is lower-trust and must not lift the carried-over
// user-level disable — the same guard a direct `zero mcp disable exa` gets.
dir := t.TempDir()
userPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(userPath, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
projectPath := filepath.Join(dir, "project.json")
if err := os.WriteFile(projectPath, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("project config must not re-enable a server the user disabled")
}
}
func TestResolveMCPKeepsRetiredDefaultTransportForPartialEntry(t *testing.T) {
// A user who added an API key to the firecrawl default wrote only the header
// — the seeded default supplied the http type and URL. Retiring the default
// must not strip those out from under them and leave an unusable server.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
@@ -123,10 +239,55 @@ func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.URL != "http://localhost:3002/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("a partial entry must keep the retired default's transport: %#v", firecrawl)
}
if firecrawl.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
if firecrawl.Headers["Authorization"] != "Bearer k" {
t.Fatalf("the user's own field must still win: %#v", firecrawl)
}
}
func TestResolveMCPDoesNotGraftRetiredDefaultOntoOwnTransport(t *testing.T) {
// A self-hosted firecrawl over stdio is a complete definition the user owns.
// Re-seeding the retired http default under it would produce an http server
// carrying a command, which NormalizeConfig rejects outright.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Type != "" || firecrawl.URL != "" {
t.Fatalf("an entry naming its own transport must be left alone: %#v", firecrawl)
}
if firecrawl.Command != "firecrawl-mcp" {
t.Fatalf("the user's command must survive: %#v", firecrawl)
}
}
func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
// The disable carries to exa, and the retired entry itself keeps a usable
// transport so a later `zero mcp enable firecrawl` does not resolve to a
// server with no type, url, or command.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if !firecrawl.Disabled {
t.Fatalf("the retired entry must stay disabled: %#v", firecrawl)
}
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("a disabled retired entry should still carry a usable transport: %#v", firecrawl)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("the disable must still carry to the successor")
}
}
@@ -34,7 +34,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
if _, ok := cfg.Servers["user-srv"]; !ok {
t.Fatal("the user server must always be present")
}
if _, ok := cfg.Servers["firecrawl"]; !ok {
if _, ok := cfg.Servers["exa"]; !ok {
t.Fatal("the built-in default must always be present")
}
})
@@ -54,7 +54,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
if _, ok := cfg.Servers["user-srv"]; !ok {
t.Fatal("the user server must survive when the project layer is dropped")
}
if _, ok := cfg.Servers["firecrawl"]; !ok {
if _, ok := cfg.Servers["exa"]; !ok {
t.Fatal("the built-in default must survive when the project layer is dropped")
}
})
+5 -1
View File
@@ -180,7 +180,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
}
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
// Seed Zero's built-in default MCP servers (e.g. keyless Exa for free,
// no-setup web search/scrape) BEFORE merging user/project config, so the user
// can override any field or disable a default by writing over it.
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
@@ -194,6 +194,10 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// server the user disabled (a user-level disable is sticky, but the user
// scope itself may lift it).
mergeMCPConfig(&cfg.MCP, fileConfig.MCP, true)
// Reconcile config written against a default Zero has since retired,
// here while the user layer is the only one merged — so a carried-over
// disable counts as a user-level decision the project layer cannot lift.
migrateRetiredDefaultMCPServers(&cfg.MCP)
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
+7 -1
View File
@@ -119,6 +119,12 @@ type PreferencesConfig struct {
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
// CompactionModel routes compaction summarization calls to this model
// instead of the session's main model (summaries at main-model prices are
// the most expensive recurring event in long runs). Empty = automatic: a
// curated cheap model on official endpoints, the main model elsewhere.
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
CompactionModel string `json:"compactionModel,omitempty"`
}
// RecentModelEntry is one provider-qualified model selection recorded in
@@ -465,7 +471,7 @@ type MCPServerConfig struct {
// fields it set or what values they hold. A built-in default seeded by
// DefaultMCPServers() is never unmarshaled from JSON, so it starts false;
// any explicit entry in the user/project file — even one that happens to
// repeat a default's exact field values (e.g. re-declaring firecrawl's
// repeat a default's exact field values (e.g. re-declaring Exa's
// default URL) — sets it true. IsUnconfiguredDefault checks this alongside
// a resolved-value comparison, so redeclaring default values verbatim still
// counts as user-configured.
+10 -1
View File
@@ -9,7 +9,16 @@ import (
"strings"
)
const PolicyVersion = 1
// PolicyVersion is the version of the approval policy that recorded grants were
// evaluated under. Bump it whenever a change narrows what may be approved: the
// grant store then backs the file up, keeps deny grants, invalidates prior
// approvals, and tells the user once, instead of silently honoring approvals
// that the current rules would have refused.
//
// v2 tightened command-prefix validation so a launcher spelled with a version
// or an executable extension ("python3.11", "python.exe") can no longer be
// approved as an ordinary command.
const PolicyVersion = 2
type Origin string
+1 -1
View File
@@ -35,7 +35,7 @@ type Server struct {
// credential lookup uses it to avoid reusing legacy user tokens by name.
ProjectConfigured bool
// UnconfiguredDefault is true when this server is one of Zero's built-in
// defaults (e.g. keyless Firecrawl) that the user never touched in their
// defaults (e.g. keyless Exa) that the user never touched in their
// config — no credentials, no overrides. Callers use it to avoid warning
// loudly when a server nobody configured fails to connect.
UnconfiguredDefault bool
+118 -3
View File
@@ -1,6 +1,8 @@
package mcp
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -90,7 +92,7 @@ func TestNormalizeConfigValidatesTransportBoundaries(t *testing.T) {
func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"exa": config.DefaultMCPServers()["exa"],
"web": {
Type: "http",
URL: "https://example.com/mcp",
@@ -107,8 +109,8 @@ func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
byName[server.Name] = server
}
if !byName["firecrawl"].UnconfiguredDefault {
t.Fatal("an untouched firecrawl default should be flagged UnconfiguredDefault")
if !byName["exa"].UnconfiguredDefault {
t.Fatal("an untouched exa default should be flagged UnconfiguredDefault")
}
if byName["web"].UnconfiguredDefault {
t.Fatal("a server the user configured must not be flagged UnconfiguredDefault")
@@ -240,3 +242,116 @@ func TestCopyStringMapTrimsKeysAndPreservesValues(t *testing.T) {
t.Fatalf("copied[TOKEN] = %q, want value preserved verbatim", copied["TOKEN"])
}
}
// Startup behavior for the firecrawl -> exa default migration: a user who
// disabled the retired default must end up with no Exa server to connect to,
// not merely a disabled entry in the resolved config.
func TestNormalizeConfigSkipsExaAfterLegacyFirecrawlDisable(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("NormalizeConfig: %v", err)
}
for _, server := range servers {
if server.Name == "exa" {
t.Fatalf("exa must not be started after the user disabled the default it replaced: %#v", server)
}
}
}
// The same path with no legacy disable still starts Exa, so the migration
// cannot quietly switch off the default for everyone else.
func TestNormalizeConfigStartsExaWithoutLegacyDisable(t *testing.T) {
cfg, err := config.ResolveMCP(config.ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("NormalizeConfig: %v", err)
}
for _, server := range servers {
if server.Name == "exa" {
if !server.UnconfiguredDefault {
t.Fatalf("an untouched exa default should be flagged unconfigured: %#v", server)
}
return
}
}
t.Fatal("expected the exa default to be started out of the box")
}
// The failure path CodeRabbit flagged: a firecrawl entry that named only a
// header relied on the retired default for its transport. Resolving it must
// still produce a complete server — an incomplete one fails NormalizeConfig,
// which takes down every other server's startup with it, not just this one.
func TestNormalizeConfigResolvesPartialRetiredDefaultEntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("a header-only legacy entry must not break startup: %v", err)
}
var firecrawl *Server
var sawExa bool
for i := range servers {
switch servers[i].Name {
case "firecrawl":
firecrawl = &servers[i]
case "exa":
sawExa = true
}
}
if firecrawl == nil {
t.Fatalf("expected the customized firecrawl server to survive: %#v", servers)
}
if firecrawl.Type != ServerTypeHTTP || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("firecrawl lost the transport its entry relied on: %#v", *firecrawl)
}
if firecrawl.Headers["Authorization"] != "Bearer k" {
t.Fatalf("firecrawl lost the user's header: %#v", *firecrawl)
}
if !sawExa {
t.Fatal("the new exa default should still start alongside it")
}
}
// The mirror case: an entry that names its own stdio transport must not have
// the retired http default grafted onto it, which would be rejected outright.
func TestNormalizeConfigLeavesRetiredEntryWithOwnTransportAlone(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("a self-hosted stdio entry must keep working: %v", err)
}
for _, server := range servers {
if server.Name != "firecrawl" {
continue
}
if server.Type != ServerTypeStdio || server.Command != "firecrawl-mcp" {
t.Fatalf("the user's own stdio transport was corrupted: %#v", server)
}
return
}
t.Fatalf("expected the self-hosted firecrawl server to survive: %#v", servers)
}
+9 -9
View File
@@ -153,16 +153,16 @@ func TestRegisterToolsSkipsUnreachableServerAndKeepsOthers(t *testing.T) {
}
func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
// firecrawl is seeded by config.DefaultMCPServers with no credentials; when a
// user never configures it, a connect failure (e.g. the real HTTP 401 from
// issue #552) must be recorded as UnconfiguredDefault so callers can avoid
// Exa is seeded by config.DefaultMCPServers with no credentials; when a
// user never configures it, a connect failure must be recorded as
// UnconfiguredDefault so callers can avoid
// warning about a server the user never asked for. "custom" is configured by
// the user and must NOT be flagged even though it also fails to connect.
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"custom": {Type: "stdio", Command: "custom-mcp"},
"exa": config.DefaultMCPServers()["exa"],
"custom": {Type: "stdio", Command: "custom-mcp"},
}}, RegisterOptions{
ClientFactory: func(_ context.Context, server Server) (ToolClient, error) {
return nil, errors.New(server.Name + " connect failed")
@@ -177,12 +177,12 @@ func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
for _, skipped := range runtime.Skipped() {
byName[skipped.Name] = skipped
}
firecrawl, ok := byName["firecrawl"]
exa, ok := byName["exa"]
if !ok {
t.Fatalf("Skipped() = %#v, want an entry for firecrawl", runtime.Skipped())
t.Fatalf("Skipped() = %#v, want an entry for exa", runtime.Skipped())
}
if !firecrawl.UnconfiguredDefault {
t.Fatalf("Skipped()[firecrawl] = %#v, want UnconfiguredDefault", firecrawl)
if !exa.UnconfiguredDefault {
t.Fatalf("Skipped()[exa] = %#v, want UnconfiguredDefault", exa)
}
custom, ok := byName["custom"]
if !ok {
+6
View File
@@ -231,6 +231,12 @@ func reasoningEffortsForModelName(name string) []ReasoningEffort {
// the gateway's translation concern — unknown fields are ignored, so the
// worst case is a silent no-op rather than a 400.
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
case strings.Contains(n, "deepseek"):
// DeepSeek V4 (deepseek-v4-flash / deepseek-v4-pro) exposes thinking-mode
// effort control through the OpenAI-compatible reasoning_effort parameter.
// The API accepts low/high/max and maps medium/xhigh onto high, so only
// the three distinct tiers are advertised.
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}
default:
return nil
}
@@ -20,6 +20,10 @@ func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
{"hy3", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"hunyuan-t1", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"openai/o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}}, // github-style vendor/ id
{"deepseek-v4-flash", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-v4-pro", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-reasoner", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-chat", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
{"gpt-4o-mini", nil},
{"ollama/llama3.1", nil},
+2 -1
View File
@@ -539,7 +539,8 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
hasCatalog := len(byID) > 0
// Aggregators publish the live list as the source of truth. Keep live-only
// ids even when a remote catalog also loaded, instead of intersecting.
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
preferLive := provider.Local ||
providermodelcatalog.PublicLiveCatalog(provider.ID) ||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
result := make([]Model, 0, len(liveModels))
for _, live := range liveModels {
@@ -226,6 +226,29 @@ func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
}
}
func TestMergeLocalModelsKeepsLiveOnlyEntries(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "lmstudio", Local: true},
[]Model{
{ID: "qwen3-coder-30b", ContextWindow: 32768},
{ID: "text-embedding-local"},
{ID: "qwen2.5-coder-32b-instruct", Description: "live description"},
},
[]Model{{ID: "qwen2.5-coder-32b-instruct", Description: "catalog description", ToolCall: true}},
)
if got, want := strings.Join(modelIDs(models), ","), "qwen3-coder-30b,qwen2.5-coder-32b-instruct"; got != want {
t.Fatalf("models = %q, want %q", got, want)
}
for _, model := range models {
if model.ID == "qwen2.5-coder-32b-instruct" {
if model.Description != "catalog description" || !model.ToolCall {
t.Fatalf("merged catalog metadata = %#v", model)
}
}
}
}
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
// Catalog and live endpoints return distinct payloads so the merge must keep
// live-only ids that are absent from the remote catalog response.
+108
View File
@@ -0,0 +1,108 @@
package providers
import (
"context"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction summarization model selection.
//
// Compaction summaries used to run on the session's main model — near a 200k
// window one summarization is a ~100k-token input call at full price, the
// single most expensive recurring event in a long run. A cheap model handles
// the "dense factual summary" task fine, so summarization is routed to one
// whenever the target is knowable.
//
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
// main model). The literal value "main" at any level forces the main model.
// A wrong choice is never fatal: the agent falls back to the main provider on
// the first summarizer failure for the rest of the run.
// CompactionModelEnv overrides the compaction summarization model.
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
// compactionMainSentinel disables the dedicated summarizer explicitly.
const compactionMainSentinel = "main"
// Curated cheap summarizer defaults, applied only for provider kinds whose
// model catalog is guaranteed (official endpoints). Custom / *-compatible
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
// failed call per run is worse than main-model prices.
const (
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
defaultOpenAICompactionModel = "gpt-4.1-mini"
)
// CompactionModelID returns the model that compaction summarization calls
// should use for the given profile, or "" to use the session's main model.
// preference is the resolved config value (preferences.compactionModel).
func CompactionModelID(profile config.ProviderProfile, preference string) string {
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
if value == "" {
value = strings.TrimSpace(preference)
}
if value != "" {
if strings.EqualFold(value, compactionMainSentinel) {
return ""
}
return value
}
return defaultCompactionModel(profile)
}
func defaultCompactionModel(profile config.ProviderProfile) string {
metadata, err := ResolveRuntimeMetadata(profile, Options{})
if err != nil {
return ""
}
var candidate string
switch metadata.ProviderKind {
case config.ProviderKindAnthropic:
candidate = defaultAnthropicCompactionModel
case config.ProviderKindGoogle:
candidate = defaultGoogleCompactionModel
case config.ProviderKindOpenAI:
candidate = defaultOpenAICompactionModel
default:
return ""
}
// Session already runs on the cheap model: a dedicated summarizer buys
// nothing and would only add a second provider instance.
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
return ""
}
return candidate
}
// CompactionSummarizerFactory returns the lazy constructor the agent loop
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
// The compactor passes the main model in force at that moment — on the first
// paid compaction and again after a mid-run model switch — so the choice
// tracks escalations: a run that starts on the cheap model gets no dedicated
// summarizer, and gains one once it escalates to a pricier main model.
// Returning (nil, nil) means "summarize on the main provider". The factory
// itself is nil only when no provider builder exists. Shared by the exec and
// TUI front ends so the selection rules cannot drift between them.
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
if newProvider == nil {
return nil
}
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
profile := base
if strings.TrimSpace(mainModelID) != "" {
profile.Model = mainModelID
}
modelID := CompactionModelID(profile, preference)
if modelID == "" {
return nil, nil
}
profile.Model = modelID
return newProvider(profile)
}
}
+113
View File
@@ -0,0 +1,113 @@
package providers
import (
"context"
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactionModelIDResolutionOrder(t *testing.T) {
anthropic := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
}
t.Setenv(CompactionModelEnv, "my-env-model")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-env-model" {
t.Fatalf("env must win, got %q", got)
}
t.Setenv(CompactionModelEnv, "main")
if got := CompactionModelID(anthropic, "my-config-model"); got != "" {
t.Fatalf("env 'main' must force the main model, got %q", got)
}
t.Setenv(CompactionModelEnv, "")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-config-model" {
t.Fatalf("config preference must apply when env unset, got %q", got)
}
if got := CompactionModelID(anthropic, "main"); got != "" {
t.Fatalf("config 'main' must force the main model, got %q", got)
}
}
func TestCompactionModelIDCuratedDefaults(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
anthropic := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: "claude-sonnet-4.5"}
if got := CompactionModelID(anthropic, ""); got != defaultAnthropicCompactionModel {
t.Fatalf("anthropic default = %q, want %q", got, defaultAnthropicCompactionModel)
}
google := config.ProviderProfile{Name: "google", ProviderKind: config.ProviderKindGoogle, Model: "gemini-2.5-pro"}
if got := CompactionModelID(google, ""); got != defaultGoogleCompactionModel {
t.Fatalf("google default = %q, want %q", got, defaultGoogleCompactionModel)
}
openai := config.ProviderProfile{Name: "openai", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
if got := CompactionModelID(openai, ""); got != defaultOpenAICompactionModel {
t.Fatalf("openai default = %q, want %q", got, defaultOpenAICompactionModel)
}
// Custom/compatible endpoints: catalog unknowable, no default.
compatible := config.ProviderProfile{
Name: "opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opengateway.gitlawb.com/v1",
Model: "some-model",
}
if got := CompactionModelID(compatible, ""); got != "" {
t.Fatalf("openai-compatible must have no default, got %q", got)
}
}
func TestCompactionModelIDSkipsWhenMainIsAlreadyCheap(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
haiku := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: defaultAnthropicCompactionModel,
}
if got := CompactionModelID(haiku, ""); got != "" {
t.Fatalf("already-cheap session must not get a dedicated summarizer, got %q", got)
}
}
func TestCompactionSummarizerFactoryTracksMainModel(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
base := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: defaultAnthropicCompactionModel}
var built []string
newProvider := func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = append(built, profile.Model)
return nil, errors.New("not needed")
}
factory := CompactionSummarizerFactory(base, "", newProvider)
if factory == nil {
t.Fatal("factory must exist when a provider builder exists")
}
// Main model is the cheap default: no dedicated summarizer, nothing built.
if provider, err := factory(context.Background(), defaultAnthropicCompactionModel); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("cheap main model must yield (nil, nil) without building, got %v %v built=%v", provider, err, built)
}
// After escalating to an expensive model the cheap default is built.
if _, err := factory(context.Background(), "claude-sonnet-4-5"); err == nil || len(built) != 1 || built[0] != defaultAnthropicCompactionModel {
t.Fatalf("escalated main model must build the cheap summarizer, built=%v err=%v", built, err)
}
// An empty main model falls back to the base profile's model — the cheap
// default here, so again nothing is built.
built = nil
if provider, err := factory(context.Background(), ""); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("empty main model must use the base profile, got %v %v built=%v", provider, err, built)
}
// The explicit "main" preference disables the dedicated summarizer.
if provider, err := CompactionSummarizerFactory(base, "main", newProvider)(context.Background(), "claude-sonnet-4-5"); provider != nil || err != nil {
t.Fatalf("preference main must disable the summarizer, got %v %v", provider, err)
}
if CompactionSummarizerFactory(base, "", nil) != nil {
t.Fatal("no provider builder means no factory")
}
}
+16 -1
View File
@@ -486,7 +486,7 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
Function: toolFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
Parameters: normalizeToolParameters(tool.Parameters),
},
})
}
@@ -494,6 +494,21 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
return mapped
}
// normalizeToolParameters keeps schemas accepted by strict OpenAI-compatible
// servers such as LM Studio. No-argument tools still need an object-valued
// parameters.properties field instead of an omitted field.
func normalizeToolParameters(parameters map[string]any) map[string]any {
normalized := make(map[string]any, len(parameters)+1)
for key, value := range parameters {
normalized[key] = value
}
properties, ok := normalized["properties"].(map[string]any)
if !ok || properties == nil {
normalized["properties"] = map[string]any{}
}
return normalized
}
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
// kill switch is set to a truthy value. "0" and "false" (any case) are
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
+130
View File
@@ -3,6 +3,7 @@ package openai
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
@@ -105,6 +106,72 @@ func TestStreamCompletionPostsChatCompletionRequest(t *testing.T) {
if tool["type"] != "function" {
t.Fatalf("unexpected tool wrapper: %#v", tool)
}
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
// TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject locks in the
// normalizeToolParameters fix: a tool whose parameters carry a typed-nil
// properties map must serialize "properties":{} on the wire, never
// "properties":null, because strict OpenAI-compatible servers (LM Studio)
// reject the null form.
func TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject(t *testing.T) {
var gotBody map[string]any
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotRaw = raw
if err := json.Unmarshal(raw, &gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "no_args",
Description: "Takes no arguments",
Parameters: map[string]any{"type": "object", "properties": map[string]any(nil)},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if !strings.Contains(string(gotRaw), `"properties":{}`) {
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":null`) {
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
func TestNewRequiresModelButNotAPIKey(t *testing.T) {
@@ -1479,3 +1546,66 @@ func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) {
t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"])
}
}
// TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject locks in the
// normalizeToolParameters type-assert branch: when a tool's parameters carry a
// `properties` field whose value is a non-map (e.g., an empty slice), it must
// be normalized to "properties":{} on the wire, never "properties":[] or
// "properties":null.
func TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject(t *testing.T) {
var gotBody map[string]any
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotRaw = raw
if err := json.Unmarshal(raw, &gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "no_args",
Description: "Takes no arguments",
Parameters: map[string]any{"type": "object", "properties": []any{}},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if !strings.Contains(string(gotRaw), `"properties":{}`) {
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":null`) {
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":[]`) {
t.Fatalf("request body must not serialize properties as an empty array, got: %s", gotRaw)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
@@ -0,0 +1,96 @@
package sandbox
import (
"path/filepath"
"strings"
"testing"
)
func TestApplyPatchPathBlockOnlyRejectsRelativeTraversal(t *testing.T) {
root := t.TempDir()
inside := filepath.Join(root, "main.js")
structured := func(path string) string {
return strings.Join([]string{"*** Begin Patch", "*** Update File: " + path, "@@", "-a", "+b", "*** End Patch"}, "\n")
}
for name, patch := range map[string]string{
"absolute inside workspace": structured(inside),
"relative": structured("main.js"),
"decorated markers": "*** Begin Patch ***\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch ***",
"no-space marker": "***Begin Patch\n*** Update File: main.js\n@@\n-a\n+b\n***End Patch",
} {
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
if block := applyPatchPathBlock(request); block != nil {
t.Fatalf("%s: unexpected block %+v", name, block)
}
}
for _, path := range []string{"../escape.js", ".."} {
for name, patch := range map[string]string{"canonical": structured(path), "no-space": "***Begin Patch\n*** Update File: " + path + "\n@@\n-a\n+b\n***End Patch"} {
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
block := applyPatchPathBlock(request)
if block == nil || block.Code != BlockOutsideWorkspace {
t.Fatalf("%s %q must be blocked as traversal, got %+v", name, path, block)
}
}
}
}
// Every marker spelling the tool applies must be classified as structured at
// the sandbox boundary too; otherwise the boundary scans the patch as a unified
// diff, extracts no targets, and validates nothing (fail-open).
func TestStructuredPatchClassifierMatchesToolSpellings(t *testing.T) {
for _, header := range []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "\ufeff*** Begin Patch"} {
patch := header + "\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch"
if !IsStructuredPatch(patch) {
t.Fatalf("%q must classify as a structured patch", header)
}
if paths := applyPatchPaths(patch); len(paths) != 1 || paths[0] != "main.js" {
t.Fatalf("%q: sandbox must extract the structured target, got %v", header, paths)
}
}
for _, header := range []string{"--- a/x", "Begin Patch", "*** Begin Patchwork", "*** Update File: x"} {
if IsStructuredPatch(header + "\n-a\n+b") {
t.Fatalf("%q must not classify as a structured patch", header)
}
}
if StructuredPatchMarker("*** End Patch ***") != "end" || StructuredPatchMarker("***End Patch") != "end" {
t.Fatal("decorated end markers must classify as end")
}
}
func TestApplyPatchRequestPathsCarryAbsolutePathsToScopeValidation(t *testing.T) {
root := t.TempDir()
// NewScope also grants the system temp dir, so a sibling t.TempDir() is
// legitimately in scope; pick a path under the filesystem root instead.
outside, err := filepath.Abs(filepath.Join(string(filepath.Separator), "zero-outside-workspace-test", "escape.js"))
if err != nil {
t.Fatal(err)
}
scope, err := NewScope(root, nil)
if err != nil {
t.Fatal(err)
}
inside := filepath.Join(root, "main.js")
structured := func(header, footer, path string) string {
return strings.Join([]string{header, "*** Update File: " + path, "@@", "-a", "+b", footer}, "\n")
}
for name, spelling := range map[string][2]string{"canonical": {"*** Begin Patch", "*** End Patch"}, "no-space": {"***Begin Patch", "***End Patch"}} {
// Failure path: the exact path the boundary parsed must be denied by the
// scope. structuredPatchHeaderPaths normalises separators to "/", so the
// parsed form is compared in slash form and then validated as-is.
paths := applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], outside)})
if len(paths) != 1 || paths[0] != filepath.ToSlash(outside) {
t.Fatalf("%s: absolute patch path must reach scope validation unchanged, got %v", name, paths)
}
if block := scope.validate(paths[0]); block == nil || block.Code != BlockOutsideWorkspace {
t.Fatalf("%s: scope must deny the parsed outside path %q, got %+v", name, paths[0], block)
}
// Success path: the parsed inside path must be accepted by the scope.
paths = applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], inside)})
if len(paths) != 1 || paths[0] != filepath.ToSlash(inside) {
t.Fatalf("%s: inside patch path must reach scope validation unchanged, got %v", name, paths)
}
if block := scope.validate(paths[0]); block != nil {
t.Fatalf("%s: scope must accept the parsed inside path %q, got %+v", name, paths[0], block)
}
}
}
+105 -10
View File
@@ -148,8 +148,10 @@ func unsafeCommandPrefix(prefix []string) bool {
return true
}
}
normalized := append([]string(nil), prefix...)
normalized[0] = normalizeLauncherName(normalized[0])
for _, banned := range bannedCommandPrefixSuggestions {
if sameStringSlice(prefix, banned) {
if sameStringSlice(prefix, banned) || sameStringSlice(normalized, banned) {
return true
}
}
@@ -165,25 +167,118 @@ func unsafeCommandPrefixPart(part string) bool {
}
func unsafeCommandPrefixLauncher(program string) bool {
program = strings.ToLower(strings.TrimSpace(program))
if strings.ContainsAny(program, `/\`) {
// A path is already refused. ":" and "~" are refused for the same reason:
// on Windows they introduce names that resolve to a different file than the
// one matched here, and no list can keep up with them. "PYTHON~1.EXE" is
// python.exe under an 8.3 short name, and 8.3 truncates the stem, so
// "POWERS~1.EXE" cannot be recognized by name at all; "python.exe::$DATA"
// names the same executable through its default stream. Refusing the shape
// costs a permission prompt on names no real command uses.
if strings.ContainsAny(program, `/\:~`) {
return true
}
switch program {
case "bash", "sh", "zsh", "/bin/bash", "/bin/zsh",
"pwsh", "powershell", "powershell.exe",
"env", "sudo", "doas", "su", "run0", "osascript",
name := normalizeLauncherName(program)
return bannedLauncherName(name) || bannedLauncherName(strings.TrimRight(name, "0123456789"))
}
func bannedLauncherName(name string) bool {
switch name {
case "bash", "sh", "zsh", "dash", "ash", "ksh", "csh", "tcsh", "fish", "busybox",
"pwsh", "powershell", "cmd", "wsl",
"env", "sudo", "sudoedit", "doas", "su", "run0", "osascript",
"command", "eval", "exec", "time",
"find", "xargs", "timeout", "nice", "nohup", "watch", "setsid", "stdbuf", "ionice",
"ssh", "make", "npm", "npx",
"python", "python3", "py", "pythonw", "pyw", "pypy", "pypy3",
"node", "perl", "ruby", "php", "lua", "deno", "bun":
"ssh", "make", "npm", "npx", "pnpm", "yarn", "bunx",
"python", "py", "pythonw", "pyw", "pypy", "uv", "uvx",
"node", "nodejs", "perl", "ruby", "php", "lua", "deno", "bun":
return true
default:
return false
}
}
// normalizeLauncherName reduces a program name to the launcher it actually runs
// so the list above cannot be stepped around by spelling the same interpreter
// differently: "python3.11", "python.exe" and "PYTHON3" all reach it as a name
// the list matches. internal/agent's commandName already normalizes case and
// Windows executable extensions on the allow side; this side matched raw, so
// every versioned or .exe-suffixed launcher validated as an ordinary command
// and persisted as a prefix grant that auto-allows every later invocation.
//
// A name is only ever narrowed toward an existing entry, so the failure
// direction is an extra permission prompt, never a silent grant.
func normalizeLauncherName(program string) string {
name := strings.ToLower(strings.TrimSpace(program))
// Windows discards trailing dots and spaces when resolving a filename, so
// "python." starts python.exe. Drop them before anything matches on the
// result, on every platform: the deny side must not depend on which OS is
// reading the grant, since the grants file travels with a synced home
// directory.
name = strings.TrimRight(name, ". ")
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com", ".ps1"} {
if strings.HasSuffix(name, suffix) {
name = strings.TrimSuffix(name, suffix)
name = strings.TrimRight(name, ". ")
break
}
}
return trimLauncherVersion(trimLauncherABISuffix(trimLauncherBuildSuffix(name)))
}
// trimLauncherBuildSuffix drops the build-channel suffixes distributions and
// upstreams append to an otherwise unchanged interpreter, so "python3.11-dbg",
// "bash-static" and "pwsh-preview" are the launchers they say they are. The set
// is closed on purpose: a general "-word" strip would also swallow
// "node-gyp", "python3-config" and "ruby-lsp", which are ordinary tools a user
// should still be able to approve.
func trimLauncherBuildSuffix(name string) string {
for _, suffix := range []string{"-dbg", "-debug", "-static", "-nightly", "-preview", "-beta"} {
if strings.HasSuffix(name, suffix) {
return strings.TrimSuffix(name, suffix)
}
}
return name
}
// trimLauncherABISuffix drops CPython's ABI flags from a versioned interpreter
// name, so "python3.7m" and "python3.6dm" reduce to their version, and the
// free-threaded builds "python3.13t" and "python3.13td" reduce to theirs. The
// flags are only removed when a digit sits underneath them, which leaves
// ordinary names that happen to end in those letters alone: "sha256sum" keeps
// its "sum", "zstd" its "td", "cat" its "t".
func trimLauncherABISuffix(name string) string {
trimmed := strings.TrimRight(name, "dmut")
if trimmed == name || trimmed == "" {
return name
}
if last := trimmed[len(trimmed)-1]; last < '0' || last > '9' {
return name
}
return trimmed
}
// trimLauncherVersion drops a trailing "<separator><digits>" version from a
// program name, so "python3.11" becomes "python3" and "python2.7" becomes
// "python2". Digits without a separator are part of the name, which keeps
// "base64", "7z" and "sha256sum" intact; the caller handles the "python3" shape
// by also testing the digit-stripped form.
func trimLauncherVersion(name string) string {
for {
trimmed := strings.TrimRight(name, "0123456789")
if trimmed == name || trimmed == "" {
return name
}
if !strings.HasSuffix(trimmed, ".") && !strings.HasSuffix(trimmed, "-") {
return name
}
next := trimmed[:len(trimmed)-1]
if next == "" {
return name
}
name = next
}
}
func sameStringSlice(left []string, right []string) bool {
if len(left) != len(right) {
return false
+115
View File
@@ -0,0 +1,115 @@
package sandbox
import "testing"
func TestUnsafeCommandPrefixLauncherIgnoresVersionAndExtension(t *testing.T) {
for _, program := range []string{
"python3", "python", "node", "git", "sh", "bash", "sudo",
"python3.11", "python3.12", "python2.7", "python3.13.1",
"python.exe", "python3.exe", "node.exe", "git.exe", "bash.exe",
"PYTHON3.11", "Node.EXE",
"cmd", "cmd.exe", "wsl", "wsl.exe",
// Windows resolves these to the same executable.
"python.", "python3.", "python3.11.", "python.exe.", "PYTHON.",
"python.cmd", "python.bat", "python.com", "python.ps1",
"PYTHON~1.EXE", "POWERS~1.EXE", "python.exe::$DATA",
// Versioned and ABI-suffixed interpreters.
"python3.7m", "python3.6dm", "pythonw3.11", "python-3.11",
// Free-threaded CPython (PEP 703) ships beside the GIL build.
"python3.13t", "python3.13t.exe", "python3.14t", "python3.13td",
// Distribution and build-channel spellings of the same interpreter.
"nodejs", "nodejs.exe", "node-nightly", "python3-dbg", "python3.11-dbg",
"python3.13t-dbg", "python3-debug", "python3.11-debug", "node-beta",
"pwsh-preview", "bash-static", "sudoedit",
// Twins of npm and npx, which the list already refuses.
"pnpm", "yarn", "bunx", "pnpm.cmd", "yarn.exe",
"perl5.36", "ruby3.2", "php8", "lua5.4", "node22.exe", "bash5",
} {
if !unsafeCommandPrefix([]string{program}) {
t.Errorf("unsafeCommandPrefix([%q]) = false, want true", program)
}
}
}
func TestUnsafeCommandPrefixKeepsOrdinaryCommandsGrantable(t *testing.T) {
for _, prefix := range [][]string{
{"cargo", "build"},
{"go", "test"},
{"ls", "-la"},
{"rg", "--json"},
{"7z", "l"},
{"base64", "-d"},
{"kubectl", "get", "pods"},
{"docker", "ps"},
{"sha256sum", "file"},
{"gcc-13", "-c"},
{"node-gyp", "build"},
{"python3-config", "--includes"},
{"s3cmd", "ls"},
{"cat", "file"},
{"zstd", "-d"},
{"sqlite3", "db"},
{"yt-dlp", "url"},
{"perl-doc", "-h"},
{"php-fpm", "-t"},
{"ruby-lsp", "--version"},
{"lua-language-server", "--check"},
{"nodemon", "app.js"},
} {
if unsafeCommandPrefix(prefix) {
t.Errorf("unsafeCommandPrefix(%q) = true, want false", prefix)
}
}
}
func TestNormalizeLauncherName(t *testing.T) {
for _, testCase := range []struct{ in, want string }{
{"python3.11", "python3"},
{"python2.7", "python2"},
{"pypy3", "pypy3"},
{"node.exe", "node"},
{"POWERSHELL.EXE", "powershell"},
{"7z", "7z"},
{"base64", "base64"},
{"sha256sum", "sha256sum"},
{"python.", "python"},
{"python3.11.exe.", "python3"},
{"python3.7m", "python3"},
{"python3.6dm", "python3"},
{"python3.13t", "python3"},
{"python3.13td", "python3"},
{"python3.11-dbg", "python3"},
{"node-nightly", "node"},
{"node-gyp", "node-gyp"},
{"python3-config", "python3-config"},
{"zstd", "zstd"},
{"cat", "cat"},
{"cargo", "cargo"},
{"", ""},
} {
if got := normalizeLauncherName(testCase.in); got != testCase.want {
t.Errorf("normalizeLauncherName(%q) = %q, want %q", testCase.in, got, testCase.want)
}
}
}
// Session-scoped grants are the agent's other entry point (loop.go grants them
// for a single turn without touching the store), so they must refuse the same
// launcher spellings the persisted path does.
func TestGrantCommandPrefixForSessionRefusesLauncherVariants(t *testing.T) {
engine := &Engine{commandPrefixes: newCommandPrefixGrantSet()}
engine.GrantCommandPrefixForSession("bash", []string{"cargo", "build"})
if _, ok := engine.LookupCommandPrefixForSession("bash", []string{"cargo", "build", "--release"}); !ok {
t.Fatal("CONTROL BROKEN: an ordinary session prefix grant did not match")
}
for _, prefix := range [][]string{
{"python3.11", "-c"},
{"python.exe", "-c"},
{"cmd", "/c"},
} {
engine.GrantCommandPrefixForSession("bash", prefix)
command := append(append([]string(nil), prefix...), "whatever")
if _, ok := engine.LookupCommandPrefixForSession("bash", command); ok {
t.Errorf("session grant %q matched %q, want refused", prefix, command)
}
}
}
+66 -1
View File
@@ -2,12 +2,15 @@ package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
@@ -343,7 +346,8 @@ func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
if err != nil {
t.Fatalf("read rewritten grant file: %v", err)
}
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), `"policyVersion": 1`) || !strings.Contains(string(raw), `"write_file": [`) {
policyLine := fmt.Sprintf(`"policyVersion": %d`, execution.PolicyVersion)
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), policyLine) || !strings.Contains(string(raw), `"write_file": [`) {
t.Fatalf("grant file was not rewritten as a versioned grant store:\n%s", raw)
}
backup, err := os.ReadFile(path + ".v1.backup")
@@ -406,6 +410,62 @@ func TestGrantStorePolicyChangePreservesDeniesAndInvalidatesApprovals(t *testing
}
}
// A prefix grant recorded before launcher-name normalization existed (a
// versioned or .exe-suffixed interpreter) no longer validates. It must reach
// the user through the policy migration — backup, denies preserved, one notice
// — and never make the whole grants file unreadable, which would silently drop
// their persisted deny grants too.
func TestGrantStoreMigratesPreNormalizationPrefixInsteadOfRejectingTheFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":3,"policyVersion":1,"grants":{"bash":[{"toolName":"bash","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["python3.11","-c"],"approvedAt":"2026-06-05T14:30:00Z"},{"toolName":"bash","prefix":["cargo","build"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 1 || grants[0].Decision != GrantDeny {
t.Fatalf("grants after migration = %#v err=%v, want the deny preserved", grants, err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil || len(prefixes) != 0 {
t.Fatalf("prefixes after migration = %#v err=%v, want all re-approved", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 2") {
t.Fatalf("notice = %q err=%v", notice, err)
}
if backup, err := os.ReadFile(path + ".policy-v1.backup"); err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v", backup, err)
}
}
// The legacy-schema path is a separate migration from the policy bump above and
// prunes per grant rather than wholesale, so a pre-normalization prefix must be
// dropped there while the valid prefix beside it survives.
func TestGrantStoreLegacyMigrationDropsOnlyThePreNormalizationPrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["python3.11","-c"],"approvedAt":"2026-06-05T14:30:00Z"},{"toolName":"bash","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil {
t.Fatalf("ListCommandPrefixes after legacy migration returned error: %v", err)
}
if len(prefixes) != 1 || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("prefixes = %#v, want only [git status]", prefixes)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "migrated 1, invalidated 1") {
t.Fatalf("notice = %q err=%v", notice, err)
}
}
func TestGrantStorePersistsCommandPrefixes(t *testing.T) {
store, err := NewGrantStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
@@ -481,6 +541,11 @@ func TestGrantStoreRejectsUnsafeCommandPrefixes(t *testing.T) {
{"python", "script.py"},
{"./script.sh"},
{"git"},
{"python3.11", "-c"},
{"python.exe", "-c"},
{"node.exe", "-e"},
{"git.exe"},
{"cmd", "/c"},
} {
if _, err := store.GrantCommandPrefix(CommandPrefixInput{ToolName: "bash", Prefix: prefix}); err == nil {
t.Fatalf("GrantCommandPrefix(%#v) succeeded, want validation error", prefix)
+33 -2
View File
@@ -281,11 +281,15 @@ func applyPatchPathBlock(request Request) *pathBlock {
if patch == "" {
return nil
}
// Only relative traversal is rejected up front. Absolute paths flow through
// the regular workspace-scope validation below (requestPaths), which accepts
// one inside the workspace and denies one outside — a model that echoes the
// absolute path read_file showed it must not be blocked for that alone.
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
if path == ".." || strings.HasPrefix(path, "../") {
return &pathBlock{
Code: BlockOutsideWorkspace,
Path: path,
@@ -296,8 +300,35 @@ func applyPatchPathBlock(request Request) *pathBlock {
return nil
}
// structuredPatchMarkerPattern is the single classifier for structured-patch
// markers, shared by the sandbox boundary and the apply_patch tool (which
// imports this package). It accepts the canonical "*** Begin Patch" /
// "*** End Patch" and the decorated spellings models emit ("*** Begin Patch ***",
// "***Begin Patch", trailing whitespace). Both sides must agree: a spelling the
// tool would apply but the sandbox did not recognise would make the sandbox
// scan the patch as a unified diff, extract no targets, and validate nothing.
var structuredPatchMarkerPattern = regexp.MustCompile(`^\*{3}\s*(Begin|End) Patch\s*\**\s*$`)
// StructuredPatchMarker classifies a line as the "begin" or "end" marker of a
// structured patch, or "" when it is neither.
func StructuredPatchMarker(line string) string {
match := structuredPatchMarkerPattern.FindStringSubmatch(strings.TrimSpace(line))
if match == nil {
return ""
}
return strings.ToLower(match[1])
}
// IsStructuredPatch reports whether patch opens with a structured begin marker.
// The tool applies exactly the patches this returns true for, so the sandbox
// extracts structured header paths for exactly the same set.
func IsStructuredPatch(patch string) bool {
first, _, _ := strings.Cut(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "\n")
return StructuredPatchMarker(first) == "begin"
}
func applyPatchPaths(patch string) []string {
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
if IsStructuredPatch(patch) {
return structuredPatchHeaderPaths(patch)
}
return patchHeaderPaths(patch)
+32 -187
View File
@@ -3,11 +3,11 @@ package tools
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
)
type applyPatchTool struct {
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
return applyPatchTool{
baseTool: baseTool{
name: "apply_patch",
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
description: "Apply a multi-hunk or multi-file patch inside the workspace (or a granted extra write root). Structured format:\n*** Begin Patch\n*** Update File: src/app.js\n@@\n unchanged context line\n-removed line\n+added line\n*** End Patch\nUse \"*** Add File: path\" with \"+\" lines to create a file and \"*** Delete File: path\" to remove one; several sections may follow each other. A unified diff (---/+++ headers with a/ b/ prefixes, @@ hunks) is also accepted and applied in-process. Paths are workspace-relative; absolute paths inside the workspace are fine. For a single targeted change, edit_file is simpler.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch patch to apply."},
"patch": {Type: "string", Description: "The structured (*** Begin Patch) or unified-diff patch text."},
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
},
Required: []string{"patch"},
@@ -59,177 +59,14 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
if isStructuredPatch(patch) {
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
}
if err := validatePatchPaths(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
tempFile, err := os.CreateTemp("", "zero-patch-*.patch")
// Unified diffs are translated into the same operations and applied by
// the same os.Root engine, so neither format opens a target by pathname
// after validation (no check-to-use window) and git is not needed.
operations, err := parseUnifiedPatch(patch)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
patchPath := tempFile.Name()
defer func() {
_ = os.Remove(patchPath)
}()
if _, err := tempFile.WriteString(patch); err != nil {
_ = tempFile.Close()
return errorResult("Error applying patch: " + err.Error())
}
if err := tempFile.Close(); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if err := recheckPatchWriteTargets(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
var createdTargets []string
var fullySuppliedTargets []string
wholeBefore := map[string]bool{}
if options.FileTracker != nil {
createdTargets = missingPatchTargets(applyRoot, patch)
fullySuppliedTargets = completeCreatedPatchTargets(applyRoot, patch)
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if absolute, _, rerr := resolveWorkspaceTargetPath(applyRoot, path); rerr == nil {
wholeBefore[absolute] = options.FileTracker.SeenWhole(absolute)
}
}
}
command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath)
command.Dir = applyRoot
output, err := command.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return errorResult("Error applying patch: " + message)
}
summary := "Patch applied successfully."
if relativeRoot != "." {
summary = "Patch applied successfully in " + relativeRoot + "."
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromPatch(relativeRoot, patch)
result.Display = Display{Summary: summary, Kind: "diff", Preview: capPreviewDiff(patch)}
fullySupplied := make(map[string]bool, len(fullySuppliedTargets))
for _, absolute := range fullySuppliedTargets {
fullySupplied[absolute] = true
}
// Re-baseline files changed by this tool. When the model had already seen the
// whole input (or supplied a complete new file), the exact patch plus that
// input determines the whole output, so a follow-up edit needs no wasted read.
// Partial observations remain conservative and are cleared by Record.
for _, changed := range result.ChangedFiles {
if absolute, _, rerr := resolveScopedPath(tool.workspaceRoot, tool.scope, changed); rerr == nil {
content, readErr := os.ReadFile(absolute)
if readErr != nil {
options.FileTracker.Forget(absolute)
continue
}
info, _ := os.Stat(absolute)
wasWhole := wholeBefore[absolute] || fullySupplied[absolute]
options.FileTracker.Record(absolute, content, info)
if wasWhole {
lines := lineCount(string(content))
options.FileTracker.RecordSeenRange(absolute, 1, lines, lines)
}
}
}
recordCreatedPatchTargets(options.FileTracker, createdTargets)
return result
}
func missingPatchTargets(root string, patch string) []string {
seen := map[string]bool{}
var missing []string
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
absolute, _, err := resolveWorkspaceTargetPath(root, path)
if err != nil || seen[absolute] {
continue
}
seen[absolute] = true
if _, err := os.Stat(absolute); os.IsNotExist(err) {
missing = append(missing, absolute)
}
}
return missing
}
// completeCreatedPatchTargets returns only files whose full contents are
// supplied by a /dev/null creation patch. A missing rename/copy destination is
// created by git too, but its bytes come from an unread source and must not gain
// whole-file observation credit.
func completeCreatedPatchTargets(root string, patch string) []string {
seen := map[string]bool{}
var created []string
oldRemaining, newRemaining := 0, 0
inHunk := false
fromDevNull := false
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
switch {
case strings.HasPrefix(line, "-"):
oldRemaining--
case strings.HasPrefix(line, "+"):
newRemaining--
case strings.HasPrefix(line, "\\"):
default:
oldRemaining--
newRemaining--
}
continue
}
inHunk = false
switch {
case strings.HasPrefix(line, "diff --git "):
fromDevNull = false
case strings.HasPrefix(line, "@@"):
oldRemaining, newRemaining = parseHunkCounts(line)
inHunk = oldRemaining > 0 || newRemaining > 0
case strings.HasPrefix(line, "--- "):
fromDevNull = patchFileHeaderPath(line) == "/dev/null"
case strings.HasPrefix(line, "+++ "):
path := patchFileHeaderPath(line)
if !fromDevNull || path == "" || path == "/dev/null" {
fromDevNull = false
continue
}
fromDevNull = false
absolute, _, err := resolveWorkspaceTargetPath(root, stripPatchPrefix(path))
if err != nil || seen[absolute] {
continue
}
if _, err := os.Stat(absolute); !os.IsNotExist(err) {
continue
}
seen[absolute] = true
created = append(created, absolute)
}
}
return created
}
func recordCreatedPatchTargets(tracker *FileTracker, missingBefore []string) {
if tracker == nil {
return
}
for _, absolute := range missingBefore {
if _, err := os.Stat(absolute); err != nil {
continue
}
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
absolute = resolved
}
tracker.RecordCreated(absolute)
}
return applyPatchOperations(applyRoot, relativeRoot, operations, options)
}
// changedFilesFromPatch extracts the unique, WORKSPACE-relative paths a patch
@@ -260,27 +97,35 @@ func changedFilesFromPatch(relativeRoot string, patch string) []string {
return paths
}
// normalizePatchPathForRoot resolves platform-level symlinks (macOS /var ->
// /private/var) in the prefix of an absolute patch path that lies outside the
// apply root, so a path the model copied from read_file compares equal to the
// symlink-resolved root. Relative paths are returned unchanged.
func normalizePatchPathForRoot(root string, path string) string {
if !filepath.IsAbs(path) {
return path
}
resolvedRoot, err := filepath.Abs(root)
if err != nil {
return path
}
if evaluated, err := filepath.EvalSymlinks(resolvedRoot); err == nil {
resolvedRoot = evaluated
}
return sandbox.NormalizePrefixForRoot(path, resolvedRoot)
}
func validatePatchPaths(root string, patch string) error {
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
// Relative traversal is rejected here; an absolute path is checked by
// resolveWorkspaceTargetPath, which only accepts one inside the root.
if path == ".." || strings.HasPrefix(path, "../") {
return fmt.Errorf("patch path %q must stay inside the workspace", path)
}
if _, _, err := resolveWorkspaceTargetPath(root, path); err != nil {
return err
}
}
return nil
}
func recheckPatchWriteTargets(root string, patch string) error {
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if err := recheckWorkspaceWriteTarget(root, path); err != nil {
if _, _, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path)); err != nil {
return err
}
}
@@ -351,7 +196,7 @@ func patchFileHeaderPath(line string) string {
// tokens. Scanning the whole line would let a crafted heading like
// "@@ -1,1 +1,1 @@ +1,999999" overwrite the real count, keep the parser stuck in
// hunk mode, and swallow later "--- "/"+++ " file headers so they escape
// validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass.
// validatePatchPaths — a workspace-confinement bypass.
func parseHunkCounts(line string) (int, int) {
_, rest, ok := strings.Cut(line, "@@")
if !ok {
@@ -0,0 +1,712 @@
package tools
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Models routinely decorate the structured markers ("*** Begin Patch ***") and
// write unified-diff ranges after "@@"; both used to fail on the first line and
// pushed the model into whole-file rewrites.
func TestApplyPatchToleratesDecoratedMarkersAndRangeHeaders(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\nbye\n")
patch := strings.Join([]string{
"*** Begin Patch ***",
"*** Update File: hello.txt",
"@@ -1,3 +1,3 @@",
" hello",
"-old",
"+new",
"*** End Patch ***",
"",
}, "\n")
if !isStructuredPatch(patch) {
t.Fatal("decorated begin marker must still be recognised as a structured patch")
}
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("decorated structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\nbye\n" {
t.Fatalf("patched content = %q", got)
}
}
func TestStructuredHunkAnchorKeepsHeadingAndPlainContext(t *testing.T) {
cases := map[string]string{
"-12,4 +12,6": "",
"-12 +12": "",
"-12,4 +12,6 @@": "",
"-12,4 +12,6 @@ func main() {": "func main() {",
"func main() {": "func main() {",
"": "",
}
for input, want := range cases {
if got := structuredHunkAnchor(input); got != want {
t.Fatalf("structuredHunkAnchor(%q) = %q, want %q", input, got, want)
}
}
}
func TestStructuredPatchMarkerSpellings(t *testing.T) {
begin := []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "*** Begin Patch **"}
for _, line := range begin {
if structuredPatchMarker(line) != "begin" {
t.Fatalf("%q must read as the begin marker", line)
}
}
if structuredPatchMarker("*** End Patch ***") != "end" {
t.Fatal("decorated end marker must read as the end marker")
}
for _, line := range []string{"*** Update File: x", "Begin Patch", "*** Begin Patchwork"} {
if structuredPatchMarker(line) != "" {
t.Fatalf("%q must not read as a marker", line)
}
}
}
func TestStructuredPatchHeaderErrorCarriesFormatHint(t *testing.T) {
_, err := parseStructuredPatch("--- a/x\n+++ b/x\n")
if err == nil || !strings.Contains(err.Error(), "*** Update File: path") {
t.Fatalf("header error should teach the format, got %v", err)
}
}
func TestApplyPatchAcceptsAbsoluteInWorkspacePaths(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
absolute := filepath.Join(root, "hello.txt")
structured := strings.Join([]string{
"*** Begin Patch",
"*** Update File: " + absolute,
"@@",
" hello",
"-old",
"+structured",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": structured})
if result.Status != StatusOK {
t.Fatalf("absolute in-workspace structured path should apply, got %s: %s", result.Status, result.Output)
}
content, _ := os.ReadFile(absolute)
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nstructured\n" {
t.Fatalf("structured content = %q", got)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "hello.txt" {
t.Fatalf("changed files should be workspace-relative, got %v", result.ChangedFiles)
}
unified := strings.Join([]string{
"--- " + absolute,
"+++ " + absolute,
"@@ -1,2 +1,2 @@",
" hello",
"-structured",
"+unified",
"",
}, "\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": unified})
if result.Status != StatusOK {
t.Fatalf("absolute in-workspace unified path should apply, got %s: %s", result.Status, result.Output)
}
content, _ = os.ReadFile(absolute)
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nunified\n" {
t.Fatalf("unified content = %q", got)
}
}
func TestApplyPatchStillRejectsAbsolutePathsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "escape.txt")
writeTestFile(t, outside, "old\n")
for name, patch := range map[string]string{
"structured": strings.Join([]string{"*** Begin Patch", "*** Update File: " + outside, "@@", "-old", "+new", "*** End Patch", ""}, "\n"),
"unified": strings.Join([]string{"--- " + outside, "+++ " + outside, "@@ -1 +1 @@", "-old", "+new", ""}, "\n"),
} {
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status == StatusOK {
t.Fatalf("%s patch outside the workspace must be rejected", name)
}
if content, _ := os.ReadFile(outside); string(content) != "old\n" {
t.Fatalf("%s patch must not touch a file outside the workspace", name)
}
}
}
// Unified diffs are applied in-process through the same os.Root engine as
// structured patches: git C-quoted paths with whitespace resolve to one file,
// and the same header outside the workspace is refused before anything is
// written.
func TestUnifiedPatchQuotedWhitespacePaths(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "work tree")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "file.txt")
writeTestFile(t, target, "hello\nold\n")
quoted := func(path string) string { return strconv.Quote(path) }
inside := strings.Join([]string{
"diff --git " + quoted("a/"+filepath.ToSlash(target)) + " " + quoted("b/"+filepath.ToSlash(target)),
"--- " + quoted(target),
"+++ " + quoted(target),
"@@ -1,2 +1,2 @@",
" hello",
"-old",
"+new",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": inside})
if result.Status != StatusOK {
t.Fatalf("quoted whitespace path inside the workspace should apply, got %s: %s", result.Status, result.Output)
}
content, _ := os.ReadFile(target)
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("content = %q", got)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "work tree/file.txt" {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
outsideDir := filepath.Join(t.TempDir(), "other tree")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(outsideDir, "file.txt")
writeTestFile(t, outside, "hello\nold\n")
escape := strings.Join([]string{"--- " + quoted(outside), "+++ " + quoted(outside), "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": escape})
if result.Status == StatusOK {
t.Fatal("quoted whitespace path outside the workspace must be rejected")
}
if content, _ := os.ReadFile(outside); string(content) != "hello\nold\n" {
t.Fatal("outside file must be untouched")
}
}
// A workspace path that is swapped for a symlink escaping the root — the
// classic check-to-use attack — is refused by the os.Root engine for both
// formats, and the outside file is never modified.
func TestApplyPatchRefusesSymlinkEscapingWorkspace(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "secret.txt")
writeTestFile(t, outside, "hello\nold\n")
link := filepath.Join(root, "target.txt")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
for name, patch := range map[string]string{
"unified": strings.Join([]string{"--- a/target.txt", "+++ b/target.txt", "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n"),
"structured": strings.Join([]string{"*** Begin Patch", "*** Update File: target.txt", "@@", " hello", "-old", "+new", "*** End Patch", ""}, "\n"),
} {
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status == StatusOK {
t.Fatalf("%s patch through an escaping symlink must be refused", name)
}
if content, _ := os.ReadFile(outside); string(content) != "hello\nold\n" {
t.Fatalf("%s patch must not modify the file outside the workspace", name)
}
}
}
func TestUnifiedPatchCreatesDeletesAndHonoursNoNewlineMarker(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "bye\n")
writeTestFile(t, filepath.Join(root, "keep.txt"), "a\nb\n")
patch := strings.Join([]string{
"diff --git a/new.txt b/new.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/new.txt",
"@@ -0,0 +1,2 @@",
"+first",
"+second",
"\\ No newline at end of file",
"diff --git a/gone.txt b/gone.txt",
"deleted file mode 100644",
"--- a/gone.txt",
"+++ /dev/null",
"@@ -1 +0,0 @@",
"-bye",
"--- a/keep.txt",
"+++ b/keep.txt",
"@@ -1,2 +1,2 @@",
" a",
"-b",
"+c",
"\\ No newline at end of file",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("multi-file unified patch should apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "new.txt")); string(content) != "first\nsecond" {
t.Fatalf("created file = %q", string(content))
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
t.Fatal("deleted file must be gone")
}
if content, _ := os.ReadFile(filepath.Join(root, "keep.txt")); string(content) != "a\nc" {
t.Fatalf("updated file = %q", string(content))
}
if len(result.ChangedFiles) != 3 {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
}
func TestUnifiedPatchUsesRangeHintThenFallsBackToContext(t *testing.T) {
root := t.TempDir()
// Two identical blocks: the hunk's range picks the second one, which
// content search alone would call ambiguous.
writeTestFile(t, filepath.Join(root, "dup.txt"), "x\ny\nx\ny\n")
patch := strings.Join([]string{"--- a/dup.txt", "+++ b/dup.txt", "@@ -3,2 +3,2 @@", " x", "-y", "+z", ""}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("range-hinted hunk should apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "dup.txt")); string(content) != "x\ny\nx\nz\n" {
t.Fatalf("content = %q", string(content))
}
// A stale range (file grew above the hunk) still applies by unique context.
writeTestFile(t, filepath.Join(root, "moved.txt"), "header\nhello\nold\n")
patch = strings.Join([]string{"--- a/moved.txt", "+++ b/moved.txt", "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("offset hunk should apply by context, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "moved.txt")); string(content) != "header\nhello\nnew\n" {
t.Fatalf("content = %q", string(content))
}
}
func TestUnifiedPatchRenameWithHunkAndRejectsBinary(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "x.txt"), "hello\nold\n")
rename := strings.Join([]string{
"diff --git a/x.txt b/y.txt",
"similarity index 90%",
"rename from x.txt",
"rename to y.txt",
"--- a/x.txt",
"+++ b/y.txt",
"@@ -1,2 +1,2 @@",
" hello",
"-old",
"+new",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": rename})
if result.Status != StatusOK {
t.Fatalf("rename with hunk should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "x.txt")); !os.IsNotExist(err) {
t.Fatal("renamed source must be gone")
}
if content, _ := os.ReadFile(filepath.Join(root, "y.txt")); string(content) != "hello\nnew\n" {
t.Fatalf("renamed content = %q", string(content))
}
for name, patch := range map[string]string{
"binary": "diff --git a/x b/x\nBinary files a/x and b/x differ\n",
"empty": "diff --git a/x b/x\n",
} {
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status == StatusOK {
t.Fatalf("%s patch must be rejected", name)
}
}
}
func TestUnifiedPatchCopyOperation(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "src.txt"), "hello\nold\n")
copyPatch := func(from, to string, hunk bool) string {
lines := []string{"diff --git a/" + from + " b/" + to, "similarity index 90%", "copy from " + from, "copy to " + to}
if hunk {
lines = append(lines, "--- a/"+from, "+++ b/"+to, "@@ -1,2 +1,2 @@", " hello", "-old", "+new")
}
return strings.Join(append(lines, ""), "\n")
}
// Success: source kept, destination created with the hunk applied, and the
// destination inherits the source's tracker state (read whole -> whole).
tracker := NewFileTracker()
registry := NewRegistry()
registry.Register(NewScopedReadFileTool(root, nil))
registry.Register(NewScopedApplyPatchTool(root, nil))
opts := RunOptions{PermissionGranted: true, FileTracker: tracker}
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "src.txt"}, opts); r.Status != StatusOK {
t.Fatalf("read: %s", r.Output)
}
result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": copyPatch("src.txt", "dst.txt", true)}, opts)
if result.Status != StatusOK {
t.Fatalf("copy with hunk should apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "src.txt")); string(content) != "hello\nold\n" {
t.Fatalf("copy must keep the source, got %q", string(content))
}
if content, _ := os.ReadFile(filepath.Join(root, "dst.txt")); string(content) != "hello\nnew\n" {
t.Fatalf("copy destination = %q", string(content))
}
if len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "src.txt" || result.ChangedFiles[1] != "dst.txt" {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
destination, err := filepath.EvalSymlinks(filepath.Join(root, "dst.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(destination) {
t.Fatal("destination copied from a fully read source must be tracked as seen whole")
}
unreadSource, _ := filepath.EvalSymlinks(filepath.Join(root, "src.txt"))
if _, tracked := tracker.Version(unreadSource); !tracked {
t.Fatal("source must stay tracked after a copy")
}
// Failure: destination already exists — nothing is written.
writeTestFile(t, filepath.Join(root, "taken.txt"), "keep\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": copyPatch("src.txt", "taken.txt", false)})
if result.Status == StatusOK || !strings.Contains(result.Output, "already exists") {
t.Fatalf("copy onto an existing destination must be refused, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "taken.txt")); string(content) != "keep\n" {
t.Fatal("existing destination must be untouched")
}
// Failure: copy onto itself.
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": copyPatch("src.txt", "src.txt", false)})
if result.Status == StatusOK || !strings.Contains(result.Output, "onto itself") {
t.Fatalf("copy onto itself must be refused, got %s: %s", result.Status, result.Output)
}
}
func TestUnifiedPatchRejectsOverDeclaredHunkCounts(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "a.txt"), "hello\nold\n")
writeTestFile(t, filepath.Join(root, "b.txt"), "x\n")
// "-1,3 +1,3" over-declares a two-line hunk, so without a guard the next
// file's ---/+++ headers would be swallowed as "-"/"+" content.
patch := strings.Join([]string{
"--- a/a.txt", "+++ b/a.txt", "@@ -1,3 +1,3 @@", " hello", "-old", "+new",
"--- a/b.txt", "+++ b/b.txt", "@@ -1 +1 @@", "-x", "+y", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status == StatusOK || !strings.Contains(result.Output, "declared line counts") {
t.Fatalf("over-declared hunk must be reported as malformed, got %s: %s", result.Status, result.Output)
}
for name, want := range map[string]string{"a.txt": "hello\nold\n", "b.txt": "x\n"} {
if content, _ := os.ReadFile(filepath.Join(root, name)); string(content) != want {
t.Fatalf("%s must be untouched after a malformed patch, got %q", name, string(content))
}
}
// Truncated at end of input is reported the same way.
truncated := "--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n hello\n-old\n"
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": truncated}); result.Status == StatusOK || !strings.Contains(result.Output, "declared line counts") {
t.Fatalf("truncated hunk must be reported as malformed, got %s: %s", result.Status, result.Output)
}
}
func TestUnifiedPatchNoNewlineMarkerOnlyAfterFinalHunk(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "two.txt"), "a\nb\nc\nd\ne\nf\n")
// A marker after the first of two hunks describes nothing real and must
// not leak into the second hunk's result: the patch is rejected untouched.
early := strings.Join([]string{
"--- a/two.txt", "+++ b/two.txt",
"@@ -1,2 +1,2 @@", " a", "-b", "+B", "\\ No newline at end of file",
"@@ -5,2 +5,2 @@", " e", "-f", "+F", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": early})
if result.Status == StatusOK || !strings.Contains(result.Output, "must follow the last hunk") {
t.Fatalf("marker before a later hunk must be rejected, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "two.txt")); string(content) != "a\nb\nc\nd\ne\nf\n" {
t.Fatal("rejected patch must not modify the file")
}
// The same marker after the final hunk applies and strips the newline.
final := strings.Join([]string{
"--- a/two.txt", "+++ b/two.txt",
"@@ -1,2 +1,2 @@", " a", "-b", "+B",
"@@ -5,2 +5,2 @@", " e", "-f", "+F", "\\ No newline at end of file", "",
}, "\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": final})
if result.Status != StatusOK {
t.Fatalf("two-hunk patch with a final marker should apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "two.txt")); string(content) != "a\nB\nc\nd\ne\nF" {
t.Fatalf("content = %q", string(content))
}
}
// A removed "-- …" line directly followed by an added "++ …" line looks like a
// ---/+++ header pair; the boundary check must not mistake it for one.
func TestUnifiedPatchKeepsAdjacentDashPlusContentLines(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.sql"), "select 1;\n-- old comment\nselect 2;\n")
patch := strings.Join([]string{
"--- a/notes.sql", "+++ b/notes.sql",
"@@ -1,3 +1,3 @@", " select 1;", "--- old comment", "+++ new comment", " select 2;", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("adjacent -- / ++ content lines must apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "notes.sql")); string(content) != "select 1;\n++ new comment\nselect 2;\n" {
t.Fatalf("content = %q", string(content))
}
}
// A unified deletion states the content it removes; it must be verified
// against the current file before anything is deleted.
func TestUnifiedPatchDeletionVerifiesExpectedContent(t *testing.T) {
deletion := func(path string, hunks ...string) string {
lines := []string{"diff --git a/" + path + " b/" + path, "deleted file mode 100644", "--- a/" + path, "+++ /dev/null"}
return strings.Join(append(append(lines, hunks...), ""), "\n")
}
t.Run("matching deletion succeeds", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-current")})
if result.Status != StatusOK {
t.Fatalf("matching deletion should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
t.Fatal("file must be deleted")
}
})
t.Run("stale deletion is refused untouched", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-stale")})
if result.Status == StatusOK || !strings.Contains(result.Output, "does not match its current content") {
t.Fatalf("stale deletion must be refused, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "current\n" {
t.Fatalf("file must be byte-for-byte unchanged, got %q", string(content))
}
})
t.Run("partial deletion is refused", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "one\ntwo\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-one")})
if result.Status == StatusOK || !strings.Contains(result.Output, "does not match its current content") {
t.Fatalf("deletion that does not cover the file must be refused, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "one\ntwo\n" {
t.Fatal("file must be unchanged")
}
})
t.Run("header-only deletion is refused", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt")})
if result.Status == StatusOK || !strings.Contains(result.Output, "must include the hunk") {
t.Fatalf("header-only deletion must be refused, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); err != nil {
t.Fatal("file must still exist")
}
})
t.Run("multi-hunk deletion verifies everything before removing", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "a\nb\nc\nd\n")
good := deletion("gone.txt", "@@ -1,2 +0,0 @@", "-a", "-b", "@@ -3,2 +0,0 @@", "-c", "-d")
bad := deletion("gone.txt", "@@ -1,2 +0,0 @@", "-a", "-b", "@@ -3,2 +0,0 @@", "-c", "-stale")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": bad})
if result.Status == StatusOK {
t.Fatalf("deletion with one stale hunk must be refused: %s", result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "a\nb\nc\nd\n" {
t.Fatal("no hunk may be applied when any hunk is stale")
}
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": good})
if result.Status != StatusOK {
t.Fatalf("multi-hunk deletion should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
t.Fatal("file must be deleted")
}
})
t.Run("structured delete stays unconditional", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "anything\n")
patch := "*** Begin Patch\n*** Delete File: gone.txt\n*** End Patch\n"
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status != StatusOK {
t.Fatalf("structured delete should apply, got %s: %s", result.Status, result.Output)
}
})
}
// The source is re-read through the root immediately before commit, so a
// change made after planning is refused instead of overwritten or removed.
func TestApplyStructuredPatchChangeRefusesSourceChangedAfterPlanning(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "file.txt")
writeTestFile(t, path, "planned\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
target := structuredPatchTarget{requested: "file.txt", absolute: path, relative: "file.txt"}
destination := structuredPatchTarget{requested: "copy.txt", absolute: filepath.Join(root, "copy.txt"), relative: "copy.txt"}
for name, change := range map[string]structuredPatchChange{
"update": {kind: structuredPatchUpdate, from: target, to: target, before: "planned\n", after: "new\n", mode: 0o644},
"delete": {kind: structuredPatchDelete, from: target, to: target, before: "planned\n", mode: 0o644},
"copy": {kind: structuredPatchCopy, from: target, to: destination, before: "planned\n", after: "planned\n", mode: 0o644},
} {
writeTestFile(t, path, "changed after planning\n")
committed, err := applyStructuredPatchChange(workspace, change)
if err == nil || committed || !strings.Contains(err.Error(), "changed on disk between planning and commit") {
t.Fatalf("%s: expected a refusal, got committed=%v err=%v", name, committed, err)
}
if content, _ := os.ReadFile(path); string(content) != "changed after planning\n" {
t.Fatalf("%s: file must be untouched, got %q", name, string(content))
}
if _, statErr := os.Stat(filepath.Join(root, "copy.txt")); !os.IsNotExist(statErr) {
t.Fatalf("%s: refused change must not create copy.txt", name)
}
}
}
func TestUnifiedPatchDeletionIsByteExact(t *testing.T) {
deletion := func(hunks ...string) string {
return strings.Join(append(append([]string{"--- a/gone.txt", "+++ /dev/null"}, hunks...), ""), "\n")
}
for name, tc := range map[string]struct{ file, patch string }{
"trailing whitespace differs": {"current \n", deletion("@@ -1 +0,0 @@", "-current")},
"indentation differs": {" current\n", deletion("@@ -1 +0,0 @@", "-current")},
"blank line not covered": {"current\n\n", deletion("@@ -1 +0,0 @@", "-current")},
"space-only line not covered": {"current\n \n", deletion("@@ -1 +0,0 @@", "-current")},
"missing final newline": {"current", deletion("@@ -1 +0,0 @@", "-current")},
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), tc.file)
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": tc.patch})
if result.Status == StatusOK {
t.Fatalf("deletion must be byte-exact; %q was deleted by a non-matching hunk", tc.file)
}
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != tc.file {
t.Fatalf("file must be unchanged, got %q", string(content))
}
})
}
t.Run("no-newline marker on the removed side matches exactly", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "current")
patch := deletion("@@ -1 +0,0 @@", "-current", "\\ No newline at end of file")
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status != StatusOK {
t.Fatalf("deletion with a matching no-newline marker should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
t.Fatal("file must be deleted")
}
})
t.Run("CRLF file matches an LF patch of the same content", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "a\r\nb\r\n")
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("@@ -1,2 +0,0 @@", "-a", "-b")}); result.Status != StatusOK {
t.Fatalf("CRLF deletion should apply, got %s: %s", result.Status, result.Output)
}
})
}
// git describes an empty file's creation or deletion with header lines only.
func TestUnifiedPatchHeaderOnlyEmptyFileForms(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "empty.txt"), "")
writeTestFile(t, filepath.Join(root, "full.txt"), "content\n")
writeTestFile(t, filepath.Join(root, "keep.txt"), "a\n")
deleteHeader := func(path string) string {
return "diff --git a/" + path + " b/" + path + "\ndeleted file mode 100644\nindex e69de29..0000000\n"
}
// A non-empty file is not deleted by the header-only form.
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deleteHeader("full.txt")})
if result.Status == StatusOK || !strings.Contains(result.Output, "expects an empty file") {
t.Fatalf("header-only deletion of a non-empty file must be refused, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "full.txt")); err != nil {
t.Fatal("non-empty file must still exist")
}
// In a multi-file patch the empty-file deletion and creation are applied
// alongside a normal hunk.
patch := deleteHeader("empty.txt") +
"diff --git a/blank.txt b/blank.txt\nnew file mode 100644\nindex 0000000..e69de29\n" +
"diff --git a/keep.txt b/keep.txt\n--- a/keep.txt\n+++ b/keep.txt\n@@ -1 +1 @@\n-a\n+b\n"
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("multi-file patch with header-only forms should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "empty.txt")); !os.IsNotExist(err) {
t.Fatal("empty file must be deleted")
}
if content, err := os.ReadFile(filepath.Join(root, "blank.txt")); err != nil || len(content) != 0 {
t.Fatalf("empty file must be created, got err=%v content=%q", err, string(content))
}
if content, _ := os.ReadFile(filepath.Join(root, "keep.txt")); string(content) != "b\n" {
t.Fatalf("hunk file = %q", string(content))
}
if len(result.ChangedFiles) != 3 {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
}
// When a later change fails, the error names exactly which files were already
// committed so the caller knows what changed and what did not.
func TestApplyPatchOperationsReportsCommittedPrefixOnFailure(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "first.txt"), "one\n")
writeTestFile(t, filepath.Join(root, "second.txt"), "two\n")
writeTestFile(t, filepath.Join(root, "third.txt"), "three\n")
// Make second.txt change after planning so its commit is refused.
structuredPatchBeforeCommit = func(change structuredPatchChange) {
if change.to.relative == "second.txt" {
writeTestFile(t, filepath.Join(root, "second.txt"), "changed\n")
}
}
defer func() { structuredPatchBeforeCommit = nil }()
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: first.txt", "@@", "-one", "+ONE",
"*** Update File: second.txt", "@@", "-two", "+TWO",
"*** Update File: third.txt", "@@", "-three", "+THREE",
"*** End Patch", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status == StatusOK {
t.Fatal("patch must fail when a later change is refused")
}
for _, want := range []string{"already committed: first.txt", "remaining files are unchanged"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("error must report the committed prefix, got: %s", result.Output)
}
}
if strings.Contains(result.Output, "committed: first.txt, second.txt") || strings.Contains(result.Output, "third.txt") {
t.Fatalf("error must not list uncommitted files as committed: %s", result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "first.txt")); string(content) != "ONE\n" {
t.Fatalf("first.txt must hold the committed change, got %q", string(content))
}
if content, _ := os.ReadFile(filepath.Join(root, "second.txt")); string(content) != "changed\n" {
t.Fatalf("second.txt must be untouched by the patch, got %q", string(content))
}
if content, _ := os.ReadFile(filepath.Join(root, "third.txt")); string(content) != "three\n" {
t.Fatalf("third.txt must be untouched, got %q", string(content))
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
if updated == modelKnownContent {
if previouslySeenWhole {
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(updated), lineCount(updated))
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(updated), trackedLineTotal(updated))
} else {
for _, span := range editedSpans {
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
+7 -7
View File
@@ -26,8 +26,8 @@ func TestReadFileToolReadsLineRanges(t *testing.T) {
}
for _, want := range []string{
"File: notes.txt (lines 2-3 of 4)",
"2 | beta",
"3 | gamma",
"2→beta",
"3→gamma",
} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
@@ -54,7 +54,7 @@ func TestReadFileToolReadsCanonicalLineRange(t *testing.T) {
if result.Truncated {
t.Fatalf("canonical limit defines the requested range and must not mark it truncated: %#v", result.Meta)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
t.Fatalf("canonical range returned the wrong lines: %q", result.Output)
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
@@ -94,7 +94,7 @@ func TestReadFileToolMixedLegacyRangesPreferLines(t *testing.T) {
if result.Status != StatusOK {
t.Fatalf("mixed legacy range should recover, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
t.Fatalf("mixed legacy range did not prefer lines: %q", result.Output)
}
}
@@ -110,7 +110,7 @@ func TestReadFileToolCombinesLegacyStartWithCanonicalLimit(t *testing.T) {
if result.Status != StatusOK || result.Truncated {
t.Fatalf("mixed compatible range should be exact, got status=%s truncated=%v: %s", result.Status, result.Truncated, result.Output)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
t.Fatalf("mixed compatible range returned the wrong lines: %q", result.Output)
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
@@ -955,7 +955,7 @@ func TestReadFileToolRecoversBackwardsRange(t *testing.T) {
for _, want := range []string{
"end_line 2 was before start_line 3", // the note
"only line 3 was read",
"3 | gamma",
"3→gamma",
} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected %q in output, got %q", want, result.Output)
@@ -984,7 +984,7 @@ func TestReadFileToolValidRangeUnchangedByRecovery(t *testing.T) {
// Byte-for-byte: header, blank separator, the exact selected lines, and no
// recovery note or trailing content. An altered header/separator or a stray
// out-of-range line would fail this where a substring check would not.
const want = "File: notes.txt (lines 2-4 of 5)\n\n2 | beta\n3 | gamma\n4 | delta"
const want = "File: notes.txt (lines 2-4 of 5)\n\n2→beta\n3→gamma\n4→delta"
if result.Output != want {
t.Fatalf("valid-range output changed by the recovery path:\n got: %q\nwant: %q", result.Output, want)
}
+6 -4
View File
@@ -1,9 +1,11 @@
package tools
// ModelVisible reports whether a registered tool is advertised to an agent.
// edit_file remains implemented for compatibility with existing integrations,
// but its text-only replacement contract cannot safely disambiguate repeated
// source fragments. Agents use apply_patch for contextual existing-file edits.
// Every registered tool is visible. edit_file (exact search/replace with a
// uniqueness guard, CRLF and fuzzy fallbacks) was hidden for a while in favour
// of apply_patch; head-to-head benchmarking showed that when a model misses the
// patch grammar it degrades to whole-file rewrites, so both edit tools are
// advertised and the prompt steers between them.
func ModelVisible(tool Tool) bool {
return tool != nil && tool.Name() != "edit_file"
return tool != nil
}
+15 -4
View File
@@ -2,17 +2,28 @@ package tools
import "testing"
func TestToolSearchDoesNotSurfaceLegacyStringReplacement(t *testing.T) {
func TestToolSearchSurfacesBothEditTools(t *testing.T) {
registry := NewRegistry()
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
search := NewToolSearchTool(registry).(toolSearchTool)
eager := search.visibleEagerToolNames(nil, nil, "ask")
if eager["edit_file"] {
t.Fatal("tool_search must not describe edit_file as available to the model")
if !eager["edit_file"] {
t.Fatal("tool_search must describe edit_file as available to the model")
}
if !eager["apply_patch"] {
t.Fatal("tool_search must retain apply_patch as the available edit tool")
t.Fatal("tool_search must retain apply_patch as an available edit tool")
}
}
func TestModelVisibleAdvertisesEveryRegisteredTool(t *testing.T) {
if ModelVisible(nil) {
t.Fatal("a nil tool must not be visible")
}
for _, tool := range []Tool{NewScopedEditFileTool(t.TempDir(), nil), NewScopedApplyPatchTool(t.TempDir(), nil), NewScopedWriteFileTool(t.TempDir(), nil)} {
if !ModelVisible(tool) {
t.Fatalf("%s must be visible to the model", tool.Name())
}
}
}
+11 -9
View File
@@ -9,12 +9,14 @@ import (
"io"
"os"
"strconv"
"strings"
"unicode/utf8"
)
const readFileByteChunkMax = 64 * 1024
// readFileLinePrefixSeparator follows the line number on every read_file line.
const readFileLinePrefixSeparator = "→"
type readFileTool struct {
baseTool
workspaceRoot string
@@ -31,7 +33,7 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
return readFileTool{
baseTool: baseTool{
name: "read_file",
description: "Read exact file text with line numbers. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
description: "Read exact file text, each line prefixed with its line number and →. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
@@ -221,7 +223,6 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
}
lastLine := startLine + selectedLines - 1
width := len(strconv.Itoa(lastLine))
header := fmt.Sprintf("File: %s (%d lines)", relativePath, total)
if startLine != 1 || endLine != total || maxLines > 0 {
header = fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total)
@@ -244,7 +245,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
budgetedOutput.WriteString("\n")
}
budgetedOutput.WriteString("\n")
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines); err != nil {
return errorResult("Error reading file " + relativePath + ": " + err.Error())
}
if truncated {
@@ -357,7 +358,7 @@ func renderReadFileBytes(path, relativePath string, total, requestedStart, limit
return Result{Status: StatusOK, Output: output, Meta: map[string]string{"next_byte_offset": strconv.Itoa(end)}}, start, end
}
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int, width int) error {
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int) error {
file, err := os.Open(path)
if err != nil {
return err
@@ -380,10 +381,11 @@ func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int
if emitted > 0 {
output.WriteString("\n")
}
number := strconv.Itoa(lineNumber)
output.WriteString(strings.Repeat(" ", width-len(number)))
output.WriteString(number)
output.WriteString(" | ")
// Compact "N→" prefix: the padded " N | " form cost ~19% of every
// read (about 1.5k tokens per 800-line file) and was re-sent on each
// later call; no consumer parses it, and models read "N→" natively.
output.WriteString(strconv.Itoa(lineNumber))
output.WriteString(readFileLinePrefixSeparator)
output.WriteString(string(trimLineBreak(raw, ended)))
emitted++
}
+20 -8
View File
@@ -171,14 +171,9 @@ func selectSourceLines(content []byte, offset, limit int) sourceSelection {
}
func sourceLineCount(content []byte) int {
if len(content) == 0 {
return 1
}
lines := strings.Count(string(content), "\n")
if content[len(content)-1] != '\n' {
lines++
}
return lines
// One implementation for reader and writer: the tracker contract lives on
// trackedLineTotal, and this must never drift from it.
return trackedLineTotal(string(content))
}
// lineCount reports the number of newline-separated lines in s (an empty string
@@ -189,3 +184,20 @@ func lineCount(s string) int {
}
return strings.Count(s, "\n") + 1
}
// trackedLineTotal reports a file's line count the way read_file reports it
// to the FileTracker (newline-terminated lines, plus an unterminated last
// line; an empty file is one line). Writers must record this same number:
// RecordSeenRange resets every observation when the total changes, so a
// writer that recorded lineCount (one higher for a trailing newline) made the
// next partial read_file wipe whole-file knowledge and refuse the next edit.
func trackedLineTotal(s string) int {
if s == "" {
return 1
}
total := strings.Count(s, "\n")
if !strings.HasSuffix(s, "\n") {
total++
}
return total
}
+219 -23
View File
@@ -6,9 +6,11 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/Gitlawb/zero/internal/pathjail"
"github.com/Gitlawb/zero/internal/sandbox"
)
const (
@@ -28,6 +30,9 @@ const (
structuredPatchAdd structuredPatchKind = iota
structuredPatchDelete
structuredPatchUpdate
// structuredPatchCopy creates movePath from path's content (plus any
// hunks) and keeps path; produced by a unified diff's "copy from/to".
structuredPatchCopy
)
type structuredPatchOperation struct {
@@ -37,8 +42,29 @@ type structuredPatchOperation struct {
contents string
chunks []structuredPatchChunk
line int
// eofNewline lets a unified diff's "\ No newline at end of file" marker
// force the trailing-newline state of the result; structured patches keep
// the file's existing state.
eofNewline eofNewlineMode
// verifyDelete marks a deletion that carries the expected old content
// (a unified diff's "+++ /dev/null" hunks, or git's header-only form for
// an empty file): the removed lines must equal the current file byte for
// byte, otherwise the deletion is stale and refused. A structured
// "*** Delete File" means "delete this path" and has no chunks.
verifyDelete bool
// oldNoNewline records a unified diff's "\ No newline at end of file"
// on the removed side, i.e. the old content did not end with a newline.
oldNoNewline bool
}
type eofNewlineMode uint8
const (
eofNewlineKeep eofNewlineMode = iota
eofNewlinePresent
eofNewlineAbsent
)
type structuredPatchChunk struct {
context string
hasContext bool
@@ -46,6 +72,11 @@ type structuredPatchChunk struct {
new []string
newSourceOffsets []int
endOfFile bool
// hint is the 0-based line the hunk is expected at (from a unified diff's
// "@@ -a,b" range). When the expected lines match there it is used as-is;
// otherwise the hunk is located by content like a structured hunk.
hint int
hasHint bool
}
type structuredPatchTarget struct {
@@ -63,8 +94,36 @@ type structuredPatchChange struct {
mode os.FileMode
}
// unifiedHunkRangePattern recognises a unified-diff range header ("-12,4 +12,6",
// optionally followed by "@@ heading") written inside a structured hunk marker.
// The line numbers carry no information for the context-anchored grammar, so
// the range is dropped and only an explicit heading is kept as the anchor.
var unifiedHunkRangePattern = regexp.MustCompile(`^-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*(?:@@\s*(.*))?$`)
const structuredPatchFormatHint = ` (format: "*** Begin Patch", then "*** Update File: path" / "*** Add File: path" / "*** Delete File: path" sections whose hunks start with "@@" and use " " context, "-" removed and "+" added lines, then "*** End Patch")`
// structuredPatchMarker classifies a line as the "begin" or "end" marker of a
// structured patch, or "" when it is neither. The classifier is owned by the
// sandbox package so the boundary check and the tool accept exactly the same
// spellings; a strict byte-equal match here once made every patch from some
// models fail on line 1 and pushed them into whole-file rewrites.
func structuredPatchMarker(line string) string {
return sandbox.StructuredPatchMarker(line)
}
// structuredHunkAnchor normalises the text after a hunk's "@@ " marker. A
// unified-diff range is dropped (its optional heading survives as the anchor);
// anything else is used verbatim.
func structuredHunkAnchor(context string) string {
context = strings.TrimSpace(context)
if match := unifiedHunkRangePattern.FindStringSubmatch(context); match != nil {
return strings.TrimSpace(match[1])
}
return context
}
func isStructuredPatch(patch string) bool {
return strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), structuredPatchBegin)
return sandbox.IsStructuredPatch(patch)
}
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
@@ -72,6 +131,14 @@ func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch str
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
return applyPatchOperations(applyRoot, relativeRoot, operations, options)
}
// applyPatchOperations applies parsed operations (from either patch format)
// through an opened workspace root: every stat, read, create and write is
// descriptor-relative and refuses to follow a link out of the root, so there
// is no pathname check-to-use window between validation and write.
func applyPatchOperations(applyRoot, relativeRoot string, operations []structuredPatchOperation, options RunOptions) Result {
workspace, err := os.OpenRoot(applyRoot)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
@@ -84,7 +151,7 @@ func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch str
wholeBefore := make(map[string]bool, len(changes))
if options.FileTracker != nil {
for _, change := range changes {
if change.kind == structuredPatchUpdate {
if change.kind == structuredPatchUpdate || change.kind == structuredPatchCopy {
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
}
}
@@ -103,6 +170,10 @@ func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch str
wasWhole := wholeBefore[change.from.absolute]
options.FileTracker.Forget(change.from.absolute)
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
case structuredPatchCopy:
// The source is untouched; the destination inherits only what the
// model had actually seen of the source.
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, wholeBefore[change.from.absolute])
}
}
@@ -122,11 +193,11 @@ func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
return nil, fmt.Errorf("structured patch is empty")
}
lines := strings.Split(normalized, "\n")
if strings.TrimSpace(lines[0]) != structuredPatchBegin {
return nil, fmt.Errorf("the first line of a structured patch must be %q", structuredPatchBegin)
if structuredPatchMarker(lines[0]) != "begin" {
return nil, fmt.Errorf("the first line of a structured patch must be %q%s", structuredPatchBegin, structuredPatchFormatHint)
}
if strings.TrimSpace(lines[len(lines)-1]) != structuredPatchEnd {
return nil, fmt.Errorf("the last line of a structured patch must be %q", structuredPatchEnd)
if structuredPatchMarker(lines[len(lines)-1]) != "end" {
return nil, fmt.Errorf("the last line of a structured patch must be %q%s", structuredPatchEnd, structuredPatchFormatHint)
}
var operations []structuredPatchOperation
@@ -191,8 +262,12 @@ func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
}
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
chunk := structuredPatchChunk{}
rest := ""
if trimmed != "@@" {
chunk.context = strings.TrimPrefix(trimmed, structuredHunkContext)
rest = strings.TrimPrefix(trimmed, structuredHunkContext)
}
if anchor := structuredHunkAnchor(rest); anchor != "" {
chunk.context = anchor
chunk.hasContext = true
}
op.chunks = append(op.chunks, chunk)
@@ -248,7 +323,7 @@ func structuredPatchPath(header, prefix string, line int) (string, error) {
}
func isStructuredPatchHeader(line string) bool {
return line == structuredPatchEnd || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
return structuredPatchMarker(line) == "end" || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
}
func structuredPatchLineError(line int, message string) error {
@@ -318,7 +393,7 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t
return nil, err
}
change.after = operation.contents
case structuredPatchDelete, structuredPatchUpdate:
case structuredPatchDelete, structuredPatchUpdate, structuredPatchCopy:
info, err := root.Stat(from.relative)
if err != nil {
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
@@ -332,12 +407,20 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
}
change.before = string(content)
if operation.kind == structuredPatchUpdate {
if operation.kind == structuredPatchDelete && operation.verifyDelete {
if err := verifyUnifiedDeletion(change.before, from.relative, operation); err != nil {
return nil, err
}
}
if operation.kind == structuredPatchUpdate || operation.kind == structuredPatchCopy {
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
if err != nil {
return nil, err
}
change.after = updated
change.after = applyEOFNewline(updated, operation.eofNewline)
if operation.kind == structuredPatchCopy && from.absolute == to.absolute {
return nil, fmt.Errorf("cannot copy %s onto itself", from.relative)
}
if from.absolute != to.absolute {
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
@@ -353,10 +436,14 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t
}
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
// Relative traversal is rejected outright. An absolute path is allowed when
// it resolves inside the apply root (models routinely echo the absolute path
// they were shown by read_file); resolveWorkspaceTargetPath rejects anything
// that lands outside.
if path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
}
absolute, relative, err := resolveWorkspaceTargetPath(root, path)
absolute, relative, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path))
if err != nil {
return structuredPatchTarget{}, err
}
@@ -389,9 +476,15 @@ func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchCh
if len(chunk.old) == 0 {
// A context anchor identifies the line immediately before a pure
// insertion. Only an explicit end-of-file hunk (or an unanchored
// insertion) belongs at the file end.
// insertion) belongs at the file end. A unified diff names the
// insertion point in its range instead; a zero-context hunk (as
// from `git diff -U0`) has nothing to verify the position against,
// so it is position-trusting by design — the same semantics git
// apply uses without fuzz. Hunks that carry context are verified.
start := lineIndex
if chunk.endOfFile || !chunk.hasContext {
if chunk.hasHint {
start = max(chunk.hint, lineIndex)
} else if chunk.endOfFile || !chunk.hasContext {
start = len(lines)
}
if start > len(lines) {
@@ -405,7 +498,13 @@ func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchCh
lineIndex = start
continue
}
index, ambiguous := findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
var index int
ambiguous := false
if chunk.hasHint && chunk.hint >= lineIndex && sequenceMatchesAt(lines, chunk.old, chunk.hint) {
index = chunk.hint
} else {
index, ambiguous = findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
}
if ambiguous {
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
}
@@ -440,6 +539,64 @@ func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchCh
return updated, nil
}
// verifyUnifiedDeletion checks, byte for byte, that the lines a unified
// deletion removes are exactly the file's current content. A deletion is not
// recoverable, so none of the whitespace tolerance used to locate update
// hunks applies here; only the file's own line-ending style is normalised.
// With no hunks (git's header-only form) the file must be empty.
func verifyUnifiedDeletion(current, path string, operation structuredPatchOperation) error {
var removed []string
for _, chunk := range operation.chunks {
if len(chunk.new) > 0 {
return fmt.Errorf("deletion of %s must not add lines", path)
}
removed = append(removed, chunk.old...)
}
expected := strings.Join(removed, "\n")
if len(removed) > 0 && !operation.oldNoNewline {
expected += "\n"
}
actual := current
if structuredPatchLineEnding(current) == "\r\n" {
actual = strings.ReplaceAll(current, "\r\n", "\n")
}
if actual != expected {
if len(removed) == 0 {
return fmt.Errorf("deletion of %s expects an empty file but it has content; include the removed lines in the patch", path)
}
return fmt.Errorf("deletion of %s does not match its current content; the removed lines must equal the whole file", path)
}
return nil
}
// sequenceMatchesAt reports whether wanted appears verbatim at lines[start:].
func sequenceMatchesAt(lines, wanted []string, start int) bool {
if start < 0 || start+len(wanted) > len(lines) {
return false
}
for offset, line := range wanted {
if lines[start+offset] != line {
return false
}
}
return true
}
// applyEOFNewline forces or strips a single trailing line ending when a
// unified diff's end-of-file marker asked for it.
func applyEOFNewline(content string, mode eofNewlineMode) string {
switch mode {
case eofNewlinePresent:
if content != "" && !strings.HasSuffix(content, "\n") {
return content + structuredPatchLineEnding(content)
}
case eofNewlineAbsent:
content = strings.TrimSuffix(content, "\n")
content = strings.TrimSuffix(content, "\r")
}
return content
}
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
if len(chunk.newSourceOffsets) != len(chunk.new) {
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
@@ -516,14 +673,19 @@ func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bo
}
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
mutated := false
// committed lists, in order, the paths whose change reached disk before a
// later change failed, so the caller (and the model) knows exactly which
// files now hold the patched content and which were never touched.
var committed []string
for _, change := range changes {
committed, err := applyStructuredPatchChange(root, change)
mutated = mutated || committed
done, err := applyStructuredPatchChange(root, change)
if done {
committed = append(committed, structuredPatchChangePaths(change)...)
}
if err != nil {
forgetStructuredPatchFiles(tracker, changes)
if mutated {
return fmt.Errorf("%w; structured patch was partially applied; re-read affected files before retrying", err)
if len(committed) > 0 {
return fmt.Errorf("%w; patch was partially applied — already committed: %s; the remaining files are unchanged; re-read the committed files before retrying", err, strings.Join(committed, ", "))
}
return err
}
@@ -531,6 +693,19 @@ func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange,
return nil
}
// structuredPatchChangePaths names the workspace-relative paths a committed
// change touched: the destination, plus the source of a move or copy.
func structuredPatchChangePaths(change structuredPatchChange) []string {
if change.kind == structuredPatchDelete {
return []string{change.from.relative}
}
paths := []string{change.to.relative}
if change.from.absolute != change.to.absolute && change.from.relative != "" {
paths = append([]string{change.from.relative}, paths...)
}
return paths
}
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
if tracker == nil {
return
@@ -543,14 +718,35 @@ func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchC
}
}
// structuredPatchBeforeCommit, when set, runs before each change's pre-commit
// recheck. Tests use it to alter the filesystem between planning and commit
// deterministically; it is nil in production.
var structuredPatchBeforeCommit func(change structuredPatchChange)
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
if structuredPatchBeforeCommit != nil {
structuredPatchBeforeCommit(change)
}
// The planner validated the source against the bytes it read; re-read
// through the same root immediately before committing so a change made
// by another process in between is refused rather than overwritten or
// removed.
if change.kind != structuredPatchAdd {
current, err := root.ReadFile(change.from.relative)
if err != nil {
return false, fmt.Errorf("re-reading %s before commit: %w", change.from.relative, err)
}
if string(current) != change.before {
return false, fmt.Errorf("%s changed on disk between planning and commit; re-read it and retry", change.from.relative)
}
}
switch change.kind {
case structuredPatchDelete:
if err := root.Remove(change.from.relative); err != nil {
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
}
return true, nil
case structuredPatchAdd:
case structuredPatchAdd, structuredPatchCopy:
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
case structuredPatchUpdate:
moving := change.from.absolute != change.to.absolute
@@ -706,7 +902,7 @@ func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, s
info, _ := os.Stat(absolute)
tracker.Record(absolute, content, info)
if seenWhole {
lines := lineCount(string(content))
lines := trackedLineTotal(string(content))
tracker.RecordSeenRange(absolute, 1, lines, lines)
}
if created {
+59
View File
@@ -0,0 +1,59 @@
package tools
import (
"context"
"fmt"
"path/filepath"
"testing"
)
func TestTrackedLineTotalMatchesReadFileStats(t *testing.T) {
root := t.TempDir()
for name, content := range map[string]string{"trailing": "a\nb\nc\n", "unterminated": "a\nb\nc", "single": "x\n", "empty": "", "crlf": "a\r\nb\r\n"} {
path := filepath.Join(root, name+".txt")
writeTestFile(t, path, content)
stats, err := scanReadFileStats(path)
if err != nil {
t.Fatal(err)
}
if got := trackedLineTotal(content); got != stats.lines {
t.Fatalf("%s: trackedLineTotal = %d, read_file reports %d", name, got, stats.lines)
}
}
}
// A whole-file read, an edit, then a partial re-read must keep the file "seen
// whole": the writer and reader now agree on the line total, so the partial
// read accumulates instead of resetting and a later edit far from the re-read
// window is not refused.
func TestEditThenPartialReadKeepsWholeFileObservation(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "notes.txt")
content := ""
for i := 1; i <= 40; i++ {
content += fmt.Sprintf("line %02d\n", i)
}
writeTestFile(t, path, content)
tracker := NewFileTracker()
registry := NewRegistry()
registry.Register(NewScopedReadFileTool(root, nil))
registry.Register(NewScopedEditFileTool(root, nil))
opts := RunOptions{PermissionGranted: true, FileTracker: tracker}
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "notes.txt"}, opts); r.Status != StatusOK {
t.Fatalf("read: %s", r.Output)
}
if r := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{"path": "notes.txt", "old_string": "line 01\nline 02\n", "new_string": "line 01\nline two\n"}, opts); r.Status != StatusOK {
t.Fatalf("first edit: %s", r.Output)
}
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "notes.txt", "limit": 5}, opts); r.Status != StatusOK {
t.Fatalf("partial read: %s", r.Output)
}
absolute, _ := filepath.EvalSymlinks(path)
if !tracker.SeenWhole(absolute) {
t.Fatal("partial re-read of an unchanged file must not discard whole-file knowledge")
}
if r := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{"path": "notes.txt", "old_string": "line 39\nline 40\n", "new_string": "line 39\nline forty\n"}, opts); r.Status != StatusOK {
t.Fatalf("edit far from the re-read window must succeed: %s", r.Output)
}
}
+393
View File
@@ -0,0 +1,393 @@
package tools
import (
"fmt"
"strings"
)
// parseUnifiedPatch converts a unified diff into the same operations a
// structured patch produces, so both formats are applied by Zero itself
// through an opened workspace root (descriptor-relative, no-follow writes)
// instead of handing pathnames to git apply after validation.
//
// Supported: file modifications, creations (--- /dev/null) and deletions
// (+++ /dev/null, verified against the content the hunks expect to remove),
// multiple hunks per file, "\ No newline at end of file"
// markers, a/ and b/ prefixes, git C-quoted paths, CRLF patches, and git's
// "rename from/to" and "copy from/to" headers (with or without hunks). Headers
// git emits around a hunk (diff --git, index, mode lines) are skipped; binary
// patches are rejected.
func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff")
lines := strings.Split(normalized, "\n")
// The element after a final "\n" is the line terminator, not an empty
// context line; counting it would let a truncated hunk look complete.
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
var operations []structuredPatchOperation
var current *structuredPatchOperation
var chunk *structuredPatchChunk
var added []string // collected "+" lines for a file creation
oldPath, newPath := "", ""
pendingFrom, pendingKind := "", structuredPatchUpdate
// git's header-only forms: "deleted file mode" / "new file mode" after a
// "diff --git" line with no ---/+++ pair describe an empty file.
diffPath, headerOnly := "", byte(0) // headerOnly is 'd' (deleted) or 'n' (new)
oldRemaining, newRemaining := 0, 0
inHunk := false
lastSide := byte(0) // '+' or '-' for the most recent content line
finish := func() error {
if current == nil {
return nil
}
switch current.kind {
case structuredPatchAdd:
if len(added) > 0 {
current.contents = strings.Join(added, "\n")
if current.eofNewline != eofNewlineAbsent {
current.contents += "\n"
}
}
case structuredPatchUpdate, structuredPatchCopy:
if !allStructuredPatchChunksHaveContent(current.chunks) {
return fmt.Errorf("unified diff for %s has an empty hunk", current.path)
}
if len(current.chunks) == 0 && current.movePath == "" {
return fmt.Errorf("unified diff for %s has no hunk lines", current.path)
}
case structuredPatchDelete:
if len(current.chunks) == 0 || !allStructuredPatchChunksHaveContent(current.chunks) {
return fmt.Errorf("unified deletion of %s must include the hunk with the content being removed", current.path)
}
}
operations = append(operations, *current)
current, chunk, added = nil, nil, nil
return nil
}
flushHeaderOnly := func(line int) error {
if headerOnly == 0 {
return nil
}
if diffPath == "" {
return fmt.Errorf("invalid unified diff at line %d: file mode header without a diff --git path", line)
}
if err := finish(); err != nil {
return err
}
switch headerOnly {
case 'd':
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: diffPath, line: line, verifyDelete: true})
case 'n':
operations = append(operations, structuredPatchOperation{kind: structuredPatchAdd, path: diffPath, line: line})
}
headerOnly, diffPath = 0, ""
return nil
}
startFile := func(line int) error {
// A ---/+++ pair means the file has hunks; it is not header-only.
headerOnly, diffPath = 0, ""
if oldPath == "" || newPath == "" {
return fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", line)
}
// A ---/+++ pair after a rename/copy header names the same files; keep
// accumulating that operation's hunks instead of starting a new one.
if current != nil && current.movePath != "" && current.path == oldPath && (current.movePath == newPath || oldPath == newPath) {
return nil
}
if err := finish(); err != nil {
return err
}
op := structuredPatchOperation{line: line}
switch {
case oldPath == "/dev/null" && newPath == "/dev/null":
return fmt.Errorf("invalid unified diff at line %d: both sides are /dev/null", line)
case oldPath == "/dev/null":
op.kind, op.path = structuredPatchAdd, newPath
case newPath == "/dev/null":
// A unified deletion states the content it expects to remove; keep
// its hunks so the planner verifies them before deleting.
op.kind, op.path, op.verifyDelete = structuredPatchDelete, oldPath, true
default:
op.kind, op.path = structuredPatchUpdate, oldPath
if newPath != oldPath {
op.movePath = newPath
}
}
current = &op
return nil
}
for index, raw := range lines {
lineNumber := index + 1
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
// A "--- " line followed by "+++ " and then a "@@" hunk header is
// the next file's header pair, not a removed "-- x" line followed by
// an added "++ y" line: an over-declared count would otherwise
// swallow both and the hunk would later fail against the wrong
// file. The "@@" requirement keeps a genuine adjacent pair intact.
if strings.HasPrefix(raw, "--- ") && index+2 < len(lines) && strings.HasPrefix(lines[index+1], "+++ ") && strings.HasPrefix(lines[index+2], "@@") {
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
}
switch {
case strings.HasPrefix(raw, "\\"):
// "\ No newline at end of file" qualifies the previous line's side.
if current != nil && lastSide == '+' {
current.eofNewline = eofNewlineAbsent
} else if current != nil && lastSide == '-' {
current.oldNoNewline = true
if current.eofNewline == eofNewlineKeep {
current.eofNewline = eofNewlinePresent
}
}
continue
case strings.HasPrefix(raw, "-"):
oldRemaining--
lastSide = '-'
if chunk != nil {
chunk.old = append(chunk.old, raw[1:])
}
case strings.HasPrefix(raw, "+"):
newRemaining--
lastSide = '+'
if current != nil && current.kind == structuredPatchAdd {
added = append(added, raw[1:])
} else if chunk != nil {
chunk.new = append(chunk.new, raw[1:])
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
}
default:
content := raw
if strings.HasPrefix(raw, " ") {
content = raw[1:]
} else if raw != "" {
return nil, fmt.Errorf("invalid unified diff at line %d: hunk lines must start with ' ', '+', '-' or '\\'", lineNumber)
}
oldRemaining--
newRemaining--
lastSide = ' '
if chunk != nil {
offset := len(chunk.old)
chunk.old = append(chunk.old, content)
chunk.new = append(chunk.new, content)
chunk.newSourceOffsets = append(chunk.newSourceOffsets, offset)
}
}
continue
}
inHunk = false
trimmed := strings.TrimSpace(raw)
if oldRemaining > 0 || newRemaining > 0 {
// The hunk ended before its declared counts were consumed. Report
// it here rather than absorbing the next file's ---/+++ headers as
// "-"/"+" content and failing later against the wrong file.
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
}
switch {
case trimmed == "":
continue
case strings.HasPrefix(raw, "\\"):
// "\ No newline at end of file" directly after a hunk's last line.
if current != nil && lastSide == '+' {
current.eofNewline = eofNewlineAbsent
} else if current != nil && lastSide == '-' {
current.oldNoNewline = true
if current.eofNewline == eofNewlineKeep {
current.eofNewline = eofNewlinePresent
}
}
continue
case strings.HasPrefix(raw, "diff --git "):
if err := flushHeaderOnly(lineNumber); err != nil {
return nil, err
}
diffPath = diffGitNewPath(raw)
continue
case strings.HasPrefix(raw, "deleted file mode "):
headerOnly = 'd'
continue
case strings.HasPrefix(raw, "new file mode "):
headerOnly = 'n'
continue
case strings.HasPrefix(raw, "index "), strings.HasPrefix(raw, "old mode "), strings.HasPrefix(raw, "new mode "):
continue
case strings.HasPrefix(raw, "similarity index "), strings.HasPrefix(raw, "dissimilarity index "):
continue
case strings.HasPrefix(raw, "rename from "), strings.HasPrefix(raw, "copy from "):
pendingFrom = strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename from "), "copy from ")))
pendingKind = structuredPatchUpdate
if strings.HasPrefix(raw, "copy from ") {
pendingKind = structuredPatchCopy
}
if pendingFrom == "" {
return nil, fmt.Errorf("invalid unified diff at line %d: missing source path", lineNumber)
}
case strings.HasPrefix(raw, "rename to "), strings.HasPrefix(raw, "copy to "):
to := strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename to "), "copy to ")))
if pendingFrom == "" || to == "" {
return nil, fmt.Errorf("invalid unified diff at line %d: rename/copy destination without a source", lineNumber)
}
if err := finish(); err != nil {
return nil, err
}
current = &structuredPatchOperation{kind: pendingKind, path: pendingFrom, movePath: to, line: lineNumber}
oldPath, newPath, pendingFrom = pendingFrom, to, ""
case strings.HasPrefix(raw, "Binary files "), strings.HasPrefix(raw, "GIT binary patch"):
return nil, fmt.Errorf("invalid unified diff at line %d: binary patches are not supported", lineNumber)
case strings.HasPrefix(raw, "--- "):
oldPath = stripPatchPrefix(patchFileHeaderPath(raw))
newPath = ""
if oldPath == "" {
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in --- header", lineNumber)
}
case strings.HasPrefix(raw, "+++ "):
newPath = stripPatchPrefix(patchFileHeaderPath(raw))
if newPath == "" {
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in +++ header", lineNumber)
}
if err := startFile(lineNumber); err != nil {
return nil, err
}
case strings.HasPrefix(raw, "@@"):
if current == nil {
return nil, fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", lineNumber)
}
oldStart, oldCount, newCount, ok := parseHunkRange(raw)
if !ok {
return nil, fmt.Errorf("invalid unified diff at line %d: malformed hunk header", lineNumber)
}
// "\ No newline at end of file" describes the end of the file, so it
// may only follow a file's final hunk; a marker before another hunk
// would otherwise leak into that later hunk's result.
if current.eofNewline != eofNewlineKeep {
return nil, fmt.Errorf("invalid unified diff at line %d: \"\\ No newline at end of file\" must follow the last hunk of a file", lineNumber)
}
oldRemaining, newRemaining = oldCount, newCount
inHunk = oldRemaining > 0 || newRemaining > 0
lastSide = 0
if current.kind != structuredPatchAdd {
next := structuredPatchChunk{hasHint: true, hint: oldStart - 1}
if oldCount == 0 {
// A pure insertion's range names the line after which the
// new lines go, so the insertion index is oldStart itself.
next.hint = oldStart
}
if next.hint < 0 {
next.hint = 0
}
current.chunks = append(current.chunks, next)
chunk = &current.chunks[len(current.chunks)-1]
}
default:
return nil, fmt.Errorf("invalid unified diff at line %d: unexpected %q", lineNumber, trimmed)
}
}
// Strict at end of input on purpose: a hunk cut short (typically a model
// response that stopped mid-patch) would otherwise apply as a partial
// change — removals without their replacements — which is worse than a
// clear error and a retry. Miscounted ranges with complete content are
// still tolerated at every other boundary.
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", len(lines))
}
if err := flushHeaderOnly(len(lines)); err != nil {
return nil, err
}
if err := finish(); err != nil {
return nil, err
}
if len(operations) == 0 {
return nil, fmt.Errorf("unified diff contains no file changes")
}
return operations, nil
}
// diffGitNewPath returns the post-image path named by a "diff --git a/X b/Y"
// line, handling git's C-quoted form; "" when it cannot be determined.
func diffGitNewPath(line string) string {
rest := strings.TrimSpace(strings.TrimPrefix(line, "diff --git "))
if strings.HasPrefix(rest, "\"") {
// Two quoted tokens: skip the first, unquote the second.
end := strings.Index(rest[1:], "\"")
for end > 0 && rest[end] == '\\' {
next := strings.Index(rest[end+2:], "\"")
if next < 0 {
return ""
}
end += next + 2
}
if end < 0 || end+2 > len(rest) {
return ""
}
rest = strings.TrimSpace(rest[end+2:])
return stripPatchPrefix(unquoteGitPath(rest))
}
fields := strings.Fields(rest)
if len(fields) < 2 {
return ""
}
last := fields[len(fields)-1]
if strings.HasPrefix(last, "\"") {
return stripPatchPrefix(unquoteGitPath(last))
}
return stripPatchPrefix(last)
}
// parseHunkRange reads "@@ -a[,b] +c[,d] @@" and returns a, b and d; a missing
// count means 1 per unified-diff convention.
func parseHunkRange(line string) (oldStart, oldCount, newCount int, ok bool) {
_, rest, found := strings.Cut(line, "@@")
if !found {
return 0, 0, 0, false
}
rangeSection := rest
if before, _, found := strings.Cut(rest, "@@"); found {
rangeSection = before
}
fields := strings.Fields(rangeSection)
if len(fields) != 2 || !strings.HasPrefix(fields[0], "-") || !strings.HasPrefix(fields[1], "+") {
return 0, 0, 0, false
}
parse := func(spec string) (int, int, bool) {
startText, countText, hasCount := strings.Cut(spec, ",")
start, err := parseNonNegativeInt(startText)
if err != nil {
return 0, 0, false
}
count := 1
if hasCount {
if count, err = parseNonNegativeInt(countText); err != nil {
return 0, 0, false
}
}
return start, count, true
}
oldStart, oldCount, ok = parse(fields[0][1:])
if !ok {
return 0, 0, 0, false
}
_, newCount, ok = parse(fields[1][1:])
if !ok {
return 0, 0, 0, false
}
return oldStart, oldCount, newCount, true
}
func parseNonNegativeInt(text string) (int, error) {
if text == "" {
return 0, fmt.Errorf("empty number")
}
value := 0
for _, r := range text {
if r < '0' || r > '9' {
return 0, fmt.Errorf("not a number: %q", text)
}
value = value*10 + int(r-'0')
if value > 1<<30 {
return 0, fmt.Errorf("number too large: %q", text)
}
}
return value, nil
}
+1 -1
View File
@@ -120,7 +120,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
if content == modelKnownContent {
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(content), lineCount(content))
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(content), trackedLineTotal(content))
}
if !existed {
options.FileTracker.RecordCreated(absolutePath)
+20 -5
View File
@@ -866,7 +866,9 @@ func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
}
}
func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
// A move whose source vanishes after planning is refused by the pre-commit
// recheck before the destination is published, so nothing is left half-done.
func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
@@ -875,22 +877,35 @@ func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
defer workspace.Close()
sourcePath := filepath.Join(root, "source.txt")
destinationPath := filepath.Join(root, "destination.txt")
writeTestFile(t, sourcePath, "removed source\n")
change := structuredPatchChange{
kind: structuredPatchUpdate,
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
before: "removed source\n", after: "moved content\n", mode: 0o644,
}
// The source exists at planning time and disappears just before commit.
removed := false
structuredPatchBeforeCommit = func(structuredPatchChange) {
if err := os.Remove(sourcePath); err != nil {
t.Fatal(err)
}
removed = true
}
defer func() { structuredPatchBeforeCommit = nil }()
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want partial-application error", err)
if !removed {
t.Fatal("pre-commit hook did not run")
}
if err == nil || !strings.Contains(err.Error(), "before commit") || strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want a pre-commit refusal with nothing applied", err)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("failed move recreated a missing source: %v", statErr)
}
if got := mustReadTestFile(t, destinationPath); got != "moved content\n" {
t.Fatalf("published move destination = %q", got)
if _, statErr := os.Stat(destinationPath); !os.IsNotExist(statErr) {
t.Fatalf("refused move must not publish the destination: %v", statErr)
}
}
+15
View File
@@ -30,6 +30,7 @@ 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"
@@ -541,6 +542,7 @@ type model struct {
recapIdleArmed bool
recapIdleRunID int
idleRecap string
compactionModel string // preferences.compactionModel (see providers.CompactionModelID)
modelPickerLoading bool
modelPickerLoadingProviderID string
modelPickerLoadError string
@@ -963,6 +965,7 @@ 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,
@@ -5422,6 +5425,18 @@ 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,
+1 -1
View File
@@ -2439,7 +2439,7 @@ func TestAssistantNarrationBeforeToolCardGetsBlankSeparator(t *testing.T) {
m.transcript = append(m.transcript,
transcriptRow{kind: rowUser, text: "run it"},
transcriptRow{kind: rowAssistant, text: "I'll inspect the existing file, then run it."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n 1 | print('x')"},
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n1→print('x')"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
toolIdx := -1
+16 -12
View File
@@ -22,18 +22,22 @@ 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
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
Provider zeroruntime.Provider
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
+3 -3
View File
@@ -1075,8 +1075,8 @@ func TestEditedDiffCardHandlesBareBlankHunkContext(t *testing.T) {
func TestReadCardBodyShowsExploredSummary(t *testing.T) {
m := limeTestModel()
// Mirrors the real read_file output shape: "<right-aligned N> | <text>".
detail := "File: internal/agent/loop.go\n\n 12 | func Run() {\n 13 | }\n"
// Mirrors the real read_file output shape: "N→<text>".
detail := "File: internal/agent/loop.go\n\n12→func Run() {\n13→}\n"
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "read_file", status: tools.StatusOK, detail: detail}
rc := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "read_file", detail: "internal/agent/loop.go"}})
got := plainRender(t, m.renderRow(row, 80, rc))
@@ -1097,7 +1097,7 @@ func TestReadCardBodyShowsExploredSummary(t *testing.T) {
row.expanded = true
expanded := plainRender(t, m.renderRow(row, 80, rc))
for _, want := range []string{"Explored", "└ Read", "internal/agent/loop.go", "func Run()", "13 |"} {
for _, want := range []string{"Explored", "└ Read", "internal/agent/loop.go", "func Run()", "13→"} {
if !strings.Contains(expanded, want) {
t.Fatalf("expanded read card = %q, missing %q", expanded, want)
}
+1 -1
View File
@@ -41,7 +41,7 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
{
name: "read_file",
hint: "README.md",
detail: "File: README.md\n\n 7 | # Zero",
detail: "File: README.md\n\n7→# Zero",
want: []string{"Read", "README.md"},
},
{
+13 -1
View File
@@ -66,7 +66,9 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
executablePath = resolved
}
method := DetectInstallMethod(executablePath)
if method != InstallMethodNpm {
// Only the standalone path rewrites the binary in place, so only it needs the
// recovery state. npm and Homebrew hand the work to the package manager.
if method == InstallMethodStandalone {
if err := preflightRecoveryState(executablePath); err != nil {
return ApplyResult{}, err
}
@@ -76,6 +78,16 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
}
switch method {
case InstallMethodHomebrew:
// Refused rather than performed. Running `brew upgrade` for the user would
// touch a package manager they own, and writing the binary directly is
// worse: the keg is what Homebrew's own records describe, so a self-update
// leaves brew reporting a version that is no longer installed, and the next
// `brew upgrade` or `brew reinstall` silently reverts it.
return ApplyResult{}, fmt.Errorf(
"this zero was installed with Homebrew (%s); run `brew upgrade zero` instead, so Homebrew's records match the binary on disk",
executablePath,
)
case InstallMethodNpm:
if err := applyNpmUpdate(ctx); err != nil {
return ApplyResult{}, err
+91 -2
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
)
// npmPackageName is the published package name for the npm distribution of
@@ -18,12 +20,38 @@ type InstallMethod string
const (
InstallMethodNpm InstallMethod = "npm"
InstallMethodHomebrew InstallMethod = "homebrew"
InstallMethodStandalone InstallMethod = "standalone"
)
// DetectInstallMethod inspects the directory containing executablePath for
// npm-install markers left by scripts/postinstall.mjs.
// homebrewCellar is the directory every Homebrew keg lives under:
// <prefix>/Cellar/<formula>/<version>/bin/<binary>.
//
// Matching the Cellar segment rather than the Homebrew PREFIX is deliberate.
// The prefix on Intel macOS is /usr/local, which is also where people put
// hand-installed binaries, so treating the prefix as the signal would classify
// an ordinary /usr/local/bin/zero as Homebrew-managed and refuse an update that
// works fine. Every keg is under Cellar and nothing else is, so this errs
// toward leaving self-update enabled, which is the recoverable direction.
//
// HOMEBREW_CELLAR needs no separate check: it defaults to <prefix>/Cellar and
// Homebrew does not support renaming it.
const homebrewCellar = "Cellar"
// DetectInstallMethod reports how the binary at executablePath was installed.
//
// Symlinks are resolved here rather than at the call sites. Homebrew links
// <prefix>/bin/zero to the keg, and Check did not resolve while Apply did, so
// the two disagreed about what a Homebrew install was: the check printed
// standalone guidance for an install the apply path would have handled
// differently.
func DetectInstallMethod(executablePath string) InstallMethod {
if resolved, err := filepath.EvalSymlinks(executablePath); err == nil {
executablePath = resolved
}
if isHomebrewPath(runtime.GOOS, executablePath) {
return InstallMethodHomebrew
}
dir := filepath.Dir(executablePath)
if _, err := os.Stat(filepath.Join(dir, ".zero-binary-version")); err == nil {
return InstallMethodNpm
@@ -50,3 +78,64 @@ func DetectInstallMethod(executablePath string) InstallMethod {
}
return InstallMethodStandalone
}
// isHomebrewPath reports whether executablePath is inside a Homebrew keg.
//
// goos is a parameter rather than runtime.GOOS so the decision can be tested on
// every target from any machine. Gating it on the real GOOS made the one test
// that matters skip on Windows, which is how a platform rule ends up unverified
// on the platform it excludes.
//
// TWO CHECKS, CHEAP ONE FIRST. The path shape is a filter, not the answer: a
// user's own /work/Cellar/tools/bin/zero has exactly the shape a keg does, and
// classifying it as Homebrew would refuse an update that works. The keg is only
// confirmed by Homebrew's own receipt, which it writes into every keg and
// nothing else does.
//
// The receipt costs one Stat, and only on a path that already looks like a keg —
// so the version check that runs for ordinary installs still does no filesystem
// work here at all.
func isHomebrewPath(goos string, executablePath string) bool {
keg, ok := homebrewKeg(goos, executablePath)
if !ok {
return false
}
return hasHomebrewReceipt(keg)
}
// homebrewKeg returns the <prefix>/Cellar/<formula>/<version> directory that
// executablePath sits inside, if its shape is a keg's.
//
// Deliberately not matched: the formula name, because a tap may name it
// something other than zero.
func homebrewKeg(goos string, executablePath string) (string, bool) {
if goos == "windows" {
// Homebrew does not run here, and a Windows path is far likelier to hold
// an unrelated directory called Cellar than a keg.
return "", false
}
// <prefix>/Cellar/<formula>/<version>/bin/<binary> — the two segments
// Homebrew always inserts, then the bin directory and the binary itself.
// Anything shorter cannot be a keg however it is named.
segments := strings.Split(filepath.ToSlash(executablePath), "/")
for index, segment := range segments {
if segment != homebrewCellar {
continue
}
if len(segments)-index < 5 {
continue
}
return strings.Join(segments[:index+3], "/"), true
}
return "", false
}
// homebrewReceipt is the file Homebrew writes into every keg it installs. Its
// presence is what distinguishes a keg from a directory tree that merely looks
// like one.
const homebrewReceipt = "INSTALL_RECEIPT.json"
func hasHomebrewReceipt(kegDir string) bool {
info, err := os.Stat(filepath.Join(kegDir, homebrewReceipt))
return err == nil && !info.IsDir()
}
@@ -0,0 +1,319 @@
package update
import (
"context"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// The path rule itself, checked for every target from any machine. Gating this
// on runtime.GOOS would skip it on Windows, leaving the Windows branch of the
// rule to be verified by nobody.
//
// The keg SHAPE only. Whether a real keg lives there is a separate question with
// a separate answer on disk — see TestOnlyAKegWithAReceiptIsHomebrew, which is
// the check that stops a user's own Cellar directory being classified.
func TestHomebrewKegShapePerTarget(t *testing.T) {
cases := []struct {
goos string
path string
want bool
}{
{"darwin", "/opt/homebrew/Cellar/zero/0.7.1/bin/zero", true},
{"darwin", "/usr/local/Cellar/zero/0.7.1/bin/zero", true},
{"linux", "/home/linuxbrew/.linuxbrew/Cellar/zero/0.7.1/bin/zero", true},
{"darwin", "/usr/local/bin/zero", false},
{"darwin", "/Users/someone/.local/bin/zero", false},
{"linux", "/opt/cellar/bin/zero", false}, // lowercase is not a keg
{"linux", "/opt/CellarX/bin/zero", false}, // segment must match exactly
{"windows", `C:\Cellar\zero\bin\zero.exe`, false}, // Homebrew does not run here
// User directories called Cellar. Too shallow to be a keg.
{"linux", "/home/someone/Cellar/zero", false},
{"darwin", "/Users/someone/Cellar/bin/zero", false},
// The case the reviewer of #910 raised. It is one segment short of a keg,
// so the depth rule alone already refuses it.
{"linux", "/work/Cellar/tools/bin/zero", false},
// One segment deeper it IS a keg by shape, and still not a keg. Nothing in
// the path can tell the difference — only the receipt can, which is what
// TestOnlyAKegWithAReceiptIsHomebrew covers.
{"linux", "/work/Cellar/tools/1.0/bin/zero", true},
}
for _, testCase := range cases {
if _, got := homebrewKeg(testCase.goos, testCase.path); got != testCase.want {
t.Errorf("homebrewKeg(%q, %q) = %v, want %v", testCase.goos, testCase.path, got, testCase.want)
}
}
}
// THE REGRESSION THE REVIEWER ASKED FOR, and the reason the receipt check
// exists: a directory tree a user built themselves can have exactly a keg's
// shape. Classifying it as Homebrew refuses a `zero upgrade` that would have
// worked perfectly, on a machine Homebrew has never touched.
//
// Homebrew writes INSTALL_RECEIPT.json into every keg it installs and nothing
// else does, so its presence is what separates the two.
func TestOnlyAKegWithAReceiptIsHomebrew(t *testing.T) {
// An explicit target rather than runtime.GOOS, so the receipt rule is checked
// on every platform including the one it is switched off for. isHomebrewPath
// already takes the target, so the skip bought nothing and cost the ability to
// catch a break anywhere but CI. Two tests broke exactly that way when the
// receipt requirement landed: they build a keg with no receipt, and only
// macOS and Linux ever ran them.
const target = "darwin"
root := t.TempDir()
// Same shape, same depth. The only difference is the receipt.
real := filepath.Join(root, "prefix", "Cellar", "zero", "0.7.1")
fake := filepath.Join(root, "work", "Cellar", "tools", "0.7.1")
for _, keg := range []string{real, fake} {
if err := os.MkdirAll(filepath.Join(keg, "bin"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(keg, "bin", "zero"), []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(real, "INSTALL_RECEIPT.json"), []byte(`{"source":{}}`), 0o644); err != nil {
t.Fatal(err)
}
if !isHomebrewPath(target, filepath.Join(real, "bin", "zero")) {
t.Error("a keg with a receipt was not recognised as Homebrew")
}
if isHomebrewPath(target, filepath.Join(fake, "bin", "zero")) {
t.Error("a user's own Cellar-shaped directory was classified as Homebrew; `zero upgrade` would refuse for nothing")
}
}
// A receipt that is a DIRECTORY is not a receipt. Cheap to get wrong with a
// bare Stat, and the failure would be silent.
func TestADirectoryNamedLikeTheReceiptIsNotOne(t *testing.T) {
// Explicit target, same reason as above: the rule is worth checking wherever
// the tests run, not only where the feature is switched on.
const target = "darwin"
keg := filepath.Join(t.TempDir(), "prefix", "Cellar", "zero", "0.7.1")
if err := os.MkdirAll(filepath.Join(keg, "INSTALL_RECEIPT.json"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(keg, "bin"), 0o755); err != nil {
t.Fatal(err)
}
if isHomebrewPath(target, filepath.Join(keg, "bin", "zero")) {
t.Error("a directory named INSTALL_RECEIPT.json was accepted as a receipt")
}
}
// A Homebrew install is a keg under <prefix>/Cellar with <prefix>/bin/zero
// linked to it. Detection has to survive both spellings, because Check passes
// the unresolved path and Apply passes the resolved one.
func TestDetectInstallMethodRecognisesAHomebrewKeg(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("DetectInstallMethod is GOOS-gated; the rule itself is covered by TestIsHomebrewPathPerTarget")
}
prefix := t.TempDir()
keg := filepath.Join(prefix, "Cellar", "zero", "0.7.1", "bin")
if err := os.MkdirAll(keg, 0o755); err != nil {
t.Fatal(err)
}
// The receipt is what separates a keg from any directory that happens to sit
// under a path containing "Cellar". Without it detection correctly answers
// standalone, which is what this test would otherwise be asserting against.
if err := os.WriteFile(filepath.Join(filepath.Dir(keg), homebrewReceipt), []byte(`{"source":{}}`), 0o644); err != nil {
t.Fatal(err)
}
binary := filepath.Join(keg, "zero")
if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if got := DetectInstallMethod(binary); got != InstallMethodHomebrew {
t.Errorf("keg path: got %q, want %q", got, InstallMethodHomebrew)
}
linkDir := filepath.Join(prefix, "bin")
if err := os.MkdirAll(linkDir, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(linkDir, "zero")
if err := os.Symlink(binary, link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
// The path Check sees. Without symlink resolution this reads as standalone
// and the guidance tells a Homebrew user to run `zero upgrade`.
if got := DetectInstallMethod(link); got != InstallMethodHomebrew {
t.Errorf("linked path: got %q, want %q", got, InstallMethodHomebrew)
}
}
// The failure that matters more than a missed detection: refusing to update an
// install Homebrew has never heard of. /usr/local is a Homebrew prefix on Intel
// macOS and an ordinary install location everywhere.
func TestDetectInstallMethodLeavesOrdinaryInstallsAlone(t *testing.T) {
root := t.TempDir()
for _, dir := range []string{
filepath.Join(root, "usr", "local", "bin"),
filepath.Join(root, "home", "user", ".local", "bin"),
filepath.Join(root, "opt", "homebrewish", "bin"),
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
binary := filepath.Join(dir, "zero")
if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if got := DetectInstallMethod(binary); got != InstallMethodStandalone {
t.Errorf("%s: got %q, want %q", dir, got, InstallMethodStandalone)
}
}
}
// An npm install must not start reading as Homebrew now that a second check
// runs first.
func TestDetectInstallMethodStillRecognisesNpm(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "zero")
if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".zero-binary-version"), []byte("0.7.1"), 0o644); err != nil {
t.Fatal(err)
}
if got := DetectInstallMethod(binary); got != InstallMethodNpm {
t.Errorf("got %q, want %q", got, InstallMethodNpm)
}
}
func TestUpgradeGuidanceSendsHomebrewUsersToBrew(t *testing.T) {
guidance := upgradeGuidance(AssetCheck{}, "", InstallMethodHomebrew)
if !strings.Contains(guidance, "brew upgrade zero") {
t.Errorf("guidance does not name the command that works: %q", guidance)
}
if strings.Contains(guidance, "Run `zero upgrade`") {
t.Errorf("guidance still offers the command that refuses: %q", guidance)
}
}
// A custom source flag must not talk a Homebrew user back into `zero upgrade`.
func TestUpgradeGuidanceIgnoresSourceFlagForHomebrew(t *testing.T) {
guidance := upgradeGuidance(AssetCheck{}, "--source", InstallMethodHomebrew)
if !strings.Contains(guidance, "brew upgrade zero") {
t.Errorf("source flag changed the Homebrew answer: %q", guidance)
}
}
// A CROSS-TARGET check outranks the install method, on purpose.
//
// Homebrew is a property of the binary on THIS machine. When the check was asked
// about a different target, the answer is about that other machine, and
// `brew upgrade zero` would change this one instead. Pinned as a test because it
// reads like an ordering bug until you see which question is being answered.
func TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew(t *testing.T) {
local := localReleaseTarget()
other := "linux-arm64"
if local == other {
other = "macos-x64"
}
target, err := ResolveTarget(other)
if err != nil {
t.Fatalf("resolve target: %v", err)
}
asset := AssetCheck{Platform: target.Platform, Arch: target.Arch}
guidance := upgradeGuidance(asset, "", InstallMethodHomebrew)
if strings.Contains(guidance, "brew upgrade zero") {
t.Errorf("a question about %s was answered with a command that changes this machine: %q", other, guidance)
}
if !strings.Contains(guidance, other) {
t.Errorf("cross-target guidance does not name the target asked about: %q", guidance)
}
}
// localReleaseEndpoint builds a data: release whose assets match THIS platform.
//
// Apply runs Check first, and Check fails outright when the release carries no
// archive for the running target. An empty asset list therefore made the
// Homebrew test pass on the error it was looking for and fail on the message,
// which is a fixture that proves nothing about the branch it was written for.
func localReleaseEndpoint(t *testing.T, version string) string {
t.Helper()
target := localReleaseTarget()
if target == "" {
t.Skip("no published release target for this platform")
}
extension := ".tar.gz"
if strings.HasPrefix(target, "windows") {
extension = ".zip"
}
archive := "zero-v" + version + "-" + target + extension
payload := url.QueryEscape(`{"tag_name":"v` + version + `","html_url":"https://example.test/release","assets":[` +
`{"name":"` + archive + `","browser_download_url":"https://example.test/` + archive + `"},` +
`{"name":"` + archive + `.sha256","browser_download_url":"https://example.test/` + archive + `.sha256"}]}`)
return "data:application/json," + payload
}
// Guards the helper above on EVERY platform, including the ones where the
// Homebrew test that uses it has to skip. The fixture broke on macOS while
// passing everywhere it was not exercised, so the fixture gets its own check.
func TestLocalReleaseEndpointIsAcceptedByCheck(t *testing.T) {
result, err := Check(context.Background(), Options{
CurrentVersion: "0.1.0",
Endpoint: localReleaseEndpoint(t, "0.7.0"),
})
if err != nil {
t.Fatalf("Check rejected the fixture release for this platform: %v", err)
}
if !result.UpdateAvailable {
t.Errorf("fixture release 0.7.0 should be newer than 0.1.0")
}
}
// Apply must refuse a Homebrew keg outright: no download, no write, and an error
// that names the command which does work.
func TestApplyRefusesToUpdateAHomebrewKeg(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("DetectInstallMethod is GOOS-gated; see TestIsHomebrewPathPerTarget")
}
prefix := t.TempDir()
keg := filepath.Join(prefix, "Cellar", "zero", "0.7.0", "bin")
if err := os.MkdirAll(keg, 0o755); err != nil {
t.Fatal(err)
}
// Without the receipt this is not a keg, Apply proceeds to the download, and
// the assertion below fails on a network error instead of on the refusal.
// Without the receipt this is not a keg, Apply proceeds to the download, and
// the assertion below fails on a network error instead of on the refusal.
if err := os.WriteFile(filepath.Join(filepath.Dir(keg), homebrewReceipt), []byte(`{"source":{}}`), 0o644); err != nil {
t.Fatal(err)
}
binary := filepath.Join(keg, "zero")
original := []byte("original binary")
if err := os.WriteFile(binary, original, 0o755); err != nil {
t.Fatal(err)
}
restore := currentExecutable
currentExecutable = func() (string, error) { return binary, nil }
t.Cleanup(func() { currentExecutable = restore })
_, err := Apply(context.Background(), Options{
CurrentVersion: "0.1.0",
Endpoint: localReleaseEndpoint(t, "0.7.0"),
})
if err == nil {
t.Fatal("Apply updated a Homebrew keg instead of refusing")
}
if !strings.Contains(err.Error(), "brew upgrade zero") {
t.Errorf("refusal does not name the command that works: %v", err)
}
after, readErr := os.ReadFile(binary)
if readErr != nil {
t.Fatalf("read binary: %v", readErr)
}
if string(after) != string(original) {
t.Error("Apply rewrote the keg binary it claimed to refuse")
}
}
+11
View File
@@ -311,6 +311,17 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM
}
return guidance + " `zero upgrade` installs onto this machine (" + local + ") instead."
}
if installMethod == InstallMethodHomebrew {
// Said before the source-flag branch below: whatever source the check read,
// the answer for a keg is the same and `zero upgrade` is never it.
//
// AFTER the cross-target branch above, which is not an ordering bug and is
// pinned by TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew. Homebrew
// is a property of the binary on THIS machine; when the check was asked
// about a different target the answer belongs to that other machine, and
// `brew upgrade zero` would change this one instead.
return "This Homebrew-managed installation is updated with `brew upgrade zero`. `zero upgrade` refuses here, because replacing the keg binary would leave Homebrew's records describing a version that is no longer installed."
}
if sourceFlag != "" {
if installMethod == InstallMethodNpm {
return "This npm-managed installation can be updated with `npm install -g " + npmPackageName + "@latest`, which installs the official npm package. The custom `" + sourceFlag + "` source only affects the release check and update gating, not the npm install source."
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@gitlawb/zero",
"version": "0.7.0",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gitlawb/zero",
"version": "0.7.0",
"version": "0.8.0",
"cpu": [
"x64",
"arm64"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitlawb/zero",
"version": "0.7.0",
"version": "0.8.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",
+65
View File
@@ -0,0 +1,65 @@
# Homebrew formula for Zero.
#
# NOT PUBLISHED. This is a draft for review. Nothing installs from it until it
# is copied into a tap (Gitlawb/homebrew-tap, Formula/zero.rb) and the release
# workflow is taught to bump the version and checksums on each tag. Both of
# those are release decisions, so they are deliberately not made here.
#
# Verify locally without a tap:
#
# brew install --build-from-source ./packaging/homebrew/zero.rb
# brew audit --strict --formula ./packaging/homebrew/zero.rb
#
# The checksums below are the real ones published with v0.7.0, read from the
# .sha256 files that ship beside each archive, so this draft is installable as
# written rather than being a skeleton with placeholders.
class Zero < Formula
desc "Terminal coding agent"
homepage "https://github.com/Gitlawb/zero"
version "0.7.0"
license "MIT"
on_macos do
on_arm do
url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-macos-arm64.tar.gz"
sha256 "75e859fe25f3f63785f512f20b9c9501c67394166f10746f80e374672a1a8b7f"
end
on_intel do
url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-macos-x64.tar.gz"
sha256 "184256abd5738b77d44cf4a99d71ac32d0a0355714ebc698a2221a69aeb71976"
end
end
on_linux do
on_arm do
url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-linux-arm64.tar.gz"
sha256 "dd0355f78b6ab044e1181184e29432d0ab7652a1dc27a161960f06e8520b4f21"
end
on_intel do
url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-linux-x64.tar.gz"
sha256 "f5120c2cc1e9f45ebf69d472b6026eb8e37eee2c113211efedd7d0917437490c"
end
end
def install
bin.install "zero"
# The Linux archive carries the sandbox helper beside the binary. Without it
# on PATH, native sandboxing is silently unavailable rather than broken, so
# install it whenever the archive provides one.
bin.install "zero-linux-sandbox" if File.exist?("zero-linux-sandbox")
end
def caveats
<<~EOS
Update with `brew upgrade zero`.
`zero upgrade` refuses on a Homebrew install on purpose: replacing the keg
binary would leave Homebrew describing a version that is no longer on disk,
and the next `brew upgrade` would revert it.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/zero --version")
end
end