Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 73d83c469d 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-17 15:01:48 +05:30
Vasanthdev2004 854064face 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.
2026-08-17 14:38:02 +05:30
Vasanthdev2004 de624f8fe8 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.
2026-08-17 14:32:15 +05:30
d065467cd3 feat(zerogit): auto-create a conventional branch before push/pr on default branch (#671)
* feat(zerogit): auto-create a conventional branch before push/pr on default branch

zero changes push/pr refused to push straight to the default branch but
never offered an alternative, unlike other agent tooling that names and
checks out a feature branch automatically. Add CreateBranch, IsDefaultBranch,
CurrentGitUser, and branch-name slugify/build helpers to zerogit, and wire
push/pr to auto-create a "<user>/<slug>" branch (slug from an LLM when a
provider is configured, otherwise a deterministic fallback from the diff)
whenever the current branch is the default one and --yes/--dry-run weren't
passed.

* fix(zerogit): normalize LLM branch slugs, bound remote lookup, handle existing branches

Address CodeRabbit's review on the auto-branch-naming PR:

- generateAutoBranchSlug now takes the first non-empty, quote-trimmed
  line of the model's response before slugifying, instead of folding
  any preamble or wrapping quotes into the branch name.
- isDefaultBranch's ls-remote lookup is now bounded by a 5s timeout so
  a slow or unreachable remote can't stall push/pr; falls back to the
  local main/master heuristic as before.
- CreateBranch checks whether the target branch already exists locally
  and checks it out instead of failing checkout -b on the collision.

Also add regression coverage: CurrentGitUser's OS-username fallback
tier, CreateBranch's existing-branch and generic checkout-failure
paths, a CLI-level test for changes pr (previously untested) mirroring
the existing changes push coverage, and a messy-LLM-response case for
the slug normalization fix.

* fix(zerogit,cli): make auto-branching collision-safe, remote-aware, fail-closed, and opt-in for LLM naming

- CreateBranch never checks out an existing branch on a name collision: it
  picks a unique suffixed name off the current HEAD (name-2 .. name-9) and
  fails visibly when the namespace is exhausted, so a stale branch with
  unrelated history can no longer be published while the new commit stays
  behind on the default branch.
- ensureFeatureBranch resolves the remote once (explicit --remote, then the
  original branch's configured upstream, then origin) via IsDefaultBranch,
  which now reports the resolved remote, and push/pr thread that remote into
  Push, so a freshly created branch without tracking configuration no longer
  falls back to origin in fork setups.
- The default-branch check fails closed: main and master count as default
  with no network, an unreachable remote falls back to the local
  refs/remotes/<remote>/HEAD record, and when neither answers the push is
  refused with guidance instead of silently dropping the confirmation guard
  for repositories whose default is trunk or develop.
- LLM branch naming is now opt-in via --auto on push/pr, matching commit
  --auto: a configured provider alone no longer causes the change diff to be
  uploaded during ordinary git-only pushes.
- After the ordinary commit-then-push sequence the tree is clean, so the
  fallback name comes from the HEAD commit subject instead of the constant
  user/changes.

* style(zerogit): align fake-runner result comments to gofmt output

* test(zerogit): cover Push's fail-closed path when the default branch is unknown

* fix(zerogit): keep a stale cached remote HEAD from clearing the push guard

When the live ls-remote default-branch lookup fails, isDefaultBranch fell
back to the local refs/remotes/<remote>/HEAD cache and returned
branch == cachedName. That cache is only a hint: if the server renamed its
default (main -> trunk) the record still names main, so checking whether
pushing trunk is safe returned false and the newly protected branch was
pushed without --yes.

Trust the cache only to block a push (a positive match proves the branch is
the recorded default); on a mismatch fall through to the existing
fail-closed unknown-default error instead of treating the branch as
unprotected.

* fix(cli): honor --diff-bytes when inspecting the diff for auto-branch naming

changes push/pr accept and document --diff-bytes, but ensureFeatureBranch
inspected the working tree with only Cwd set. With --auto the resulting
unbounded diff was embedded in the branch-name request to the provider, so a
user who capped the diff to limit how much proprietary source is uploaded
for naming still sent the complete diff.

Thread options.maxDiffBytes through ensureFeatureBranch into InspectOptions,
matching how the commit path already bounds the diff.

* fix(cli): refuse an auto-branch push when HEAD has nothing to publish

The auto-branch path decided to branch solely from working-tree status and
never checked that HEAD was ahead of the default branch. On a clean,
up-to-date default branch it would create and push user/<subject> at the
exact default tip; with only uncommitted edits it named a branch from those
edits but pushed the unchanged HEAD, leaving the edits local, and changes pr
then abandoned the empty branch before the host rejected the comparison.

Add zerogit.CommitsAhead and consult it before branching: when HEAD is
provably not ahead of <remote>/<default>, report "no changes to publish"
instead of creating the branch. An indeterminate count (for example a
remote-tracking ref that was never fetched) is treated as "cannot tell" so a
legitimate first push is not blocked.

* fix(cli): recover the branch slug from preamble or fenced LLM replies

The slug helper returned the first non-empty line verbatim, so a reply like
"Here is a suggested branch name:\nadd-login-page" produced
user/here-is-a-suggested-branch-name, and a reply wrapped in a code fence
slugified the ``` line to nothing and silently fell back to the local name.

Drop Markdown code-fence lines and prefer a line that already reads as a
kebab-case slug, falling back to the first quoted/plain line only when no
slug-shaped line exists. Cover preamble and fenced responses with tests.

* fix(zerogit): harden auto-branch remote handling

Three review findings:

- isDefaultBranch passed the remote to ls-remote before the HEAD
  positional with no --, so a remote value shaped like an option
  (--upload-pack=/bin/echo) was parsed as one. Terminate options with --
  and cover a dash-prefixed remote.

- An unborn remote (freshly created, zero refs) answered ls-remote with
  empty output, fell through to the fail-closed unknown-default error,
  and made the very first feature-branch push a --yes dead end that
  git remote set-head --auto cannot repair. ls-remote succeeding with no
  refs now counts as proof there is no protected default; main/master
  stay guarded by the name heuristic and every failure path stays
  fail-closed.

- CreateBranch probed only local refs/heads for collisions, so a branch
  existing only on the target remote (an old merged-PR branch) was
  silently fast-forwarded by the later push -u. The target remote's
  heads are now probed once (bounded, with --) and count as taken;
  an unreachable remote fails visibly before anything is created.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(zerogit): close branch-naming and push safety gaps from review

Require a confirmed unborn HEAD, not just an empty ls-remote --symref,
before granting the first-push exception, since a dangling remote
HEAD looked the same. Resolve branch naming and ahead-count checks
against the remote branch instead of a working-tree snapshot, which
could describe uncommitted edits a commit-only push would never
publish. Guard concurrent branch creation with a force-with-lease
push. Scope the ls-remote timeout to the default-branch preflight
only, so it no longer caps Push's own unrelated check.

* fix(cli): require a clean tree before auto-branch push/pr

Refuse ensureFeatureBranch when the working tree is dirty or the
ahead count against the remote default cannot be determined, so a
default-branch push cannot leave uncommitted edits behind or publish
an empty comparison under a guessed name.

* fix(zerogit): drop fixed five-second remote lookup timeout

IsDefaultBranch and CreateBranch applied a hard 5s bound on ls-remote,
so ordinary feature-branch pushes and the collision probe failed on slow
but reachable remotes (SSH/VPN) before Push could run. Honor the
caller's context instead; callers that need a bound pass a deadline.

* fix(cli): bypass the ahead-count and diff-base checks on a confirmed-unborn remote

jatmn flagged that the confirmed-unborn-remote exception this PR added to
IsDefaultBranch never actually helps the main/master case it was written
for: when the local branch is named main, isDefaultBranch returns true from
the name heuristic before it ever consults the remote, so
ensureFeatureBranch still unconditionally calls commitsAhead against
<remote>/<branch>. On a genuinely empty remote that ref cannot exist, so
`zero changes push`/`pr` from a local default branch against a brand-new
empty remote still dead-ended with "cannot determine whether HEAD is
ahead of origin/main" - the exact bug this PR set out to fix.

Tracing the flow further, the same problem also hit the diff-base Inspect
call right after: it uses the same nonexistent <remote>/<branch> ref, so
bypassing only commitsAhead would have left the dead end one line later.

ensureFeatureBranch now probes the new zerogit.IsUnbornRemote (ls-remote
--heads on the remote) whenever commitsAhead fails, and only bypasses both
checks when that probe positively confirms the remote has no refs at all.
An unreachable remote, or one that answers with refs, still fails closed
exactly as before - the fail-closed "unknown default branch" behavior is
unchanged and covered by TestEnsureFeatureBranchFailsWhenAheadCountUnknown
and the new TestEnsureFeatureBranchFailsWhenUnbornCheckErrors.

TestEnsureFeatureBranchCreatesBranchOnConfirmedUnbornRemote is the
regression test: confirmed failing (with the exact dead-end error) against
the pre-fix logic, passing after.

* fix(zerogit): resolve main/master against the live remote default first

isDefaultBranch short-circuited on the literal branch names main/master
before ever consulting the remote. A repository whose remote default is
genuinely something else (e.g. trunk) could still have a local main branch
tracking that remote, and the shortcut wrongly treated it as protected
instead of trusting the live symref result. The conventional-name check
now only applies as a last-resort fallback, after both the live ls-remote
symref lookup and the local remotes/<remote>/HEAD cache fail to answer.

* fix(cli): preserve push lease on retry and check the live default ref

Two related gaps in ensureFeatureBranch's auto-branch preflight:

- When an auto-branch push loses a force-with-lease race against a
  concurrent creator, the generated branch is left checked out locally
  with no successful push behind it. A retry took the "already off the
  default branch" early return and dropped RequireNewRemoteBranch,
  letting the next push silently fast-forward whatever the concurrent
  creator published. The lease now stays required unless the branch
  already has a configured upstream (proof a push already landed).

- The ahead-of-default check compared HEAD against the local
  remote-tracking ref, which is only refreshed at clone or the last
  fetch. A stale ref can under-report how far ahead HEAD is, or say
  zero when the remote has actually moved past it, so ensureFeatureBranch
  now refreshes that tracking ref before trusting it, and fails closed
  if the refresh itself cannot be confirmed.

Adds regression coverage for both: a retry after a lease collision keeps
requiring the lease (and an already-published branch does not), and a
stale tracking ref is refreshed before the ahead check runs (with a
fail-closed case when that refresh cannot be confirmed).

* fix(cli): track generated branches, reject PR on unborn remote, classify main on unborn

* fix(zerogit,cli): preserve explicit nonexistence lease on force, propagate marker write errors, check unborn remote before branch switch

* fix(zerogit): preserve non-existence lease on target remote and roll back failed branch marking

Compare target remote against configured upstream remote to retain non-existence lease on target, roll back created branch if marking fails, and allow --yes to bypass PR default-branch preflight.

Refs #671

* fix(cli): address review findings for auto feature branch creation

* fix(cli): refine LLM preamble candidate slug extraction and enforce unborn-remote guard

* fix(cli): restore default branch after auto-branch and resolve --yes upstream

Do not leave auto-branch commits on the protected local default branch,
and resolve the real upstream for the unborn-remote guard under --yes
instead of always probing origin.

* test(cli): assert auto-branch passes capped diff into the provider

Thread --auto and a mock provider through DiffBytes coverage so the
cap is checked on the real LLM request path, not only Inspect.

* fix(cli): block auto-branch on unborn remotes and fix lease/restore

Refuse auto-creating a feature branch when the target remote has no refs yet.
Publishing only user/slug leaves remote HEAD dangling, so the next changes pr
fails closed and a later main push has no PR diff. Require push --yes first.

Keep the nonexistence lease unless branch@{upstream} is exactly the target
remote/generated-branch ref from a successful push -u. Inherited origin/main
from branch.autoSetupMerge=inherit must not drop the lease.

Restore the original default branch against its own upstream tip, not the
push destination, so --remote upstream does not rewrite a fork main that
tracks origin/main.

* fix(cli): recover push -u upstream and filter LLM slug preambles

Push -u can publish a remote branch then fail to write local upstream
config. Treat that as incomplete, recover with set-upstream-to, and probe
the remote before reasserting the nonexistence lease on retry. Also skip
one-word LLM acknowledgements before accepting a slug-shaped line.

Refs #671

* fix(cli): decide the branch-collision lease with one live remote check

Replace the local-state inference chain (upstream config, a
zeroAutoBranch marker, per-remote provenance tracking) that kept
producing new collision-safety edge cases across many review rounds
with a single live remoteHasBranch check against the destination
remote: missing there gets the nonexistence lease, already there gets
a plain push, and git's own fast-forward check is the safety net if
that existing branch isn't actually ours.

Removes MarkGeneratedBranch/IsGeneratedBranch, now dead, along with
the tests that pinned the specific local-state heuristics rather than
the collision-safety property itself.

* fix(cli,zerogit): address CodeRabbit findings on auto-branch naming

Recover trailing-punctuation LLM slugs, notice deterministic fallback,
report dual restore/rollback failures, and skip Push upstream repair on
dry-run. Align comments with fail-closed and DryRun behavior.

Refs #671

* fix(tools): set explicit stdin reader and use resolved binary for format-on-write

Use the binary path resolved by exec.LookPath and set formatter.Stdin
to an empty reader so format-on-write does not hang on inherited stdin
handles on Windows.

Refs #671

* fix(cli,zerogit,tools): address CodeRabbit review findings on PR 671

Remove redundant command validation, eliminate duplicate remote lookup in PR
workflow, harden ref options and branch switching, isolate test Git
environment, and add formatter lookup test.

Refs #671

* fix(cli,zerogit): address new CodeRabbit review findings on PR 671

Harden CreateBranch name validation, remove unreachable workflow error check,
complete doc comment, isolate test Git environment, and clean up unused
appDeps field.

Refs #671

* fix(cli,zerogit,tools): address third round of CodeRabbit review findings on PR 671

Use featureBranchOptions struct for ensureFeatureBranch, bound preflight
contexts, add upstream repair mismatch subtest, and centralize test Git
config isolation.

Refs #671

* fix(cli,zerogit): finish ensureFeatureBranch doc contract and close review gaps

The ensureFeatureBranch doc comment ended mid-sentence and sat on the featureBranchOptions type, so go doc attached function prose to the struct. Move it above the function and state which deps are required non-nil (inspectChanges, commitsAhead, headCommitSubject, currentGitUser, createBranch) versus optional and nil-guarded (refreshTrackingRef, isUnbornRemote, branchUpstreamRef, resetBranchRef, deleteBranch). Also extract the branch slug filename with path.Base because git paths are always slash-separated, gate two tests on the git version their fixtures need (autoSetupMerge=inherit requires 2.35, init --bare -b requires 2.28), and cover the literal "user" CurrentGitUser fallback via a test seam.

Refs #671

Co-Authored-By: cairn-code <[email protected]>

* Protect auto-branch publication and default ref restoration

Co-Authored-By: cairn-code <[email protected]>

* fix(cli): wire expected-old restore through the dependency seam

The P1 fix added expectedOld to resetBranchRef but left the appDeps field and the workflows.go import behind, so the CLI package did not compile. Update the field type to the 5-argument signature and restore the filepath import used by worktree release resolution.

Co-Authored-By: cairn-code <[email protected]>

* fix(cli,zerogit): wire CAS tip and close review test gaps

Addresses the CodeRabbit round on 93e3448e: fillAppDeps now defaults currentBranchTip so the compare-and-swap old value actually reaches ResetBranchRef in production; TestEnsureFeatureBranchRestoresDefaultBranchAfterCreate asserts the CAS value; the real-git auto-branch test asserts local main is restored to its upstream tip; docs explain the deliberate context.Background push, CurrentBranchTip semantics, the RemoteHasBranch tip-equality ownership check, and the Git 2.24 requirement for CommitsAhead; CreateBranch gains name-validation subtests.

Co-Authored-By: cairn-code <[email protected]>

* fix(zerogit): make RemoteHasBranch report existence only, not tip equality

RemoteHasBranch's tip-equality check made ensureFeatureBranch reassert the
nonexistence lease (--force-with-lease=<branch>:) on every push after the
first: a second local commit puts HEAD ahead of the just-published remote
tip, so the check reported false for a branch that plainly exists, and Git
rejected the follow-up push with a stale-info error instead of a normal
fast-forward.

The concurrent-creator race the tip check was meant to guard was already
handled one layer up: ensureFeatureBranch drops to a plain push when the
branch exists and relies on Git's own fast-forward check as the safety net
(see TestRunChangesPushDropsLeaseWhenBranchExistsOnRemote), so a race
survivor with unrelated history is rejected there regardless of tip
equality here.

Add TestRemoteHasBranchReportsTrueForDivergedTip, which fails under the old
tip-equality check and passes now.

Refs #671

* fix(cli,zerogit): preserve the generated branch on a compare-and-swap conflict

After auto-branching, ensureFeatureBranch restores the default branch to its
own upstream tip using the compare-and-swap form of update-ref. When that
refused because the default branch had moved concurrently, the failure was
indistinguishable from an ordinary update-ref write error, so the rollback
path ran and deleted the freshly created feature branch. That branch
exclusively owns the user's commits at that point, so a lost race destroyed
work in order to "recover" from it.

Add ErrCompareAndSwapConflict, returned by ResetBranchRef only when an
expected-old value was supplied (the sole way git refuses is a tip mismatch).
ensureFeatureBranch now recognizes it and returns without rolling back,
reporting that the branch was preserved so the user can reconcile the default
branch by hand.

Extend TestResetBranchRefRefusesConcurrentDefaultAdvance, which already drove
a real concurrent advance, to require the sentinel. Add
TestEnsureFeatureBranchPreservesBranchOnCompareAndSwapConflict, which fails
with "must not delete the generated branch" when the new branch is removed.

Refs #671

* Address review feedback for auto-branch workflow.

Refresh the exact configured upstream ref before branching and restoring, guard direct URL and path remote destinations from tracking-ref assertion, and remove undeclared --end-of-options from CommitsAhead.

Refs #671

* fix(zerogit,cli): resolve reviewer feedback on branch restoration and remote handling

Update CommitsAhead and ResetBranchRef to safely terminate option parsing with --, inspect all linked worktrees before ref updates, resolve advertised branches on direct path and URL remotes, and query git configuration directly for tracking remote and merge branch without splitting on slashes.

Refs #671

* fix(zerogit): invoke rev-list without positional delimiter to treat revs correctly

Remove premature option terminator from rev-list command invocation while validating base ref against option-like prefixes.

Refs #671

* fix(cli,zerogit): resolve restore tip commit and enforce push remote unborn policy

Resolve restore tip commits via ResolveRemoteBranchTip for named and direct URL/path upstreams to eliminate bare branch fallback, enforce unborn destination policy on push remotes before branch creation, and extract resolveBranchPushPlan to isolate decision logic from repository mutation.

Refs #671

* fix(cli,zerogit): address review feedback on destination remote and upstream matching

Forward resolved pushRemote to branchPushPlan, update HasUpstream to support multi-segment remote upstream references, clarify IsNamedRemote comment, and extend CLI argument parsing tests.

Refs #671

* docs(cli): complete ensureFeatureBranch documentation and wire branchUpstreamRemoteAndMerge

Refs #671

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-16 16:01:40 +08:00
Vasanth T 0eab63c908 docs(agents): record the normalize-before-match blocker (#900)
Three separate changes hit this in a fortnight, each written by someone
being careful about exactly the thing that got them.

A transform that removes bytes without leaving a gap is also a
reassembler. Redaction that matches by shape, run before a sanitizer that
strips control bytes, lets a credential split by a NUL or an ESC pass the
patterns as two fragments and be rejoined on the way out: the MCP failure
reason in #835, and the imported-transcript chokepoint in #878. The path
form of the same mistake is comparing where a handle landed against a
value produced by the same resolver the kernel just used, so a redirect
agrees with itself: the ACL guard in #808, where junctions were caught
only by an accident of Go's mode bits and directory symlinks were not
caught at all.

The unsplit value passing is what makes it survive review, so the note
says the test needs a split case.
2026-08-15 20:32:33 +08:00
64a783b08c fix(dictation): redact API keys from streaming transcriber errors (#852)
* fix(dictation): redact API keys from streaming transcriber errors

Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into
websocket URLs/headers and echoed unredacted transport errors and
server-side error payloads. Wrap those error endpoints with
providerio.Redact and switch the connect-error format specifier from %w
to %s so the raw error cannot leak via errors.Unwrap().

Adds regression tests asserting the API key is not present in the
returned stream error for both transcribers.

Co-authored-by: euxaristia <[email protected]>

* fixup(dictation): address review: redact session/write errors, move redaction tests

- Redact API key in OpenAI Realtime session configuration error.
- Return redacted OpenAI Realtime write error from writeErrCh instead of discarding.
- Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and
  TestDeepgramStreamTranscribeErrorRedaction next to their source files.

Refs #710

* fixup(dictation): sync Deepgram redaction test on CloseStream before close

Address CodeRabbit nitpick: the server now drains the client's audio frames and
waits for CloseStream before rejecting the connection with an API-key-bearing
policy-violation close reason. This ensures StreamTranscribe observes the close
reason (and redacts it) instead of failing earlier on a write error.

Refs #710

* fix(dictation): preserve context.Canceled through streaming error redaction

Formatting the read error with %s dropped the unwrap chain, so an Esc
abort of a Deepgram or OpenAI Realtime dictation no longer matched
errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious
error toast on top of the cancellation notice. Return the cancellation
sentinel itself before redacting — it carries no key — and keep the
redacted flat string for genuine failures. Adds regression tests that
cancel a live stream and assert the sentinel survives.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(dictation): close remaining cancellation and redaction gaps

Key cancellation detection on ctx.Err() instead of
errors.Is(err, context.Canceled), which was racing against
writer-goroutine transport errors that could overwrite the error
value right before the check. Applied consistently across both
transcriber implementations. Fix goroutine leaks and a possible hang
in the cancellation regression tests.

* test(dictation): cover dial and startup cancel identity

Add regression tests for Esc-abort during WebSocket dial and right after
accept (session update / first write) so those redaction sites keep
returning context.Canceled for the TUI errors.Is check.

* fix(dictation): prefer cancel over a racing OpenAI realtime error

Esc can cancel after conn.Read already has an error frame. Check ctx.Err()
after partial callbacks and on the realtimeError path so the stream still
returns context.Canceled instead of a redacted failure notice.

Refs Gitlawb/zero#710

* fix(dictation): prevent auto-submit on cancelled realtime race

handleDictationTranscribed suppressed context.Canceled but then fell
through to the msg.submit branch. A delta -> Esc -> already-buffered
realtime error race returns a nonempty transcript alongside
context.Canceled, so with stt.autoSubmit on, Esc could submit the
composer's restored pre-existing text instead of doing nothing.

Treat a canceled result as terminal before the auto-submit path is
reached, and add a TUI-level test covering the race.

* test(dictation): exercise writeErrCh cancellation branch in realtime transcriber

* fix(dictation): add custom key header redaction and single-pass redaction tests

* fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test

* fix(dictation): initialize non-zero session ID and filter stale session 0 messages

Ensure the first dictation session in a process gets stale-message protection by initializing sessionID to 1 in newDictationController and removing msg.sessionID != 0 exemptions in handleDictationTranscribed and handleDictationPartial. Clean up unreachable redaction guards in providerio.

Refs #710

* fix(dictation): redact API keys from streaming transcriber errors

Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into
websocket URLs/headers and echoed unredacted transport errors and
server-side error payloads. Wrap those error endpoints with
providerio.Redact and switch the connect-error format specifier from %w
to %s so the raw error cannot leak via errors.Unwrap().

Adds regression tests asserting the API key is not present in the
returned stream error for both transcribers.

Co-authored-by: euxaristia <[email protected]>

* fixup(dictation): address review: redact session/write errors, move redaction tests

- Redact API key in OpenAI Realtime session configuration error.
- Return redacted OpenAI Realtime write error from writeErrCh instead of discarding.
- Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and
  TestDeepgramStreamTranscribeErrorRedaction next to their source files.

Refs #710

* fixup(dictation): sync Deepgram redaction test on CloseStream before close

Address CodeRabbit nitpick: the server now drains the client's audio frames and
waits for CloseStream before rejecting the connection with an API-key-bearing
policy-violation close reason. This ensures StreamTranscribe observes the close
reason (and redacts it) instead of failing earlier on a write error.

Refs #710

* fix(dictation): preserve context.Canceled through streaming error redaction

Formatting the read error with %s dropped the unwrap chain, so an Esc
abort of a Deepgram or OpenAI Realtime dictation no longer matched
errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious
error toast on top of the cancellation notice. Return the cancellation
sentinel itself before redacting — it carries no key — and keep the
redacted flat string for genuine failures. Adds regression tests that
cancel a live stream and assert the sentinel survives.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(dictation): close remaining cancellation and redaction gaps

Key cancellation detection on ctx.Err() instead of
errors.Is(err, context.Canceled), which was racing against
writer-goroutine transport errors that could overwrite the error
value right before the check. Applied consistently across both
transcriber implementations. Fix goroutine leaks and a possible hang
in the cancellation regression tests.

* test(dictation): cover dial and startup cancel identity

Add regression tests for Esc-abort during WebSocket dial and right after
accept (session update / first write) so those redaction sites keep
returning context.Canceled for the TUI errors.Is check.

* fix(dictation): prefer cancel over a racing OpenAI realtime error

Esc can cancel after conn.Read already has an error frame. Check ctx.Err()
after partial callbacks and on the realtimeError path so the stream still
returns context.Canceled instead of a redacted failure notice.

Refs Gitlawb/zero#710

* fix(dictation): prevent auto-submit on cancelled realtime race

handleDictationTranscribed suppressed context.Canceled but then fell
through to the msg.submit branch. A delta -> Esc -> already-buffered
realtime error race returns a nonempty transcript alongside
context.Canceled, so with stt.autoSubmit on, Esc could submit the
composer's restored pre-existing text instead of doing nothing.

Treat a canceled result as terminal before the auto-submit path is
reached, and add a TUI-level test covering the race.

* test(dictation): exercise writeErrCh cancellation branch in realtime transcriber

* fix(dictation): add custom key header redaction and single-pass redaction tests

* fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test

* fix(dictation): initialize non-zero session ID and filter stale session 0 messages

Ensure the first dictation session in a process gets stale-message protection by initializing sessionID to 1 in newDictationController and removing msg.sessionID != 0 exemptions in handleDictationTranscribed and handleDictationPartial. Clean up unreachable redaction guards in providerio.

Refs #710

* fix(dictation): drop providerio widen; pin session and redaction tests

Revert the Token/X-Api-Key providerio.Redact expansion (belongs in its own PR with coverage). Make Deepgram single-pass, cancel-race, and live-session partial tests pin the branches they name, and restore the ST1005 nolint on the user-facing Deepgram error string.

* fix(dictation): ignore stale batch startup by session ID

Gate dictationStartedMsg on session ID so a late Start result after cancel cannot reset a newer recording. Rename and comment cleanups from review; race suite recorded on the PR body.

* fix(dictation): exit writers on cancel and pin dial/write redaction paths

Select on ctx.Done while waiting for audio chunks so cancelled streaming
sessions do not leave writer goroutines blocked on an open idle channel.
Add non-cancellation redaction tests for Deepgram dial failure and OpenAI
Realtime dial, session-configuration, and async writer failures.

Refs #852

* fix(dictation): close the realtime socket on writer failure so a silent server cannot strand the read loop

CodeRabbit review 4885680006 flagged that the OpenAI Realtime writer goroutine pushed write errors to writeErrCh and returned without closing the connection, so conn.Read stays parked forever when the peer goes silent after session setup and the error is never drained. Close the socket on both the append and commit write failure paths (CloseNow, since the close handshake itself would block on a silent peer), and add a silent-server regression test.

Refs #852

Co-Authored-By: cairn-code <[email protected]>

* test(dictation): cancel the silent-server writer failure test during teardown

CodeRabbit review 4909044011 identified that the silent-server regression test used context.Background(), allowing its goroutine to remain blocked in conn.Read after a timeout. Give the test a cancellable context and defer cancellation so teardown always releases the blocked read.

Refs #852

Co-Authored-By: cairn-code <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-15 20:31:52 +08:00
9c292092a2 fix(daemon): clean up child after startup timeout (#774)
* fix(daemon): clean up child after startup timeout

* fix(daemon): terminate startup process tree

* test(background): accept reaped-later zombie children

* fix(background): retain configured process group target

* fix(background): address termination review feedback

* fix(daemon): use launch-time group identity, fix zombie/reap classification

Addresses jatmn's three #774 review findings:

- TerminateCommand rediscovered the process group via
  execution.TerminateProcessTree's Getpgid(pid) lookup. On Darwin,
  that lookup can return ESRCH once an unreaped group leader has
  already exited, even though live descendants remain in the group
  it configured at launch — silently falling back to signalling only
  the (already dead) leader PID and leaving descendants running (the
  required macOS smoke job's failure). Add
  execution.TerminateProcessGroup, which signals the negative PID
  directly using the launch-time Setpgid invariant instead of
  rediscovering it, and route TerminateCommand through it.

- signalTargetRunning's zombie check matched the individual-PID
  fallback target (a process that is not its own group leader)
  against the process table by PGID. That target's actual PGID
  differs from its own PID by definition, so no row ever matched,
  unknown resulted every time, and a zombie individual-PID target
  was always conservatively reported as still running. Branch on the
  sign of the target: group members by PGID, individual PIDs by PID.

- TerminateCommand returned the termination error even when the
  subsequent reap succeeded — a successful cmd.Wait is authoritative
  proof the leader is gone and collected, regardless of how the kill
  attempt itself went (e.g. taskkill /T racing an already-exited PID
  on Windows). Classify a successful reap as success.

All three regression tests are verified to fail against the pre-fix
code (confirmed by temporarily reverting each fix and rerunning).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(daemon): harden command termination inputs

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-abb8-73de-9489-51e9e6514c6b

Co-authored-by: Pierre Bruno <[email protected]>

* fix(background): preserve process tree failures

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* docs(daemon): clarify boundary-probe and Setsid-guard comments per PR #774 review

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-08-15 20:31:10 +08:00
KRATOSandClaude Opus 4.8 060d38ce71 feat(security): path-jail primitive, credential-store locking, worktree git hardening (#891)
Split 1/3 of #829, carved out as the independently-reversible hardening layer
with no dependency on the orchestration work that stacks on top of it.

- internal/pathjail: os.Root handle-relative path confinement — ancestor
  traversal is confined, the final component is checked for reparse points
  (including a name written with a trailing separator), unprivileged Windows
  junctions are covered, temp-name fragments are rejected before they can
  escape the directory, whitespace-bearing paths are confined as spelled, and
  temp files stay unpredictable and exclusive.
- internal/credstore: file locking around the credential read-modify-write —
  exclusive for writers, shared for readers, so a reader cannot race a publish.
- internal/worktrees: git invocation hardening.

pathjail has no importers in this PR by design; internal/memory and
internal/specialist adopt it in the stacked PRs (2/3 and 3/3).


Origin-Session: local-de382f | Claude Code | 4 prompts
Origin-Snapshot: 52db9b74dad5

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-15 20:24:08 +08:00
Anandan 04fd3c01a7 Improve diff viewer rendering (#902)
* Improve diff viewer rendering

* Address diff viewer review findings

* Fix diff viewer interaction regressions

* Preserve header-like diff content

* Fix diff hunk parsing and image vulnerability

* Strengthen diff blank-context coverage

* Fix diff rendering edge cases
2026-08-15 17:54:38 +08:00
Vasanth T dc15e82298 fix: bump Go to 1.26.6 for stdlib vulnerability fixes (#903) 2026-08-14 18:58:16 +05:30
Anandan 2d2450e9a7 fix(providers): discover ChatGPT capabilities (#890)
* fix(providers): discover ChatGPT capabilities

* fix(providers): preserve discovered capabilities

* test(agent): cover service tier switching

* fix(tui): restrict fast mode to ChatGPT
2026-08-12 22:41:05 +05:30
cabfeefc4e security(update): stage binary replacement at an unpredictable, exclusive path (#751)
* security(update): stage binary replacement at an unpredictable, exclusive path

Fixes #742. installBinary staged downloaded release bytes at the fixed
<target>.new path and opened it with O_CREATE|O_WRONLY|O_TRUNC. In an
installation directory writable by a lower-privileged process, that
process could pre-create <target>.new as a hard link or reparse point
to another file the (possibly elevated) updater can write; the
truncating open then overwrote that unintended file with verified Zero
executable bytes.

stagingFilePath now generates a cryptographically random suffix so the
path can't be targeted in advance, and platform-specific
createStagingFile opens it exclusively without following a pre-existing
link: O_CREATE|O_EXCL on POSIX (which POSIX guarantees fails on a
pre-existing path, symlink or not, without resolving it), and
CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus a
post-open GetFileInformationByHandle check on Windows (not a reparse
point, not a directory, single hard link).

replace_windows.go's rename-swap sequence is unchanged: the dangerous
operation was always the truncating open of the staged path, not the
.old rename/removal that follows.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* security(update): bind the binary swap to the staged object, not its pathname

Addresses the two open findings on #751.

[P1] The staged file is no longer promoted by pathname. Randomizing the name
and creating it exclusively stops PRE-creation, but under #742's model — a
lower-privileged principal that can write in the installation directory — the
name can be observed after the fact and the entry replaced between the write and
the swap, so the updater would install the substituted file. installBinary now
keeps the creating handle open and promotes through it:

- Windows renames through the handle itself
  (SetFileInformationByHandle/FileRenameInfo, hence the added DELETE access), so
  there is no second pathname lookup to win. replaceBinary is gone; promote owns
  the aside-rename of the running binary and its restore-on-failure retry.
- POSIX has no rename-by-descriptor, so staging moves into a private directory
  created next to the target by os.MkdirTemp (mode 0700, random name, created
  exclusively). An attacker who can write in the installation directory cannot
  create, replace, or list entries inside it. promote additionally sets the
  executable bit through the handle (os.Chmod would re-resolve the path) and
  fails closed if the entry ever stops naming the object it wrote.

[P2] Staging cleanup now covers every failure path: the removal defer is
registered before the copy, and stageBinary discards the object if the copy
fails, so a mid-write error (ENOSPC, unreadable source) no longer leaks a
release-sized file that the next attempt will not reuse. CleanupStaleBinary also
sweeps leftovers from a hard crash — staging files on Windows, staging
directories on POSIX, where it used to be a no-op — skipping anything younger
than an hour so a concurrent update is never disturbed.

Tests: substitution-race regression per platform (Windows asserts the verified
bytes are installed despite a replaced staging entry; POSIX asserts the private
0700 directory and that promote refuses a substituted entry), success controls,
cleanup-on-failure, and the crash-leftover sweep. The forced-staging-failure test
for helper refresh now uses a stageBinary seam, because the staging location can
no longer be occupied from outside — which is the point of the fix.

* security(update): close remaining updater staging races

Address jatmn's review findings on PR #751:

- Windows promote() now verifies targetPath is actually reachable after a
  reported-successful handle rename before trusting it. Some Windows versions
  have been observed accepting SetFileInformationByHandle against a handle
  whose directory entry was substituted out from under it without the object
  actually moving, which let promote report success while targetPath was
  left missing entirely. renameFileByHandle is now a package var so this is
  covered by a deterministic regression test rather than relying on
  reproducing the exact trigger condition.

- When a promotion failure's restore-to-.old also fails (a writable-parent
  attacker occupying targetPath with a lock MOVEFILE_REPLACE_EXISTING can't
  get past), that combination is now wrapped in ErrTargetPossiblyTampered
  instead of reading like an ordinary failed update, with a best-effort
  MOVEFILE_DELAY_UNTIL_REBOOT fallback so an admin-context updater can still
  recover the original at next boot.

- POSIX promote() now binds its final rename to a directory descriptor
  opened when the staging directory is created (unix.Renameat), not a
  pathname. The 0700 staging directory protects its contents from a
  writable-parent principal, but not its own directory entry — that
  principal could rename the staging directory aside and recreate a
  look-alike with an attacker file at the same basename in the gap between
  the identity check and the rename, which a plain os.Rename would silently
  follow.

- Stale-cleanup on both platforms now matches the exact generated artifact
  shape (POSIX: prefix + os.MkdirTemp's all-digit suffix; Windows: prefix +
  32 lowercase hex chars + ".new") instead of a loose prefix/suffix, so a
  user's own similarly-named file or directory is never swept up. Both also
  re-check identity immediately before deleting to shrink the window a
  writable-parent principal could swap the checked path in.

- copyFrom now copies in chunks and refreshes the POSIX staging directory's
  mtime between them, since writing into an already-created file never
  touches the parent directory's own mtime — a large or slow copy could
  previously look abandoned to a concurrent update's cleanup sweep while
  still in progress.

Verified on real Windows and Linux (via WSL), plus cross-compiled build/vet
checks for darwin/linux-arm64.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(update): address staging review findings

* fix(update): verify promoted object identity safely

Bind POSIX staging creation and both rename endpoints to directory descriptors, clean only identity-matched empty directories, and verify Windows promotion by file identity rather than path spelling.

Preserve unverifiable crash leftovers and add adversarial cleanup, hard-link, and reparse-ancestor coverage.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-27da-72ec-8e6d-5d43f127b6a1

Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): preserve promotion recovery signals

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): keep the recovery copy a failed restore promised

Two error-path follow-ups from the #742 staging review.

When a promotion fails and restoring the original also fails, the error tells
the operator their original is preserved at <binary>.old — and then the next
Apply deleted it, because a present target read as proof the copy was stale.
What occupies the target in exactly that case is the bytes the updater could
not verify, so cleanup was erasing the one it could. A failed restore now marks
the copy, cleanup honors the mark, and a successful promotion clears it.

The POSIX staging flow could also leave an empty .zero-stage-* directory behind
when the stat immediately after mkdirat failed. Nothing knows that name yet, so
it would have stayed for good; it is now removed best-effort with rmdir
semantics, which cannot touch anything but the empty directory just created.

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

* fix(update): stop the recovery copy from being destroyed by the next attempt

The marker added in d9523ba kept cleanup from deleting <target>.old, but four
paths still undermined the promise it was meant to make.

Promotion destroyed the copy it was preserving. Skipping the pre-rename cleanup
was not enough: os.Rename uses MOVEFILE_REPLACE_EXISTING on Windows, so renaming
the running binary aside overwrote the last verified copy with the unverified
bytes the earlier failure left at the target — and a promotion that then failed
could only move those unverified bytes back. Promotion now refuses while the
tamper state is unresolved. Only the operator can say whether the file at the
target is theirs, so the error names both moves that end it: restore the copy,
or delete the marker to accept what is installed.

The marker itself was a predictable link-following truncate write. Its path is
fixed, so a writer in the install directory could pre-create it as a hard link
or reparse point and have the elevated updater write through it. It is now
created with the same CREATE_NEW + FILE_FLAG_OPEN_REPARSE_POINT + fresh-regular-
file check as a staging file, and never truncates; an existing name is treated
as already-marked rather than an object to open.

A marker that could not be written left the promise unqualified while the next
run's cleanup deleted exactly the file it named. That failure now rides in the
error, telling the operator to copy it somewhere safe now.

Helper refreshes downgraded tampering to a warning. An ordinary helper failure
still warns — a stale helper is better than a failed update — but a helper whose
path may hold unverified content fails the apply, because helpers are resolved
from the install directory and executed by the sandbox runner, and reporting
Applied: true would hand the operator a success while a sibling executable is
suspect. ErrTargetPossiblyTampered moved to apply.go so callers on every
platform can test for it without build tags.

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

* fix(update): keep the recovery copy when its marker cannot be established

Surfacing a failed marker write told the operator to hurry; it did not stop the
next run from deleting the file they were being told to save. When no marker can
be established, the copy is now moved to an unpredictable sibling name that
routine cleanup never touches — CleanupStaleBinary only ever removes the exact
"<target>.old" — and the error names where it went. Telling the operator to act
now remains the fallback for when even that move fails.

The marker check is conservative in the same direction: only a definite
"not there" allows the copy to be deleted. An Lstat that fails for any other
reason leaves the question open, and deleting is irreversible while keeping
costs one stale file.

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

* fix(update): preserve Windows recovery state across retries

Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>

* fix(update): watch every recovery path and never orphan a partial marker

A failed second-or-later update marks the randomized aside it used, but
promotion only consulted the canonical <target>.old marker, so a retry
proceeded over an unverified target while the verified copy sat marked on a
path the refusal logic did not watch. Promotion now enumerates every recovery
candidate — canonical and randomized asides — and refuses while any of them
is marked, naming each recovery path and its marker.

The missing-target refusal had the same single-path blind spot: with a stale
canonical .old from an earlier successful update plus the aside from an
interrupted attempt, it named only the canonical path, which can be the wrong
binary. It now names the single actual candidate or refuses the ambiguous
layout outright until the operator resolves it.

A failed marker write could also leave a partial .keep behind while the
recovery copy was relocated elsewhere; later refusals then named a location
that no longer held the verified bytes. markOldBinaryPreserved now removes
the entry it created before reporting a write/close failure, and
conservatively reports success when that removal (or the state check) fails
so oldPath stays authoritative. keepUnmarkedRecoveryCopy is handle-bound end
to end: it pins the recovery copy with a no-delete-sharing, no-reparse open,
verifies it is a single-link regular file, renames through the handle with
ReplaceIfExists false, and confirms identity at the destination, failing
closed with ErrTargetPossiblyTampered on any race.

Finally, the error for an unestablishable marker still promised that a later
update would otherwise remove the copy, but CleanupStaleBinary is now a
no-op; the operator is told that a manual copy is required.

* fix(update): tolerate the fail-closed outcome of a fully-unlinked substitution

TestPromoteInstallsTheStagedObjectNotTheStagedPath deletes the staging
file's only directory entry and recreates it as an "attacker" file, then
required promote to succeed with the verified bytes installed. CI's
windows-latest runner permits that delete (this workstation's exclusive
share mode blocks it, so the path went unexercised here); on that runner,
renaming the now fully-unlinked staging handle back into existence is not
something every Windows build honors, so verifyPromotedTarget's post-
rename identity check finds nothing at targetPath, promote fails, and
restoreOriginalBinary moves the pre-update binary back — a safe, fail-
closed outcome, not a security regression.

The test now accepts either outcome after a real substitution: promote
succeeding with the verified bytes, or promote failing as long as the
attacker's substituted bytes were never installed. Any promote failure
that occurs without substitution having actually happened still fails
the test outright.

* fix(update): surface unverified recovery path, match .old case-insensitively

keepUnmarkedRecoveryCopy discarded the destination path when the rename
to it succeeded but the post-rename identity verification failed,
leaving restoreOriginalBinary's error telling the operator to save
oldPath after it had already been vacated. Return the attempted kept
path alongside the verification error so the caller can still name it.

existingRecoveryPaths compared "<target>.old" filenames case-sensitively,
but NTFS is case-insensitive/case-preserving, so a recovery or marker
file spelled e.g. "zero.exe.OLD" could be silently skipped by every
caller's fail-closed check. Fold both sides before matching.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(update): bound Windows recovery cleanup

Amp-Thread-ID: https://ampcode.com/threads/T-019fbdec-f8dc-71f9-abdd-ea044a902b9a
Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): secure Windows recovery cleanup

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): terminate Windows rename paths

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>

* test(update): allow namespaced recovery copies

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): bind Windows cleanup to handles, drop dead cleanup API

Failure-path staging cleanup on Windows re-resolved the staging pathname
after the exclusive handle was closed, so a principal who can write in the
installation directory could substitute that entry in the gap and have the
updater delete a file of their choosing. Removal is now requested through the
handle (FileDispositionInfo) before it is released, and nothing is removed by
name afterwards; the same change covers the staging-file verification failure
path and the partial recovery marker.

CleanupStaleBinary has had no production caller since bounded, identity-bound
cleanup moved into promote, so the exported no-op and the tests that could only
pass vacuously against it are removed. The POSIX link-regression tests now
drive createStagingFileAt, the primitive createStagedBinary actually uses,
instead of a path-taking helper kept alive only for them.

Also documents the fail-closed Windows recovery states in docs/UPDATE.md,
including the refusal a planted .old/.keep pair can cause.

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

* fix(update): bind Windows recovery to handles and bound its state

Addresses the 2026-08-02 review round on #751.

Keep the recovery restore bound to the object promote moved aside instead
of its pathname: promote now opens the target with delete access and no
delete sharing before renaming it aside, and restoreOriginalBinary renames
that same handle back (retrying through the handle, never re-resolving the
source name), so a directory writer cannot swap the aside entry during the
promotion gap and have the substitute installed.

Run the recovery-state checks as a standalone-install precondition, under
the same lock promotion uses, so "already up to date" can no longer report
success while a .keep/.recovery state is unresolved. The in-promotion check
stays to close the race.

Make the cleanup record a queue: entries are only retired once their object
is actually gone, so a copy held open by a scanner is retried by a later
update rather than leaking, while records for paths that vanished or that
this updater could never have created are retired instead of accumulating.

Also from review of the above:
- delete the pathname restore chain that lost its production callers
  (restoreOriginalBinary's pathname form, openIdentityFile,
  recordRecoveryCleanup) and point their tests at the live handle path
- name the relocation path in the error when the post-move verification
  fails, since oldPath is already vacated by then
- scope the recovery docs to Windows and describe POSIX replace semantics

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-11 20:03:18 +08:00
AnandanandVasanthdev2004 0c1de25a04 Add interactive terminal companions (#887)
* Add terminal companions

* Polish interactive terminal companions

* Fix terminal companion edge cases

* Strengthen terminal companion regression tests

* Preserve config layout when selecting pets

* Handle sparse pet preference layouts

* fix(terminalpet): reset SGR before erasing a sixel companion

On Windows Terminal the companion draws a filled black rectangle over the
interface, and dragging it mangles text under and around it. It looks like a
platform quirk. It is an untested code path that only Windows reaches.

Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty hands
the terminal an image id and the placement goes away. A sixel is already in the
cell grid, so clearRenderedImage writes literal spaces over the cells it
occupied. Those spaces are ordinary characters and carry whatever SGR the TUI
last set, and nothing normalised it, so every erase stamped a filled block in
the ambient background. A drag erases and repaints on every pixel, so the block
was continuously refreshed over the UI.

It stayed invisible everywhere else because ghostty, kitty, WezTerm and iTerm
all resolve to the Kitty protocol and delete by id, never reaching this code.
Windows Terminal is the only mainstream terminal that lands on the sixel branch,
which is why testing on Linux and macOS could not have caught it.

The erase now resets SGR before painting, and uses DECSC/DECRC rather than
CSI s/u because only DECSC saves the attributes this clobbers. Restoring them
matters: the erase is emitted mid-frame, between styled writes the TUI has
already begun.

Measured before and after rather than reasoned about. Before:

  \x1b[s\x1b[3;41H      \x1b[4;41H      \x1b[5;41H      \x1b[u

no reset anywhere. After, the reset leads and DECRC closes.

Tests assert the bytes, since bytes are what the terminal sees: SGR is reset
BEFORE the first row is painted rather than merely present somewhere, the erase
still covers every claimed cell so the fix cannot be satisfied by erasing
nothing, and the Kitty path still deletes by id rather than acquiring the
character-painting erase. Verified by mutation: restoring the old sequence fails
the first of those with the exact reported symptom.

Deliberately not fixed here, since both are design calls rather than defects:
the erase rectangle is computed from the app's own cell maths rather than the
terminal's real footprint, and the sixel path has no in-place move, so every
drag pixel is a full erase and repaint where Kitty gets a cheap reposition.

* fix(tui): ask sixel terminals for their cell size and erase what was painted

The companion blanks a row and a column of live interface on Windows Terminal.
The cause is one predicate.

petPixelProtocolSupported gates two unrelated questions that only looked like
one: whether pixel-precise dragging is available, and whether to ask the
terminal for its cell size with CSI 16 t. It answers true for Kitty only, so on
a sixel terminal the request was never sent, the reply never arrived, and
petCellPixelWidth/Height stayed zero for the whole session.

Everything downstream then took its fallback. petImageHeightPixels returned its
blind preferredHeight of 75 pixels instead of the cell-aware branch below it,
and the erase in clearRenderedImage used petImageColumns and petImageRows. Those
constants describe the RESERVED area, not what was painted. Windows Terminal
reports a 20x10 cell, so a 75-pixel sprite covers 4 rows and about 8 columns
while the erase blanked 5 by 9, taking a row and a column of interface with it
on every move. A drag repaints per pixel, so it did that continuously.

Windows Terminal answers CSI 16 t, confirmed by hand: it replies
ESC [6;20;10t. This was never a question of terminal support. Nothing asked.

So the request now goes out for every image protocol, via a predicate separate
from the dragging one, and the sixel footprint is computed from the rendered
pixel height and the reported cell size rather than from the constants. It is
still clamped to the reserved area, because the layout keeps that many cells
clear and the drag clamps to it, so a larger computed footprint would mean
erasing into live interface.

Deliberately NOT widening petPixelProtocolSupported itself: that would enable
pixel-precise dragging on sixel as a side effect, which is a separate feature
decision rather than part of this defect.

Kitty is untouched and a test pins that. There Columns and Rows are the
placement REQUEST handed to the terminal, which owns the region and scales into
it, so recomputing them would resize the image rather than fix an erase.

Tests use the real 20x10 measurement: the sixel footprint is 4 rows not 5, Kitty
keeps its requested placement, the fallback without metrics stays the reserved
area rather than collapsing to zero and erasing nothing, and every image
protocol asks for the measurement.

* Keep Sixel companions in the reserved dock

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-08-11 09:24:06 +08:00
github-actions[bot] 2458e0c67b chore(main): release 0.7.0 (#837)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-10 11:13:32 +08:00
Vasanth T 027a67080b fix(mcp): name the non-text blocks a tool result drops (#874)
* fix(mcp): name the non-text blocks a tool result drops

TextContent keeps only blocks whose type is "text", so a server that
returns an image, audio, or an embedded resource produced "(empty MCP
tool result)". A screenshot or browser server would complete a call
successfully, hand back a perfectly good image, and the model would be
told nothing came back. It then usually retries, which spends another
call to reach the same empty answer, and the user never learns an image
existed at all.

Carrying the payload is the larger half of #823 and is not this change.
Dropping silently is the part that causes the retry loop, and it can be
fixed on its own.

Decode mimeType on Content, and add DroppedContentSummary, which
describes what TextContent discarded ("1 image/png block", "2 resource
blocks, 1 audio/wav block"), grouping by mime type and ordering by first
appearance so the same result always reads the same way. Servers that
omit mimeType fall back to the block type rather than printing an empty
label.

registryTool.Run appends that as a note, and only when something was
actually dropped, so a text-only result is byte-for-byte unchanged. The
note also says retrying will return the same thing, since telling the
model what happened without telling it not to retry only fixes half the
loop.

Refs #823.

* fix(mcp): do not promise a retry returns the same content

The dropped-block note said "Retrying will return the same thing".
registryTool.Run makes a fresh remote call on every retry, so the server
may well answer differently; that is a promise this code is in no
position to make.

What cannot change is that Zero still has nowhere to put a non-text
block, so the note now says retrying cannot RECOVER the payload. Same
guidance, a claim that is actually true.

The test pins both directions: the new wording must be present, and the
absolute claim must not come back.

Raised by CodeRabbit on #874.
2026-08-10 10:59:03 +08:00
euxaristiaandeuxaristia fb4f293c20 feat(providers): live model lists for OpenRouter and OpenGateway (#860)
* feat(providers): fetch live model lists for OpenRouter and OpenGateway

Prefer each aggregator public /v1/models catalog over the dead OpenGateway
models.json path and models.dev-only OpenRouter mapping so the picker shows
current routes with context windows and free labels.

Refs #859

* fix(providers): parse OpenRouter string pricing and simplify sort assertions

Parse OpenRouter prompt and completion string pricing fields into InputCost and OutputCost, and directly assert alphabetical model sort order in discovery tests.

Refs #859

* fix(providers): scale live pricing and restore OpenRouter fallback

Convert OpenRouter/OpenGateway per-token pricing to per-million for Model costs, and fall back to models.dev when the live OpenRouter fetch fails so the picker still degrades.

* fix(providers): fall back on OpenRouter parse errors; cover live-only merge

Route malformed OpenRouter HTTP 200 bodies through the models.dev fallback,
and exercise catalog vs live payload split so live-only models stay merged.

* fix(providers): address remaining CodeRabbit findings on live catalogs

Reject non-finite OpenRouter pricing, keep capability-marked live-only
models, and harden related regression tests.

---------

Co-authored-by: euxaristia <[email protected]>
2026-08-10 10:58:38 +08:00
Vasanth T ccc42b94e7 test(agent): raise the eager tool schema ceiling to 3650 (#877)
main is red. TestEagerToolSchemaTokenBudget fails at 3578 tokens against
a 3550 ceiling, and every open PR inherits it.

Bisected: edf660ab and 4ca4fa75 pass, cd0eb194 (#843, which added
view_image to the core set) fails at 3588. #867 later trimmed it to 3578.
#843's own CI was green, because the ceiling is only crossed once the
tool set is combined, so no per-PR check could have caught it.

Raised rather than paid down. The overrun is 28 tokens, and view_image
costs 82, the second cheapest tool in the set against exec_command's 909
and request_permissions' 397. It is simply the one that crossed the line.
The alternative was trimming a description to reclaim 28 tokens,
degrading a tool's usability to satisfy a line drawn against a 2026-07
measurement that predates two tools. The ratchet worked exactly as
intended: it caught the creep and forced a decision instead of a drift.

Also corrects the failure message. It advised "defer a tool", but this
test pins DeferThreshold at 0 so deferral is inactive and marking a tool
deferred leaves it exposed here, changing nothing. Deferring is still
worth doing for real sessions; it just cannot move this number. The
levers that can are a smaller schema, one fewer core tool, or a
deliberate raise, and the message now says so.
2026-08-10 10:57:42 +08:00
Vasanth T 2ec5c3ae6b fix(agent): tell the user a prefix approval leaves the sandbox (#885)
Approving a command prefix does not only stop the prompt from reappearing.
shellExecutionArgsForApproval rewrites the call to require_escalated, which
resolves to a nil sandbox engine, so the command runs unsandboxed on the host.
The engine's escalation gate is then satisfied by the PermissionGranted flag
from the approval the user just gave for the sandboxed form.

The escalation itself is deliberate and guarded: proposedCommandPrefix refuses
to offer a prefix when any other segment of the command is not known-safe,
precisely because approval covers the whole command. The defect is that the
prompt never said so. The user chose "allow command prefix for session" and got
"and also run it outside the sandbox".

So this discloses rather than removes. PermissionRequest gains
PrefixApprovalEscalates, computed by shellPrefixApprovalEscalates from the same
guards the rewrite consults, and the two prefix options append "(runs outside
the sandbox)" when it is set. Both the TUI and the ACP path carry it, since an
editor user decides on the same terms as a terminal user.

The main test asserts the disclosure and the rewrite agree on every input
rather than checking each against a fixed expectation, so the two cannot drift
apart later. Verified by mutation: reverting the helper to always-false fails
it, as does making the label unconditional.
2026-08-10 10:56:49 +08:00
ff62f73b7f fix(secrets,worktrees): fix secret redaction leakage and prune stale worktrees (#855)
* fix(secrets,worktrees): fix secret redaction leakage and prune stale worktrees

1. Prevent trailing redaction leaks in github_token, aws_access_key_id, and google_api_key by adding trailing word boundaries and allowing variable lengths. Refine the openai_key pattern to cleanly distinguish legacy keys and modern prefixed keys (sk-proj-, sk-svcacct-) from ordinary kebab-case phrases.
2. Implement auto-pruning of zero-owned git worktrees older than 24 hours at the start of worktrees.Prepare to prevent indefinite disk space leaks.

* fix(secrets,worktrees): close tail-leak edge case and worktree data-loss risk

Drop the trailing \b anchor on the four secret patterns whose body
class allows "-" (slack_token, google_api_key, the modern openai_key
branch, jwt). \b requires a word/non-word transition, so a secret
ending in "-" right before a delimiter has none, and the engine
backtracked the greedy quantifier to drop that last character instead
of failing the match, leaking it. The body character class already
provides the real stopping boundary, so the anchor was unnecessary.

Fix two issues in worktree Clean flagged in review:

- Staleness was decided by the worktree directory's own mtime, which
  only changes when an entry is added/removed/renamed directly inside
  it, not when a long-running task edits existing files deeper in the
  tree. Clean now walks the tree and treats any recently modified entry
  as live, and also skips any worktree a caller has explicitly locked
  via git worktree lock.
- baseDir ownership used a raw strings.HasPrefix, so a sibling like
  "<baseDir>-other" would false-match. Replaced with a filepath.Rel
  path-boundary check.

* fix(worktrees): check exit codes on removal, fail closed on inspection errors

defaultRunGit deliberately returns a nil error alongside a nonzero
CommandResult.ExitCode for a failed git invocation, so the worktree
remove call must check ExitCode itself instead of trusting a nil error
to mean success. Route it through gitOutput, which already does that.

worktreeIsStale treated an inspection failure (an unreadable file, a
WalkDir error) the same as "keep walking," which can let an
incompletely-inspected worktree be judged stale. Any inspection error
now makes it ineligible for removal instead.

* fix(secrets,worktrees): catch appended-suffix keys and scope pruning to owned worktrees

The scanner's trailing \b anchors made a credential vanish entirely when
followed by a word character outside its body class (an appended suffix
like AKIA...EXTRA, or ghp_..._suffix): the fixed or unbounded-greedy
quantifier had no valid word boundary to land on and the whole match
failed, so the real secret reached the redaction output unredacted.
Dropping the trailing anchors lets the body class itself stop the match,
so the credential prefix still gets redacted even when noise follows it.
Also recognize sk-admin- alongside sk-proj-/sk-svcacct- so OpenAI admin
keys aren't skipped by the narrowed modern-key branch.

Clean pruned any worktree under the caller-supplied BaseDir, but Prepare
only ever creates worktrees under a per-repository
zero-worktree-<repoKey> subtree of it. Scope pruning to that subtree so a
worktree a user manages by hand elsewhere under a shared BaseDir is never
force-removed. Also refuse to force-remove a worktree whose mtime looks
stale but that still has uncommitted or untracked changes: a task can
hold live work while waiting on a model, network, or user for longer
than the staleness window without writing to the tree again.

* fix(worktrees): make nested-activity test exercise the deep walk it claims to

activePath/internal was created by the same MkdirAll as the nested pkg
dir but never backdated, so it kept a fresh mtime and worktreeIsStale's
walk reported "not stale" as soon as it hit that directory, before ever
reaching the freshly-written file two levels deeper. The test passed
without actually exercising recursion past the first directory.

* fix(worktrees): lock zero-created worktrees and treat ignored files as dirty

Prepare never called git worktree lock, so the entry.locked skip in Clean
only ever protected worktrees a human locked by hand, never zero's own; a
worktree that finished committing and sat idle (e.g. waiting on a slow
model or network retry) for more than 24h looked clean-and-stale and got
force-removed by the mtime+dirty heuristic alone. Lock every worktree
Prepare creates so it gets the same protection.

worktreeIsDirty also used git status --porcelain with no --ignored, so a
worktree holding only .gitignore-matched task data (credentials, generated
drafts, artifacts) reported as clean and got force-removed with --force,
silently discarding it. Add --ignored so those files count as dirty too.

* fix(worktrees): release Zero's Prepare lock so Clean can reclaim finished worktrees

Prepare locks every worktree it creates so Clean's mtime+dirty staleness
heuristic never force-removes one Zero is still using, but nothing ever
unlocked it, making the automatic disk-space cleanup permanently inert.

Add Release (git worktree unlock) and wire it in two ways: zero exec
--worktree defers a release once its own run finishes, since that flow's
use of the worktree is bound to its own process. zero worktrees prepare
hands the path to a longer-lived external caller with no defined
end-of-life, so a new zero worktrees release <path> subcommand lets that
caller release it explicitly when done.

* fix(worktrees): normalize release path, aggregate Clean errors, unlock deleted worktrees

Address CodeRabbit's review on the lock-release fix:

- zero worktrees release now resolves its path argument to absolute
  before calling Release, since git worktree unlock matches against
  the path git recorded at creation, not whatever directory the
  caller happens to be running from.
- Clean now aggregates removal failures with errors.Join instead of
  overwriting lastErr, so multiple stale worktrees failing removal in
  the same pass are all reported, not just the last one.
- Release falls back to options.Cwd as the git working directory when
  the worktree path itself no longer exists (e.g. a caller deleted a
  locked worktree by hand instead of releasing it first), so the
  orphaned lock can still be cleared.

Added regression coverage for all three.

* style(worktrees): align aggregation test comments to gofmt output

* fix(worktrees,cli): restore reuse lease and scope unlock to owned locks

- Prepare re-locks a reused worktree so Clean's staleness heuristic cannot
  force-remove it while the new caller is still using it; a lock already
  held by a live external caller is kept in place and reported through the
  new Result.LockAcquired field.
- exec --worktree only releases the lock its own Prepare call acquired and
  surfaces a failed release on stderr with the affected path instead of
  discarding the error.
- worktrees release wires the resolved workspace root into Options.Cwd so
  the deleted-path recovery works outside the worktree directory.
- The relative-path release test derives its expected value via
  filepath.Abs, matching the resolution the CLI uses, so macOS /var vs
  /private/var spellings no longer break it.

* fix(worktrees,cli): reject in-use leases, validate before cleanup, add release -C

- Prepare rejects a worktree whose lock another run still holds, on both
  the reuse and the create-race paths, instead of handing a second live
  caller an unprotected shared checkout whose sole Git lock the first
  caller's exit would release.
- The automatic stale-worktree pruning runs only after the request itself
  validates, so a rejected command (an invalid --name) has no destructive
  cleanup side effect; covered end to end with real git in both directions.
- worktrees release accepts -C/--cwd naming the source repository, which
  the deleted-path recovery needs when launched outside the repo (the
  deleted worktree path is a one-way hash with no way back to its source).

* test(worktrees): canonicalize test roots to physical spelling

git records worktree paths in physical form, so the CI runners' symlinked
(/var -> /private/var) and 8.3-short (RUNNER~1) temp spellings made Clean's
containment check skip the test's stale entry and the pruning assertion
fail on macOS and Windows.

* fix(worktrees): preserve orphaned commits, verify release ownership, canonicalize base dir

Four fixes from the latest review round:

- Clean now creates a durable ref (refs/zero/orphaned-worktree/<sha>)
  for a detached worktree's HEAD before force-removing it, when that
  commit isn't already reachable from any other ref. Prepare always
  creates worktrees with `worktree add --detach`, so a commit made
  there had no ref pointing at it once the worktree was deleted,
  making it immediately eligible for git gc despite never having been
  merged/pushed elsewhere.

- Clean resolves its configured base directory through EvalSymlinks
  before comparing it against git's reported worktree paths: `git
  worktree list --porcelain` reports each worktree's PHYSICAL location
  (resolving symlink components), so a symlinked --worktree-dir made
  every worktree created under it permanently unprunable.

- Release now verifies path has a zero-worktree-<repoKey> ancestor
  directory component before running `git worktree unlock`, so it
  can't be used to clear the lock on a worktree a user or another tool
  manages by hand. The check doesn't need to know which --dir a given
  Prepare call used (nothing records that against a specific worktree,
  and the CLI never threads BaseDir through to Release) — the repoKey
  component is Prepare's actual ownership signature regardless of
  which directory it was created under.

- Route the release command's printed path and exec's release-failure
  diagnostic through the existing CLI redaction helper, and split the
  worktrees help text into prepare-specific and release-specific flag
  sections (release only ever supported -C/--cwd, not the --name/--dir/
  --json the shared block advertised).

All four have regression tests confirmed to fail without their fix
(two using real git worktrees, not just fakeRunner sequences). Build,
vet, and gofmt clean on linux/windows/darwin.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(worktrees): recoverable PID leases and reclaimable released worktrees

Two review findings on the cleanup lifecycle:

- A lock left by an abnormal exit (SIGKILL, crash, power loss) was
  skipped by Clean forever, recreating the permanent disk leak this PR
  set out to fix. exec --worktree now records its PID in the lease
  reason; Clean expires a lease whose recorded owner is provably dead,
  unlocking only after the staleness, dirty, and HEAD-preservation
  guards all pass. Human locks and PID-less leases (external
  `worktrees prepare` owners) remain permanent until explicit release,
  and any ambiguity in the liveness probe counts as alive.

- An explicitly released worktree holding only gitignored residue
  (node_modules, build output) was skipped at every age. Release is the
  owner's completion signal, so unlocked entries now block removal only
  on tracked/untracked changes; expired crashed leases keep the
  conservative --ignored probe since they never signaled completion.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(worktrees): address review feedback on lease detection and release safety

Split dead-lease PID checking into posix/windows implementations so
Windows can reliably tell a dead process from a live one. Fix a path
canonicalization mismatch in two release tests. Derive release
ownership from git worktree list instead of the git-dir parent, which
was wrong for repos with a separate git-dir. Refuse to clear a lock
that was not taken by Zero in the first place.

* fix(worktrees): canonicalize paths for clean/release ownership checks

Compare Clean containment and Release ownership against physical path
spellings so macOS /var vs /private/var and symlink TMPDIR layouts match
git worktree list. Require a registered porcelain entry and a Zero lease
reason before unlock; treat an already-unlocked Zero worktree as a no-op.

* fix(worktrees,secrets): address jatmn review findings on #632

- Prepare and Release now agree on repoKey regardless of which worktree
  (main or linked) Prepare runs from, by keying off git worktree list's
  first entry (always the main worktree) instead of --show-toplevel.
  A worktree prepared from a linked checkout previously failed its own
  ownership check on release and its lease could never be cleared.
- osProcessAlive on Windows no longer treats every OpenProcess failure
  as "process is dead": only ERROR_ACCESS_DENIED (a live process this
  caller lacks rights to query) is now distinguished from a genuinely
  missing PID, so Clean can no longer force-remove an active worktree
  whose owning process it simply couldn't query.
- The openai_key redaction pattern now recognizes sk-or-v1- (OpenRouter)
  alongside the existing sk-proj-/sk-svcacct-/sk-admin- prefixes, so
  hyphenated OpenAI-compatible provider keys are redacted again without
  reopening the sk-<kebab-case-phrase> false-positive this pattern was
  narrowed to avoid.
- Release/exec --worktree error text is redacted before reaching
  stderr, matching the already-redacted success path; ownership errors
  interpolate the caller-supplied path, which could carry a key-shaped
  segment.
- canonicalizePath resolves symlinks through the nearest existing
  ancestor when the target itself no longer exists, so the documented
  `release -C` recovery path works again for a worktree deleted by hand
  under a symlinked --worktree-dir.
- Prepare rolls back the worktree `git worktree add` just created if the
  subsequent lock call fails for a reason other than a concurrent
  racer, instead of leaking an unleased checkout until Clean's 24h
  staleness window reclaims it.

Not addressed here: the P2 finding that worktree ownership is provable
only by a directory-name convention plus a lock-reason prefix, both of
which a user can reproduce by hand. A durable per-worktree ownership
marker would close that gap, but internal/worktrees has been on main
since #70, so a marker requirement could reject worktrees an
already-installed zero created before this change existed. Needs a
decision on migration before implementing.

* fix(worktrees): prove Prepare ownership with a git-admin marker

Address review findings that path convention plus lease-reason prefix are
forgeable by hand. Prepare now writes a zero-owner marker into the worktree
admin dir; Release and Clean require it before force-touching a path. Clean
also keys its owned subtree off the main worktree root so linked-checkout
calls still prune the same bucket Prepare uses. Tests plant the marker and
list the main worktree first so fixtures match production.

* fix(cli): complete worktrees release in shell completions

* test(worktrees,cli): add coverage for linked-worktree Clean, forged lease rejection, and completions

- TestCleanFromLinkedWorktreePrunesStaleWorktree: pins Clean deriving its
  owned-subtree key from the main worktree root (not the invoking linked
  checkout's --show-toplevel) so Prepare/Clean run from a linked worktree
  actually reclaim the worktrees Prepare created there.
- TestReleaseRejectsForgedZeroLeaseWithoutOwnershipMarker: pins the
  ownership-marker requirement against the exact forgery jatmn described - a
  worktree under the predictable zero-worktree-<repoKey> path, manually
  locked with a reason that merely starts with the zero lease prefix.
- Fix TestPrepareCreatesDetachedGitWorktree: its fake git runner ran out of
  canned results at Prepare's post-lock ownership-marker write, so
  writeOwnershipMarker resolved gitDir to "" and os.WriteFile wrote
  "zero-owner" as a relative path into the test process's real working
  directory instead of failing loudly. Give it autoAbsoluteGitDir like the
  other Prepare-exercising tests use and assert on the marker-write call.
- completions_test.go: assert `worktrees`/`worktree` completions include
  `release` alongside `prepare`.

* fix(redaction,worktrees): sort extra secret values by length descending and canonicalize worktree paths

* fix(secrets,worktrees): restore Anthropic key redaction and handle legacy worktree cleanup

* fix(secrets): add bash output redaction regression test for Anthropic API keys

* fix(worktrees): touch worktree mtime on reuse to prevent stale pruning

When Prepare reuses an existing worktree, it now touches the directory
to refresh its mtime. This prevents Clean from force-removing a
long-running but idle worktree (e.g. waiting on a model) that has no
recent file changes but is still actively in use.

Addresses the data-loss risk flagged in the PR review where mtime-only
staleness + --force removal could discard live worktrees.

Co-authored-by: cairn-code
Co-Authored-By: cairn-code <[email protected]>

* fix(worktrees): properly handle os.Chtimes error on reused worktree path

Co-Authored-By: cairn-code <[email protected]>

* fix(worktrees,secrets): unlock using porcelain entry path, set RepoRoot to primaryRoot, and add anthropic_key pattern

Unlock using matched porcelain entry path, set Result.RepoRoot to primaryRoot in Prepare, add dedicated anthropic_key secret pattern, and canonicalize fake-runner test paths.

Refs #632

* fix(tools): correct redaction placeholder assertion in Anthropic key test

TestFormatBashOutputRedactsAnthropicKey checked for the openai_key
placeholder instead of anthropic_key, a copy-paste leftover. The
redaction itself was already correct; only the assertion was wrong.

* test(worktrees): physicalize test paths for macOS tempdir symlink resolution

* fix(secrets,worktrees): address CodeRabbit review findings

Align RedactString token boundaries with secrets.Scan, strengthen the
Anthropic bash redaction assertion, fix Clean fixtures so they reach
the guards under test, and fail closed on ambiguous processAlive probes.

* fix(secrets,worktrees): restore broad key redaction and legacy Clean safety

Address human review on #855: keep sk- bodies with a digit filter instead
of enumerated vendor prefixes, add a looser JWT form, restore the
sk-test fixture, probe legacy ownership before dirty, treat non-INVALID
Windows OpenProcess errors as alive, redact Abs/cwd release errors, and
reclaim dead-owner leases on Prepare reuse.

* fix(secrets,redaction): always redact known OpenAI key prefixes

Alphabet-only sk-proj-/sk-svcacct-/sk-admin- tokens are still credentials;
keep the digit filter only for unknown sk- vendor forms so kebab phrases
like sk-learn-… stay un-redacted.

* Preserve digit-free legacy keys during redaction.

Refs #855

* fix(secrets,worktrees): address CodeRabbit findings on PR #855

Redact full compact JWE tokens, pin git locale for lock parsing, write the
ownership marker atomically, surface missing-dir unlock failures, and
fail closed when HEAD probes are indeterminate. Align Clean fixtures and
digit-free known-prefix redaction tests with the shipped behavior.

Refs #855

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: cairn-code <[email protected]>
Co-authored-by: euxaristia <[email protected]>
2026-08-10 10:51:11 +08:00
0176ee49ff feat(agent): add PermissionModePlan for interactive read-only planning (#853)
* feat(agent): add PermissionModePlan for interactive read-only planning

* fix(agent): drop redundant modeName branch, add plan mode regression tests

string(permissionMode) already yields "plan" / "spec-draft", so the
if/else recomputing modeName in the denial message was dead branching
on the same values. Also add plan-mode coverage mirroring three of the
four existing spec-draft regression tests: advertised tool set, and
denied write_file/bash calls. The fourth (submit-and-stop review
control) has no plan-mode analog, since plan mode has no submit tool.

* fix(agent): deny request_permissions in plan/spec-draft even when the registry omits it

request_permissions is dispatched by name in executeToolCall before the
registry-based ToolAdvertised gate runs, so that gate only helps when
the tool happens to be present in the caller's registry. A plan- or
spec-draft-mode registry that simply omits the tool (rather than
registering it as denied) let the call fall through to a real
turn/session-scoped permission grant, defeating the read-only
boundary. Deny it unconditionally at the top of
executeRequestPermissions for both read-only modes instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(agent): suppress executable hooks while plan/spec-draft mode is active

Plan mode promises a read-only turn, but sessionStart/sessionEnd fire on
every run and beforeTool/afterTool fire around allowed read calls, and
all four execute configured host commands outside the advertised-tool
and sandbox gates — so a project hook could mutate the workspace or
spawn a process from a session that advertises it cannot. Gate all four
dispatch points on the run's permission mode, with a regression test
asserting no hook command launches during a plan-mode run.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): close plan-mode tool advertisement bypass

toolAdvertisedInPlan whitelisted ask_user and update_plan by name
alone, so a caller could register a mutating tool under either name
and have it advertised and executed in plan mode. Validate every tool
against its Safety() instead. Also exclude lsp_navigate, which is
marked SideEffectRead but lazily spawns a real language-server
process, contradicting plan mode's read-only guarantee. Add
regression tests for both.

* fix(agent): keep trust-gated hooks in spec-draft mode

Plan mode still suppresses executable hooks so a read-only planning turn
cannot spawn host processes via session or tool hooks. Spec-draft keeps
the existing trust model: project hooks fire when the workspace (or its
worktree trust root) is trusted. Unconditionally suppressing hooks in
spec-draft broke TestExecSpecWorktreeInheritsTrustEndToEnd for trusted
worktrees under --use-spec --worktree.

* fix(agent): cover plan-mode spoof and lsp_navigate denials

Expand the name-only spoof regression to both update_plan and ask_user,
and add an execution-path denial for lsp_navigate so plan mode cannot
spawn language servers even when a call still arrives.

* fix(agent): filter tool_search deferred candidates by plan/spec-draft visibility

tool_search resolved and ranked deferred tools by EnabledTools/DisabledTools
only, never by the run's permission-mode visibility. tool_search itself is
already denied at dispatch in plan/spec-draft (its Safety carries no side
effect, so it fails the same SideEffect==Read advertisement gate direct calls
use), so this was not reachable through the normal Run() path today. But it
is a real landmine: if that outer gate is ever loosened independently (e.g.
tool_search's no-side-effect Safety is judged advertisable), the loader had
no gate of its own and would hand a deferred write/mutator tool's name,
description, and full schema straight to a plan/spec-draft model.

Mirror agent.ToolAdvertised's plan/spec-draft branches inside the tools
package (toolAdvertisedForPermissionMode, next to the existing
toolAllowedByFilters mirror that avoids the same import cycle) and apply it
alongside the operator filters in visibleDeferredTools and
visibleEagerToolNames.

Added unit tests in tool_search_test.go for both modes, and an end-to-end
agent test that force-calls tool_search in plan mode and asserts no schema
leaks. Verified by reverting tool_search.go and confirming the new tests fail
(one shows load_tools resolving to the mutator's name); restored and
confirmed they pass. Also confirmed via a temporary probe that if plan mode's
outer advertisement gate is loosened, this filter is what actually stops the
leak.

* fix(agent): require Safety for spec-draft ask_user/submit_spec

Do not advertise or load re-registered control tools by name alone in
spec-draft mode. ask_user must be SideEffectRead+Allow and submit_spec
must be SideEffectWrite+Allow, matching the real tools. Apply the same
filter in tool_search and add spoof regression tests.

Refs Gitlawb/zero#642

* fix(agent): wire plan mode into TUI, CLI, and ACP entry points

Address the P1 finding that PermissionModePlan was documented but never
selected by /plan, zero exec, or ACP mode selectors. /plan on|off now
toggles the session permission mode (restoring the prior mode on off),
zero exec --plan selects plan for a run, and ACP advertises plan as a
client-selectable mode. Integration coverage for each entry path.

* fix(cli): reject --plan combined with --worktree

Worktree preparation runs in runExec before the plan permission mode is
assigned, so `zero exec --plan --worktree` could still trigger workspace
mutation ahead of the read-only gate. Reject the combination during
option validation, alongside the existing --use-spec/--skip-permissions-
unsafe conflict checks, so no worktree prep can occur.

Addresses a coderabbitai finding on PR #642.

* test(tools): assert spoofed tool schema doesn't leak via tool_search

The spoofed-control-tool regression only asserted on the description
string; Parameters() exposed no distinctive schema marker, so a
regression that leaked the schema without the description would still
have passed. Add a spoofed_secret property to the test tool's schema
and assert it's absent from result.Output alongside the description.

Addresses a coderabbitai finding on PR #642.

* fix(tui): gate local mutating commands behind plan mode

/plan on only flips the agent permission mode, which gates agent tool
calls. Local TUI commands that run entirely inside the TUI process
bypass that gate: /rewind restores workspace files from a checkpoint,
/export writes a transcript to disk, and /sandbox-setup spawns a
native host process. Add a shared plan-mode guard at the start of
dispatchCommand (mirroring the existing BTW-unavailable guard) that
rejects these three commands while permissionMode is
agent.PermissionModePlan, with regression coverage proving each is
blocked with no mutation/process spawn in plan mode and unaffected
outside it.

Addresses a coderabbitai finding on PR #642.

* fix(agent,specialist): layer plan mode system prompt and enforce read-only subagent mode

* Fix jatmn review findings for PR 642

* fix(agent,cli,tui): resolve CodeRabbit review comments on active turn model field and --plan permission mode conflict

* fix(agent): propagate permission mode to exec options and serialize ACP mode changes

Parse permissionMode in resolveExecPermissionMode, acquire turnMu.Lock in ACP handleSetMode to serialize mode changes with active turns, and block MCP subcommands in TUI plan mode.

Refs #642

* fix(agent,tui): update tests off removed tools.CoreTools/NewWriteFileTool/NewLSPNavigateTool wrappers

Those were thin unscoped wrappers around the Scoped variants, deleted
upstream in #706 since nothing else called them directly. Only these
tests still did; switch to the Scoped calls main's own tests already
use.

* fix(agent): fail-closed beforeTool vetoes and permission-mode plan guards

Keep beforeTool deny gates active under plan mode so hooksSuppressed no longer
fails open, and stop propagating --permission-mode for auto/ask/member children
so swarm members keep write tools. Apply --plan combination rejects to
--permission-mode plan as well.

Refs #853

* fix(agent): address CodeRabbit findings for plan-mode advertisement and entry paths

Unify plan/spec-draft tool advertisement in tools.ToolAdvertisedForPermissionMode
(with PermissionDeny short-circuit), cover ACP config and --permission-mode plan
list-tools paths, and allow bare /mcp while blocking mutating MCP subcommands.

Refs #853

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
2026-08-10 10:15:29 +08:00
Anandan 160e3bed1a Unify tool output views (#880)
* Unify tool output views

* Preserve finalized tool outcomes

* Make outcome tests portable
2026-08-10 09:58:05 +08:00
Vasanth T 7fbd864e91 fix(tui): fall back to CellMotion where AllMotion is unreliable (#872)
* fix(tui): fall back to CellMotion where AllMotion is unreliable

AllMotion (1003) buys exactly one thing over CellMotion (1002): hover
highlighting. Wheel, click and drag all arrive under either. But a
terminal that does not implement 1003 does not degrade to 1002, it sends
nothing at all, and because the TUI holds the alternate screen the
terminal's own scrollback and wheel are gone too. The user is left
unable to scroll by any means, and a long answer then reads as a hang
rather than as a missing highlight. That is how #870 arrived: a
completed turn with "59 more" lines below the fold, reported as a
freeze.

The existing code already conceded 1003 is fragile by carving out PRoot.
The legacy Windows console is the same case and was not covered, so a
bare zero.exe outside Windows Terminal asked for a mode its terminal
does not answer.

Move the decision into mouseModeFor, which trusts terminals that
identify themselves (WT_SESSION for Windows Terminal, TERM_PROGRAM for
hosts like VS Code and mintty) and assumes an unidentified Windows
terminal is the legacy console. Guessing wrong that way costs a hover
highlight; guessing wrong the other way costs every mouse event.

ZERO_MOUSE_MODE=all|cell|off overrides it, so a user on a terminal we
guessed wrong about can fix it without waiting for a release. An
unrecognised value is ignored rather than treated as "off", so a typo
cannot silently kill the mouse.

Refs #870.

* test(tui): pin the inherited-environment limit of the mouse-mode guess

WT_SESSION and TERM_PROGRAM are inherited, so they answer for whichever
process set them rather than for the console host attached now. A shell
started from Windows Terminal or Git Bash that later runs zero.exe
against the legacy console still carries them, and mouseModeFor then
asks for AllMotion on a host that may drop every mouse event: the #870
failure class through a narrower door.

Not fixed, and deliberately so. Telling an inherited variable from a live
one needs the console host rather than the environment, and erring the
other way costs every Windows Terminal user their hover highlighting for
a case that takes an unusual launch path to reach.

So the limit is written down in the code and pinned by a test that also
asserts ZERO_MOUSE_MODE=cell still overrides it, since that override is
the only recourse an affected user has. If real host detection lands
later the test fails by design, and its message says to delete it.

Raised by @jatmn on #872.
2026-08-10 09:57:44 +08:00
euxaristia 020c28eaf9 fix(tui): fill alt-screen frame with theme panel background (#850)
* fix(tui): fill alt-screen frame with theme panel background

Blank and cleared cells fell back to the terminal's own default
background instead of the theme's panel color, flashing white on
light themes during resize or when ClearScreen erases the frame.
Wrap the rendered content in zeroTheme.panel sized to the full frame
so every cell is explicitly painted.

* fix(tui): apply theme panel content to tea.View after render

tea.NewView stores the original string; reassigning the local content
variable left the view unchanged. Call SetContent after the panel
render so the alt-screen frame uses the themed output.
2026-08-10 09:57:25 +08:00
euxaristiaandgoogle-labs-jules[bot] 21e1e8d072 style(cli): flatten trust workspace resolve errors with %s (#849)
* fix: prevent leaking sensitive info in wrapped error responses

Modified `fmt.Errorf` calls in `internal/cli/trust.go` to use `%s` instead of `%w` when wrapping errors that are subsequently redacted via `redaction.ErrorMessage()`. This ensures the underlying error is flattened to a plain string, preventing sensitive information from being exposed later via `errors.Unwrap()`.

Co-authored-by: euxaristia <[email protected]>

* chore(cli): drop agent sentinel from trust error-format tidy

Remove .jules/sentinel.md (local agent artifact, not in main). Keep the
trust.go %s flatten as a readability tidy only; redaction.ErrorMessage
already stringifies inline, so this is not a security fix.

* style(cli): note intentional %s flatten on trust resolve errors

Keep a short comment at both sites so the %s form is not "fixed" back to %w; ErrorMessage stringifies inline.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-10 09:50:44 +08:00
936aa4e77c fix(lsp): dispatch notifications off read loop (#759)
* fix(lsp): dispatch notifications off read loop

* fix(lsp): keep notification reads non-blocking

* fix(lsp): make the notification queue lossless

Addresses the P1 on #759.

The previous commit traded the read-loop deadlock for silent protocol-event
loss: at 64 queued items enqueueNotification discarded the OLDEST message. A
textDocument/publishDiagnostics is the server's only report for its URI, so a
dropped one makes session.waitForDiagnostics time out and Manager.Check return
no diagnostics even though the server published them — and the overflow test
codified that loss as intended behavior.

The queue is now unbounded: the read loop still never blocks (so a handler may
make a re-entrant Client.Call), and nothing is discarded. Both alternatives are
worse — blocking on a full buffer is the original deadlock, and dropping loses
state the protocol never repeats. Growth is bounded in practice by what the
server emits while a handler runs; the backing array is released as soon as the
queue drains, so a burst does not retain capacity afterwards.

Tests: TestClientNotificationQueueIsLossless replaces the drop-oldest test, and
the end-to-end burst test now also asserts that all 512 notifications sent while
a handler was blocked in a re-entrant call arrive, in order. Both were checked
against the old drop-oldest policy and fail there (64 of 512 delivered, FIFO
broken), so they have teeth.

* fix(lsp): stop retaining notifications after Close

Addresses the shutdown defect CodeRabbit found in the lossless queue.

Client.Close signals the worker loop to exit, but readLoop keeps consuming
frames until the transport ends — and Server.Shutdown closes the client BEFORE
closing stdin, so a language server emitting notifications while it handles
shutdown/exit fed an unbounded queue that nothing would ever drain.

failPending's closeOnce now marks the queue closed and releases whatever is in
it, and enqueueNotification drops instead of appending once that flag is set.
Both run under notifyMu, so an enqueue racing with shutdown either sees the flag
and drops its item or appends just before it and has the item cleared. Queued
work is dropped rather than drained: the worker is gone by definition of close,
and callers that need diagnostics collect them before shutting the client down.

TestClientDropsNotificationsAfterClose keeps the transport writable after Close,
as Server.Shutdown does, and requires 64 subsequent notifications to neither
reach the handler nor accumulate. Verified against the unguarded enqueue, where
it reports 64 retained notifications.

* fix(lsp): baseline diagnostics against receipt, bound the notification backlog

Addresses jatmn's two #759 review findings:

- Manager.Check captured its diagnostics baseline from publishCount,
  which only advances once session.handleNotification actually runs
  for a URI. Dispatch happens off a queue now, so a publish for a
  version Check is about to supersede could already be sitting
  received-but-undispatched at baseline-capture time, then run
  afterward and look newer than the baseline purely because handling
  lagged receipt. Since many servers omit the version field, the
  existing staleness check in handleNotification (positive-version
  only) couldn't catch it either. Stamp every notification with a
  receipt sequence at enqueue time (Client.NotificationSeq), carry it
  through the queue to the handler, and baseline against receipt
  instead of against how many publishes have been processed.

- The lossless notification queue had no failure policy: a language
  server sustaining a higher notification rate than the single
  handler can drain (no stuck handler required) would retain every
  full json.RawMessage payload without bound. Add notifyQueueLimit;
  exceeding it fails and closes the client observably, letting the
  manager's existing dead-session eviction take over on next use,
  instead of growing the queue until the heap gives out.

Both regression tests are verified to fail against the pre-fix code:
the receipt-vs-baseline race was reproduced directly against the
pre-fix API (confirmed real, not hypothetical) before porting the
test to the fixed API, and the overload test was confirmed to fail
with the limit check removed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(lsp): bound notification backlog bytes

Track retained notification payload bytes in addition to queue length and fail the client before the backlog exceeds 16 MiB. Wake diagnostic waiters when that failure closes the client, preserving already-received fresh diagnostics during debounce. Add race-enabled regression coverage for byte overload cleanup and client-failure wakeup.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-b80c-717b-b041-5d5135a61d96

Co-authored-by: Pierre Bruno <[email protected]>

* fix(lsp): preserve diagnostics across client closure

When a fresh publish and client closure wake a diagnostic waiter together, reconcile against the recorded receipt sequence instead of allowing select order to discard the publish.

Add a regression that repeatedly makes both wakeups ready together and verifies diagnostics handled before closure are preserved.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-5dd3-7118-a7a6-fba8f9d5af29

Co-authored-by: Pierre Bruno <[email protected]>

* fix(lsp): drain accepted notifications on close

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(lsp): pin the receipt boundary at read time and finish the drain

Stamping a notification's receipt seq at enqueue left a window async dispatch
had opened: readMessage consumes the frame, then json.Unmarshal and the enqueue
run later on the same goroutine while another can capture a publishBaseline. A
publishDiagnostics for superseded text could land above a baseline taken after
it was already off the wire and — most servers omit the version field the
staleness check needs — reach the caller as the answer for the new text. The
read loop now stamps every frame the moment it leaves the wire and hands that
seq to the enqueue, so decoding cost sits outside the boundary. ReceiptSeq
replaces NotificationSeq: the clock counts frames, and gaps in it are fine.

Notification drain handling is now uniform. Every exit from waitForDiagnostics
catches the worker up on notifications it already accepted before reading what
was recorded, instead of only the seq<=baseline closure arm doing so. That
covers cancellation racing an accepted-but-queued publish (which used to make
Check return (nil, nil) for text the server had answered), and the debounce
closure arm returning while a newer publish was still queued (which handed the
caller the older one). After closure the drain is bounded — admission is shut
and a handler's re-entrant Call fails immediately — so it is no longer cut short
by ctx; on a live client the catch-up is bounded by a short grace instead.

Also cap Content-Length before allocating the body, so the notification byte
budget is authoritative rather than measured after a huge frame is materialized.

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

* fix(lsp): follow publishes accepted while catch-up is waiting

Catch-up snapshotted the accepted sequence once, but the reader keeps running
while it waits. The worker finishing the item that was last at the start
satisfied the check, so catch-up returned with a newer publish for the same URI
still queued — and the caller read diagnosticsFor and got the older one. That is
the failure the closed-client drain exists to prevent, reproduced on the live
path by the async dispatch this PR introduces.

The target is now re-read every pass. This cannot spin forever: the live-client
grace already caps the wait no matter how fast the server publishes, and a
closed client still drains unconditionally because its backlog cannot grow.

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

* test(lsp): wait for follow-up notification enqueue

Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>

* test(lsp): pin receipt stamping to read time, not enqueue

Regression test for the gap flagged in review pullrequestreview-4834747507
on #759: TestPublishBaselineRejectsFrameReadBeforeBaseline calls
stampReceipt directly and never drives readLoop, so it doesn't pin where
the stamp is taken. A response frame (read but never enqueued as a
notification) distinguishes the two call sites directly.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-08-10 09:50:03 +08:00
30f6d8f591 fix(specialist): make overwrites atomic (#757)
* fix(specialist): make overwrites atomic

* fix(specialist): harden atomic replacement

* fix(specialist): replace through a Windows-atomic, DACL-preserving primitive

Addresses both P1 findings on #757.

os.Rename — which fsutil.RenameWithRetry wraps — is documented non-atomic
outside Unix, and the retry loop only handles sharing violations. Worse, the
replacement is a freshly created temporary file, so renaming it over the
destination also publishes the directory's inherited DACL in place of the
destination's own: overwriting a specialist that had been explicitly restricted
inside a group-readable directory would expose its system prompt to that group.
temp.Chmod(0o600) cannot prevent that on Windows, where Go maps only the
owner-write bit.

New fsutil.ReplaceWithRetry publishes through a platform primitive:

- Windows uses ReplaceFileW with REPLACEFILE_WRITE_THROUGH, one operation that
  carries the replaced file's security descriptor, attributes, and streams over
  to the replacement. REPLACEFILE_IGNORE_ACL_ERRORS is deliberately not passed —
  silently losing the descriptor is the failure being fixed — so a merge failure
  surfaces and leaves the destination untouched. A missing destination falls back
  to a rename (nothing to replace or preserve), as do volumes that cannot perform
  a replace at all (FAT/exFAT, some network redirectors), which carry no ACL
  either. The transient-lock retry is unchanged.
- Unix keeps rename(2), which is already atomic within one filesystem.

Tests: Windows coverage asserts the destination's creation time survives (a
rename would not, proving the call goes through ReplaceFileW) and that a
protected owner-only DACL survives both at the fsutil layer and through the real
`Create --force` path; a plain rename was verified to replace that DACL with the
directory's inherited ACEs, so the assertion has teeth. POSIX covers both
destination shapes.

* fix(fsutil): stop ignoring ACL merge errors, recover partial ReplaceFileW

Addresses jatmn's two #757 review findings:

- The call passed REPLACEFILE_IGNORE_MERGE_ERRORS (0x2) while the
  comment claimed ACL failures were fail-closed. Microsoft documents
  0x2 and REPLACEFILE_IGNORE_ACL_ERRORS (0x4) identically: with
  either set, a call lacking WRITE_DAC "succeeds but the ACLs are not
  preserved". Passing 0x2 therefore let a --force overwrite silently
  publish the temp file's inherited directory DACL over a restricted
  specialist and expose its system prompt. Pass no flags at all.

- With no backup file, ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176) and
  ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177) leave the destination
  deleted (1176) or renamed to an unreachable name (1177) while the
  replacement survives only under its temporary name. Returning those
  unrepaired let the caller's deferred temp-file cleanup remove the
  sole surviving copy, so one failed 'specialist create --force'
  destroyed both the old manifest and the new one. recoverPartialReplace
  now moves the replacement into place and still reports the error,
  since the destination's descriptor died with the destination.

Also drops the doc claim that REPLACEFILE_WRITE_THROUGH (0x1) flushes
before returning: Microsoft documents that value as "not supported".

The two fsutil regression tests are verified to fail against the
pre-fix code. The specialist-side test guards the caller half of the
recovery contract and does not fail pre-fix; its comment says so.

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

* fix(fsutil): preserve Windows replacement backups

Use a managed ReplaceFileW backup so partial failures can restore the original file and DACL. Fail closed when atomic replacement is unsupported, retry rollback and backup cleanup, and retain a known backup if rollback cannot complete.\n\nCover unsupported volumes, 1176/1177 semantics, cleanup, rollback retries, and symlink rejection.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-be8e-75eb-b274-b7e22af373a0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(specialist): report committed cleanup warnings

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* docs(specialist): describe what a failed overwrite actually leaves behind

The Windows partial-replace recovery errors told an operator the replacement
"remains at" the caller's temporary path, but the only caller removes that file
in a deferred cleanup before the error ever surfaces — it named a file that was
already gone. Recovery from a failed overwrite is always about the original, so
the errors now describe only what the replace itself left on disk: the state of
the destination and of the managed backup. The replacement path is no longer a
parameter, since nothing reports it.

The one terminal state that needs a human — 1177 whose rollback exhausted its
retries — now says so outright. The original then exists only under an opaque
.zero-replace-*.backup name, and the loader reads *.md, so the specialist stays
invisible until somebody moves it back. The error names both paths and the
action; docs/SPECIALISTS.md walks through it; and loadDirectory records why the
non-.md siblings an interrupted overwrite can leave are skipped rather than
guessed at.

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

* fix(specialist): serialize managed loads during replacement

Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 09:49:32 +08:00
euxaristiaandgoogle-labs-jules[bot] c52db055b0 fix: suppress gosec G204 warning for trusted local config commands (#848)
Addresses a Gosec G204 (Command Injection) warning where provider command strings were passed directly into `sh -c` and `cmd /C`. Since these commands originate from the user's trusted local config and explicitly require shell features (like pipes and variable expansions) to function, removing the shell invocation introduces a breaking change. This adds a documented `#nosec` annotation to suppress the false positive instead of degrading functionality.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-10 09:12:58 +08:00
55fa05f0ab fix(sandbox): deny reads of Zero credential stores (#681)
* fix(sandbox): deny reads of zero credential stores

* fix(sandbox): address PR review comments

- Deny reads of mcp-oauth-tokens.json.secret in the default Zero
  config candidates: file-backed oauth stores keep their encryption
  secret in a sibling .secret file, so the default MCP token store
  needs the same protection as the override paths (CodeRabbit).
- Clarify that explicit credential-file overrides are still filtered
  by on-disk existence like every other candidate (Copilot).
- Align the zeroUserConfigDir doc comment with config.UserConfigDir:
  macOS honors XDG_CONFIG_HOME before falling back to ~/.config
  (Copilot).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sandbox): deny the whole Zero config dir, not itemized filenames

Address code review on PR #681:

- Deny credentialDenyReadPaths' Zero-config candidate as the containing
  directory instead of an itemized filename list. The token/credential/
  config stores each publish through a randomly-named .tmp-<pid>-<nanos>
  sibling before their atomic rename, and the legacy MCP token store
  leaves a mcp-oauth-tokens.json.migrated backup after importing it —
  none of those names were covered by the old itemized list, so a
  sandboxed command could read them under the read-all posture.

- Stop dropping default candidates that don't exist yet at profile-build
  time. A store created later in a long-lived sandboxed session (e.g. a
  concurrent ) previously got no deny rule at all; every
  backend already treats a deny rule over a not-yet-existing path as a
  harmless no-op that still takes effect once the path appears.

- Resolve ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH overrides
  the same way the token stores resolve them (relative-to-cwd, no ~
  expansion) instead of through normalizeProfilePath, which tilde-expands
  and so could derive a deny path different from where the store actually
  writes.

Adds regression coverage for the directory-wide deny (including the
migrated backup and synthetic temp-file siblings), for building the
profile before the store directory exists, and for the tilde-override
resolution mismatch.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(sandbox): address macOS deny-path test and config parity

Recompute the expected Zero config deny path after MkdirAll and resolve
the temp base with EvalSymlinks so macOS /var -> /private/var does not
flake TestPermissionProfileDeniesZeroCredentialFiles. Add a parity test
that sandbox.zeroUserConfigDir stays aligned with config.UserConfigDir.

Co-authored-by: PierrunoYT <[email protected]>

* fix(sandbox): harden credential deny paths

* test(cli): normalize credential deny baseline

* fix(sandbox): close credential path gaps

* test(sandbox): normalize command credential path

* fix(sandbox): handle absent credential paths safely

* fix(sandbox): protect MCP OAuth siblings

* fix(sandbox): protect OAuth lock siblings

* docs(sandbox): clarify Linux future-path limits

* test(sandbox): probe credential store reads in the Linux smoke test

The smoke test wired HOME/XDG_CONFIG_HOME at credential directories but never
attempted a read through the sandbox, so it could not catch a real-bwrap
regression for the paths this change protects. Write a secret into ~/.aws and
$XDG_CONFIG_HOME/zero and add both to the deny-read table.

* fix(sandbox): keep the credential deny usable, current, and unguessable

Four problems with the credential deny baseline, all in the same mechanism.

The whole-directory deny over $XDG_CONFIG_HOME/zero also hid the supported
user plugin, specialist, and command roots, whose commands and scripts are
executed through this sandbox, so an installed user plugin could no longer
start. Narrowing the deny to itemized files was not an option: the stores
publish through temporary names and a concurrent login can add a store that
did not exist when the profile was built, and only a directory rule covers
both. The directory deny therefore stays, and the non-secret subtrees are
carved back out through a new DenyReadCarveouts field: seatbelt re-allows
them after the deny (SBPL is last-match-wins) plus ancestor metadata so path
resolution can reach them, and bubblewrap masks the directory with --perms
111 instead of 000 -- unlistable but traversable -- and re-binds the carveout
read-only before the tmpfs is remounted read-only.

A relative ZERO_OAUTH_TOKENS_PATH, ZERO_MCP_OAUTH_TOKENS_PATH, or
XDG_CONFIG_HOME was resolved against the command directory only, while
oauth.ResolveStorePath and mcp.ResolveTokenStorePath call filepath.Abs, i.e.
resolve against the Zero process working directory. A command run from a
subdirectory therefore had the wrong path denied and the real store stayed
readable under the read-all posture. Relative candidates now resolve against
both directories, since the process that resolves the value is not
necessarily the one that writes the store. The regression test runs the
command from a different directory than the process, which the previous
tests did not.

bubblewrap cannot mount over a path that is absent when the namespace is
assembled, so a store created mid-session stayed readable through the live
read-only host-root bind for the rest of a long-lived sandbox session. The
directories Zero owns -- its config directory and the per-store publication
directories -- are now reported as EnsureDenyReadDirs and created (0700)
before the namespace is assembled, so their mask always exists. Third-party
stores such as ~/.aws are still skipped when absent: Zero must not create
them, and that limitation stays documented on the field.

The fixed <store>.tmp publication sibling made the plaintext token, and via
the same path its encryption key, observable to a same-user sandboxed process
with write access to an override parent: it could wait for the known name and
rename the opened inode into an allowed path. Both stores now publish through
a per-store <store>.publish directory with a randomly-named file inside. The
directory name is derived from the store path, so the profile can deny it up
front, while the random name inside is what cannot be waited for or renamed
away. A test keeps the duplicated suffix in step with oauth.PublicationDirSuffix.

Also adds the seatbelt render coverage that was missing: dropping
DenyReadIfExists from denyReadRules made every credential store readable
again on macOS without failing a single test.

* fix(sandbox): close credential deny review gaps

Preserve process credential roots alongside command overrides while limiting host precreation and read carveouts to trusted process-derived paths.

Canonicalize future credential roots through existing path aliases, reject symlink carveouts, and collapse automatic masks already covered by user or automatic directory denies. Normalize the policy golden and add regression coverage across profile, Seatbelt, and bubblewrap rendering.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): normalize credential carveout paths

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): canonicalize backend credential masks

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* test(sandbox): normalize backend mask expectations

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): fail closed for replaceable token stores

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): allow keyring token path overrides

Amp-Thread-ID: https://ampcode.com/threads/T-019fbdfa-8469-731e-87c0-b26b7641cf59
Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): harden command credential roots

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-c723-74d9-a323-026040b94436
Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): close credential deny review gaps

Reapply credential denies after Seatbelt extension carveouts and model the legacy MCP token path as a migration input rather than an atomic writer.

Retain Bubblewrap fail-closed markers for OAuth outputs exposed through readable carveouts, and add regressions for both platform policies.

Amp-Thread-ID: https://ampcode.com/threads/T-019fbf55-f6b0-71fc-b384-652ba32b9645
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: PierrunoYT <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 07:33:29 +08:00
1057d74512 docs(readme,cli): document /new & zero upgrade, add Discord link, and sync help text (#489)
* docs(readme): document /new, zero upgrade, and add Discord link

Add the /new slash command and zero upgrade command to the README's
command references, expand docs/UPDATE.md to cover the --apply install
flow, and link the project Discord server.

* docs(update): escape pipe in --endpoint table cell

CodeRabbit flagged the unescaped | in the --endpoint placeholder
breaking the Markdown table layout.

* docs(readme-zh): bring /new and zero upgrade in line with README.md

README_ZH.md had picked up the Discord link but was still missing the
/new slash command and zero upgrade entries added to README.md,
letting the translation drift out of sync.

* cli: add upgrade to root help output

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* docs: make required update flag explicit in common commands

zero update errors without --check or --apply, so the bare form in
the common-commands examples was misleading. Addresses the Copilot
finding from the PR #489 review.

* docs: use a permanent (non-expiring) Discord invite in the READMEs

* docs(cli): match upgrade help summary to download/verify/install wording

Copilot flagged the root zero --help summary for upgrade as less
specific than the download/verify/install phrasing already used in
README.md and docs/UPDATE.md for the same command.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs: use discord.gg short link for the community invite

discord.com/invite/<code> and discord.gg/<code> resolve to the same
invite; discord.gg is the canonical short form to publish.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(update,readme-zh): keep npm --json stdout clean, add ZH Discussions route

`zero upgrade --json` (and `zero update --apply --json`) on an npm-managed
install wired npm's own stdout directly to the process stdout, so its
progress output landed ahead of the JSON result and made it unparseable.
Route npm's stdout into a buffer when JSON output is requested, only
surfacing it in the error message if the install fails.

README_ZH.md's Community section only pointed to Discord, unlike the
English README, which reserves Discord for real-time chat and routes
Q&A/ideas/sharing to GitHub Discussions per SUPPORT.md/CONTRIBUTING.md
policy. Bring the Chinese section in line with the same split.

Addresses the outstanding findings from jatmn's review on PR #489.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs(update): qualify npm-managed JSON output

Document the npm subprocess output caveat instead of promising a single parseable JSON document for apply operations. Drop the JSON-specific updater plumbing and tests so the PR remains focused on documentation and help discovery.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-8bcd-71bc-9bde-9b4c28b78b38

Co-authored-by: Pierre Bruno <[email protected]>

* docs(update): align upgrade guidance

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): keep the upgrade recommendation about this machine

`zero update --check --target windows-x64` answers a question about a different
machine, but the new call to action told the user to run `zero upgrade`, which
only ever installs onto this one — replacing the target-specific download
guidance the flag used to print. The recommendation is now conditional: a
current-platform check keeps it, and a cross-target check gets the manual
instruction plus a note naming the machine `zero upgrade` would have touched
instead.

Also align two flag contracts with what the code does: `zero update --help`
described --repo/--endpoint as check-only although apply honors them, and
UPDATE.md did not mention that both are ignored on an npm-managed apply, which
takes its release from the npm registry rather than GitHub.

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

* fix(update): do not send a custom-source check to the default source

`zero upgrade` is a fresh invocation: it does not inherit the --repo/--endpoint
of the check that suggested it, so recommending it bare after a custom-source
check told the user to install from a repository they never asked about. The
check now records the flag it was given and the guidance repeats it, naming
where a bare upgrade would install from instead.

ZERO_UPDATE_RELEASE_URL is deliberately not recorded: every Check reads it from
the environment, including the one inside Apply, so a bare upgrade already
follows it and telling the user to repeat it would be wrong.

Two smaller edges from the same review: a cross-target check on a host with no
published release target no longer ends by describing what `zero upgrade` does
there, since it does not work there at all; and the flag table and help text now
carry the npm caveat the prose bullet already had, so docs and help agree.

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

* fix(update): keep source guidance accurate

Amp-Thread-ID: https://ampcode.com/threads/T-019fafa2-7d9d-75bc-8801-6d0efc8db1df
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 07:32:53 +08:00
Anandan f922cb378e Add cross-session messaging (#884)
* Add cross-session messaging

* Harden cross-session messaging

Authenticate sender metadata against live registry records, bound peer lifecycle state, preserve records across transient discovery failures, and make receipt delivery direct and bounded.

Also serialize TUI peer approvals, harden platform runtime paths and transport deadlines, widen session references, and add regression coverage for identity spoofing, queue limits, path boundaries, and tool failures.

* Support canonical macOS socket paths

Resolve Unix transport directories to their physical path before applying strict no-symlink traversal, allowing the standard /var to /private/var macOS alias without weakening runtime-directory validation.

* Fix macOS socket path regression test

Create the canonicalization fixture beneath a guaranteed short Unix path so it exercises the primary endpoint on macOS, and report temporary-directory cleanup failures.

* Harden peer messaging lifecycle

* Fix Windows peer directory traversal

* Handle Windows token-owned peer directories

* Accept equivalent Windows ACL masks

* Build Windows peer ACL explicitly

* Pin Windows ACL trustee SIDs

* Validate Windows peer ACL semantics

* Propagate Windows peer handle cleanup errors

* Use null handle for Windows close failure test

* Test held-message eviction callback
2026-08-09 18:12:09 +05:30
Anandan 7f39a63067 Centralize agent context planning (#879)
* Centralize agent context planning

* Fix context plan request accounting

* Freeze planned tool schemas
2026-08-08 16:40:17 +05:30
Anandan ff608c7834 Reduce compaction context while preserving task state (#873)
* Reduce compaction context while preserving task state

* Fix durable compaction evidence identity

* Preserve structured tool errors during compaction

* Unify manual and automatic compaction

* Report compaction truncation structurally

* Preserve automatic compaction metadata
2026-08-08 11:26:27 +05:30
Anandan 57858a31d1 Improve source read efficiency and model compatibility (#867)
* Improve source read efficiency and compatibility

* Preserve lexical context in ranged reads

* Fix bounded source read edge cases

* Prevent overflow in ranged minified reads

* Trim eager read tool schemas
2026-08-07 21:29:45 +05:30
280 changed files with 41530 additions and 1443 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.6.0"}
{".":"0.7.0"}
+8
View File
@@ -91,6 +91,14 @@ These classes drive multi-round reviews. Fix them before requesting review:
final-component-only no-follow is insufficient. On multi-step setup, roll back
only what this run created; never destroy pre-existing resources you did
not create; never report success when cleanup or unlock failed.
- **Normalize before matching:** Any transform that REMOVES bytes without
leaving a gap is also a reassembler, so it has to run before whatever matches
on the result. Redacting by shape and then stripping control bytes lets a
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
fragments and be rejoined on the way out; comparing a path against a form
produced by the same resolver the kernel just used makes a redirect agree with
itself. Strip, decode, or canonicalize first, then match. The unsplit value
passing is not evidence, so the test needs a split case.
- **Atomic shared state:** Write a complete temporary file, then atomically
replace the destination so concurrent readers never see a partial write.
Exclusive create or a write lock alone is not enough for readers. Serialize
+36
View File
@@ -6,6 +6,42 @@ 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.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
### Features
* **agent:** add PermissionModePlan for interactive read-only planning ([#853](https://github.com/Gitlawb/zero/issues/853)) ([0176ee4](https://github.com/Gitlawb/zero/commit/0176ee49ffddcff6f7edf6ae2ade4967e5b22cc3))
* **providers:** live model lists for OpenRouter and OpenGateway ([#860](https://github.com/Gitlawb/zero/issues/860)) ([fb4f293](https://github.com/Gitlawb/zero/commit/fb4f293c20eb860ca388a10d22fd566ce282ba1f))
* **tools:** give tool results an image channel and add view_image ([#843](https://github.com/Gitlawb/zero/issues/843)) ([cd0eb19](https://github.com/Gitlawb/zero/commit/cd0eb194e4df4b844785796c0701216740a56344))
* **tui:** replace /retitle with local /rename ([#826](https://github.com/Gitlawb/zero/issues/826)) ([5886354](https://github.com/Gitlawb/zero/commit/588635487f22ef4d62d350140a219b7b8bb24119))
### Bug Fixes
* **agent:** end the additional_permissions retry loop ([#864](https://github.com/Gitlawb/zero/issues/864)) ([4ca4fa7](https://github.com/Gitlawb/zero/commit/4ca4fa75d31b139dbe55a3badf17da86892ab001))
* **agent:** tell the user a prefix approval leaves the sandbox ([#885](https://github.com/Gitlawb/zero/issues/885)) ([2ec5c3a](https://github.com/Gitlawb/zero/commit/2ec5c3ae6b8fb543887bdeafdb34c4da057b8d36))
* **cli:** report MCP servers that failed to start ([#822](https://github.com/Gitlawb/zero/issues/822)) ([#827](https://github.com/Gitlawb/zero/issues/827)) ([021281e](https://github.com/Gitlawb/zero/commit/021281ebbab3f2bbe6a77b2dab4bf121bcfe6b52))
* **lsp:** dispatch notifications off read loop ([#759](https://github.com/Gitlawb/zero/issues/759)) ([936aa4e](https://github.com/Gitlawb/zero/commit/936aa4e77cf49738560a89395b8ba4d22263a396))
* **mcp:** name the non-text blocks a tool result drops ([#874](https://github.com/Gitlawb/zero/issues/874)) ([027a670](https://github.com/Gitlawb/zero/commit/027a67080bea511b876c284bc911ef011eceb308))
* **sandbox:** deny reads of Zero credential stores ([#681](https://github.com/Gitlawb/zero/issues/681)) ([55fa05f](https://github.com/Gitlawb/zero/commit/55fa05f0ab5a01b495345d43464606cd19a31b4f))
* **sandbox:** stop the Windows write jail honouring Everyone-granted paths ([#865](https://github.com/Gitlawb/zero/issues/865)) ([91b413c](https://github.com/Gitlawb/zero/commit/91b413c514cc1607d5b4e7936780d40569239814))
* **secrets,worktrees:** fix secret redaction leakage and prune stale worktrees ([#855](https://github.com/Gitlawb/zero/issues/855)) ([ff62f73](https://github.com/Gitlawb/zero/commit/ff62f73b7f4dc5e2472db21acba43cf6140c06d1))
* **specialist:** make overwrites atomic ([#757](https://github.com/Gitlawb/zero/issues/757)) ([30f6d8f](https://github.com/Gitlawb/zero/commit/30f6d8f591a0b81638b4182ff56c86c8a50a5c70))
* suppress gosec G204 warning for trusted local config commands ([#848](https://github.com/Gitlawb/zero/issues/848)) ([c52db05](https://github.com/Gitlawb/zero/commit/c52db055b0b213758a33c5c5627a411e052f8de4))
* **tui:** a copied NUL byte no longer panics the TUI on Windows ([#876](https://github.com/Gitlawb/zero/issues/876)) ([cae0269](https://github.com/Gitlawb/zero/commit/cae0269e77e7a0a493cf799b41c653c9ff9a19de)), closes [#875](https://github.com/Gitlawb/zero/issues/875)
* **tui:** exclude permission waits from turn time ([#820](https://github.com/Gitlawb/zero/issues/820)) ([15eb16f](https://github.com/Gitlawb/zero/commit/15eb16f736f903c94203207032f98aacbcf06318))
* **tui:** fall back to CellMotion where AllMotion is unreliable ([#872](https://github.com/Gitlawb/zero/issues/872)) ([7fbd864](https://github.com/Gitlawb/zero/commit/7fbd864e91433441489902571ea394ebc5b7bd0d))
* **tui:** fill alt-screen frame with theme panel background ([#850](https://github.com/Gitlawb/zero/issues/850)) ([020c28e](https://github.com/Gitlawb/zero/commit/020c28eaf95e2f38853f99fc06a13cd1568bf3c9))
* **tui:** hide unavailable sidebar shortcut ([#821](https://github.com/Gitlawb/zero/issues/821)) ([845df6c](https://github.com/Gitlawb/zero/commit/845df6c7d28e02f475e90acfe838733eb37e6aa2))
* use OAuth credentials in provider health checks ([#828](https://github.com/Gitlawb/zero/issues/828)) ([d37de92](https://github.com/Gitlawb/zero/commit/d37de9214b1ba86542f4f5ef3b060bb96849763b))
### Performance Improvements
* compact common test output ([#846](https://github.com/Gitlawb/zero/issues/846)) ([edf660a](https://github.com/Gitlawb/zero/commit/edf660ab85131f90a452cee51dc2c3a5cf62d632))
* improve harness context efficiency ([#838](https://github.com/Gitlawb/zero/issues/838)) ([8e26679](https://github.com/Gitlawb/zero/commit/8e266797a8305e689806eadcf9698da2796481ab))
## [0.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
+6 -1
View File
@@ -8,6 +8,7 @@
<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="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>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
@@ -212,6 +213,7 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/new` | start a fresh session in place (previous session stays on disk) |
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
@@ -308,7 +310,8 @@ zero verify detect and run local verification checks
zero changes inspect and commit local git changes
zero usage token usage and estimated cost
zero cron scheduled agent jobs
zero update check for newer releases
zero update --check check for newer releases
zero upgrade download, verify, and install the latest release
```
## Extending Zero
@@ -415,6 +418,8 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
## Community
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
+18 -1
View File
@@ -8,6 +8,7 @@
<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="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>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
@@ -141,6 +142,7 @@ zero
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
| `/image` | 为视觉模型附加图片 |
| `/resume`、`/rewind` | 继续或回滚本地会话 |
| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) |
| `/compact`、`/context` | 管理上下文使用 |
| `/permissions`、`/tools` | 检查可用工具和策略 |
| `/add-dir` | 为此会话授予额外的写入目录 |
@@ -226,7 +228,8 @@ zero verify 检测和运行本地验证检查
zero changes 检查和提交本地 Git 变更
zero usage Token 使用量和估算成本
zero cron 定时智能体任务
zero update 检查更新版本
zero update --check 检查更新版本
zero upgrade 下载、验证并安装最新版本
```
## 外观和无障碍
@@ -269,6 +272,20 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [性能](docs/PERFORMANCE.md)
- [智能体评估](docs/AGENT_EVALS.md)
## 社区
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
+3 -2
View File
@@ -234,10 +234,11 @@ directory.
## Updating
Check for a newer release:
Check for a newer release, then download, verify, and install it in place:
```bash
zero update --check
zero upgrade
```
Then reinstall with npm or rerun the install script for the version you want.
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
+42
View File
@@ -138,3 +138,45 @@ output or stop a still-running task by id.
If Zero is restarted while a background task is still marked `running`, the new
manager marks that task `error` and clears its PID. This avoids sending
`TaskStop` to a stale PID that may now belong to an unrelated process.
## Recovering an Interrupted Overwrite
Zero writes and flushes a complete temporary file before publishing an overwrite,
so a write failure before publication leaves the existing manifest unchanged
instead of truncating it. On Unix, publication uses a same-directory rename and
preserves the existing file's permission bits.
On Windows, Zero uses `ReplaceFileW` to preserve the destination DACL instead of
silently replacing it with the temporary file's inherited DACL. `ReplaceFileW`
is not observer-atomic: another process can briefly observe the destination path
as absent during replacement. Zero serializes specialist loads and managed
mutations within one process, but cannot synchronize external processes or
editors.
Windows errors 1176 (`ERROR_UNABLE_TO_MOVE_REPLACEMENT`) and 1177
(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) are partial replacement failures. With
Zero's managed backup, 1176 leaves the original names intact and needs no manual
recovery. For 1177, Zero has moved the original aside and tries to move it back.
That rollback almost always succeeds, and the failed write changes nothing.
If the rollback itself fails — typically because another process is holding a
lock on the file — the original is not lost, but it is left under a name Zero
does not read:
```text
<specialist dir>/.zero-replace-<random>.backup
```
Only `*.md` files are loaded as specialists, so until that file is renamed the
specialist will not appear in `zero specialist list` or resolve by name. Zero's
error message includes both the backup path and destination path. Recover by
closing whatever holds the lock and renaming the backup back:
```powershell
Move-Item .zero-replace-<random>.backup <name>.md
```
A `.zero-replace-*.backup` can also linger after a *successful* overwrite if the
backup could not be deleted afterward. That case is reported as a warning rather
than an error — the new manifest is already in place, and the leftover file is
safe to delete.
+75 -7
View File
@@ -1,16 +1,23 @@
# Update Flow
`zero update --check` checks the latest GitHub release and compares it with the
local CLI version.
local CLI version. `zero update --apply` (or its shorthand, `zero upgrade`)
downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Gitlawb/zero
zero update --check --target windows-x64
zero upgrade
zero update --apply
```
The command is intentionally check-only:
`--check` and `--apply` are mutually exclusive. `zero update` requires one of
them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--check` is check-only:
- It does not replace the running binary.
- It exits with code `0` when the check succeeds, even when an update is
@@ -19,14 +26,35 @@ The command is intentionally check-only:
- `--json` prints the same result in a machine-readable format for scripts and
CI.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
- On Windows, the running executable is renamed aside and cleaned up on the
next `zero update --apply` or `zero upgrade`, since it can't be overwritten
while running.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
takes its release from the npm registry, not from GitHub. They still apply to
`--check` there.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
also write progress output to stdout, so neither
`zero update --apply --json` nor `zero upgrade --json` is guaranteed to
produce a single parseable JSON document. Use `--check --json` for
machine-readable automation.
Useful flags:
| Flag | Purpose |
|---|---|
| `--repo <owner/repo>` | Check another GitHub repository. |
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
| `--repo <owner/repo>` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
| `--endpoint <url\|owner/repo>` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
| `--timeout <duration>` | Override the default release check timeout. |
| `--target <platform-arch>` | Validate release metadata for another supported target. |
| `--target <platform-arch>` | Validate release metadata for another supported target (`--check` only). |
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
@@ -40,5 +68,45 @@ Endpoint resolution order:
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
before reinstalling.
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
instead of reinstalling.
## Windows recovery state (standalone installs)
This section describes Windows only. On Linux and macOS a standalone update
renames the staged file directly over the executable path through the
installation directory's file descriptor, so the replacement is atomic and no
aside copy, marker, or recovery record is ever created — a failed update leaves
the previous binary in place and nothing to resolve.
On Windows, a running executable cannot be replaced in place, so the previous
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
records that exact file — bound to its filesystem identity, in per-user state
outside the installation directory — and deletes only that recorded copy after
the next update is verified in place. Backups it did not create are never
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
recorded copy that something else holds open (an on-access scanner, an editor)
stays recorded and is removed by a later update instead.
An update **refuses to run** while unresolved recovery state exists beside the
binary:
| State on disk | Meaning |
|---|---|
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
The refusal names the paths involved and the two moves that end the state:
either move the recovery binary back over the executable path, or — if the
installed binary is the one you want — delete the `.keep` marker (or the
`.recovery` copy, once you have verified the installed binary) and update again.
This is deliberately fail-closed and differs from older releases, which deleted
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
and make every subsequent update refuse until an operator clears them. That is
the safe direction: the alternative is an update that overwrites the only
verified copy of the previous binary. If updates start refusing, inspect those
files before removing them — an installation directory that a lower-privileged
account can write to is itself worth fixing.
+4 -2
View File
@@ -1,25 +1,27 @@
module github.com/Gitlawb/zero
go 1.26.5
go 1.26.6
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/Microsoft/go-winio v0.6.2
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/coder/websocket v1.8.15
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/image v0.45.0
golang.org/x/sys v0.47.0
mvdan.cc/sh/v3 v3.13.1
)
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
+6
View File
@@ -4,6 +4,8 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
@@ -62,6 +64,10 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+30 -6
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) {
@@ -349,9 +363,11 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk:
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
@@ -381,9 +397,13 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
return nil, err
}
case configIDMode:
// Same turnMu as handleSetMode so the two advertised mode doors (session
// set_mode and set_config_option) serialize mode flips consistently.
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk:
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
@@ -439,13 +459,16 @@ func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
// ---- advertising helpers ----
func (a *Agent) modeState(s *acpSession) *SessionModeState {
// Only auto/ask are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access.
// auto/ask/plan are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access. Plan
// only narrows what a client can do (read-only, no write/shell tools), so
// unlike Unsafe there is no elevation risk in letting a client select it.
return &SessionModeState{
CurrentModeID: string(s.currentMode()),
AvailableModes: []SessionMode{
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{ID: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{ID: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
},
}
}
@@ -516,6 +539,7 @@ func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
Options: []SessionConfigOptionValue{
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{Value: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
},
}}
}
+57
View File
@@ -393,6 +393,32 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
t.Fatalf("configured mode = %q", got)
}
// Plan is accepted: it only narrows capability (read-only), so unlike Unsafe
// there is no elevation risk in letting a client select it.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode plan: %v", err)
}
// Configuration path is a separate contract from MethodSessionSetMode: the
// mode option is advertised via configOptions and applied by handleSetConfigOption.
var planConfigured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModePlan),
}, &planConfigured); err != nil {
t.Fatalf("set_config_option plan: %v", err)
}
if got := planConfigured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModePlan) {
t.Fatalf("configured mode after plan = %q, want plan", got)
}
hasPlanOption := false
for _, opt := range planConfigured.ConfigOptions[1].Options {
if opt.Value == string(agent.PermissionModePlan) {
hasPlanOption = true
break
}
}
if !hasPlanOption {
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
@@ -403,6 +429,37 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
}
}
// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
// the same gap this test's TUI counterpart covers for /plan on.
func TestACPPlanModeWiresPermissionModeIntoAgentOptions(t *testing.T) {
deps := testDeps(t)
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode plan: %v", err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("plan it out")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
}
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
// shell tools run confined, not unconfined on the host.
+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)
}
}
+77 -22
View File
@@ -15,17 +15,10 @@ 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)
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
if kind == "" {
continue // skip actions that have no clean ACP option (e.g. cancel)
}
@@ -38,21 +31,61 @@ 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).
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
//
// escalates reports that approving a command prefix will also run the command
// outside the sandbox. The two prefix options say so in their label, since the
// label is all an ACP client shows and the option id carries no room for it. An
// editor user is deciding on exactly the same terms as a terminal user, so the
// 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:
return PermAllowAlways, "Allow this command for the session"
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
case agent.PermissionDecisionAlwaysAllow:
return PermAllowAlways, "Always allow"
case agent.PermissionDecisionAlwaysAllowPrefix:
return PermAllowAlways, "Always allow this command"
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
case agent.PermissionDecisionDeny:
return PermRejectOnce, "Reject"
case agent.PermissionDecisionCancel:
@@ -64,19 +97,31 @@ func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
// 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:
@@ -84,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
}
}
@@ -123,3 +168,13 @@ func rawInputBytes(data []byte) json.RawMessage {
}
return json.RawMessage(data)
}
// withSandboxEscalationNote appends the escalation disclosure to a prefix
// option's label. Kept parallel to the TUI's wording so the same consequence is
// not described two different ways depending on which client the user runs.
func withSandboxEscalationNote(name string, escalates bool) string {
if !escalates {
return name
}
return name + " (runs outside the sandbox)"
}
@@ -0,0 +1,69 @@
package acp
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
return agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAlwaysAllowPrefix,
agent.PermissionDecisionDeny,
},
PrefixApprovalEscalates: escalates,
}
}
// An ACP client renders only Name, so if the escalation is not in the label the
// editor user approves it blind. They face exactly the outcome a terminal user
// faces, so the disclosure has to reach both clients or it is not a fix.
func TestACPPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
found := 0
for _, option := range buildPermissionOptions(escalationRequest(true)) {
action := agent.PermissionDecisionAction(option.OptionID)
if action != agent.PermissionDecisionAllowPrefix && action != agent.PermissionDecisionAlwaysAllowPrefix {
if strings.Contains(option.Name, "sandbox") {
t.Errorf("option %q carries the disclosure but does not escalate", option.Name)
}
continue
}
found++
if !strings.Contains(option.Name, "outside the sandbox") {
t.Errorf("prefix option %q does not say the command leaves the sandbox", option.Name)
}
}
if found != 2 {
t.Fatalf("expected both prefix options, saw %d", found)
}
}
func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
for _, option := range buildPermissionOptions(escalationRequest(false)) {
if strings.Contains(option.Name, "sandbox") {
t.Errorf("option %q warns about the sandbox when approving would not leave it", option.Name)
}
}
}
// The disclosure must live in the label only. The option id is the decision
// action round-tripped verbatim, and decisionFromOutcome matches it against
// what was offered, so decorating it would fail every prefix approval closed.
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
request := escalationRequest(true)
options := buildPermissionOptions(request)
for _, option := range options {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
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)
}
+81 -20
View File
@@ -38,8 +38,9 @@ const compactionTriggerRatio = 0.7
// transcript (and so tests can assert on it).
const summaryLabel = "[Summary of earlier conversation]"
// summaryInstructions is the system prompt handed to the summarizer model.
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
// CompactionSummaryInstructions is the system prompt handed to both automatic
// and manually requested compaction summarizers.
const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
@@ -75,6 +76,12 @@ type CompactionResult struct {
// summary message also includes summaryLabel and any preserved structured
// state needed by later compactions.
SummaryText string
// ProjectedChars is the text content sent to the summarizer after semantic
// projection, excluding provider protocol framing.
ProjectedChars int
// Truncated reports whether projection dropped content to satisfy its
// bounded brief or previous-summary limits.
Truncated bool
// Compacted reports whether Messages contains an injected summary.
Compacted bool
}
@@ -216,11 +223,11 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
}, nil
}
summary, err := opts.Summarize(middle)
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
if err != nil {
return CompactionResult{}, err
}
summary = strings.TrimSpace(summary)
summary := summaryResult.SummaryText
// Preserve structured state (active plan + loaded skills) from the elided
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
@@ -238,10 +245,53 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
RemovedCount: len(middle),
PreservedCount: len(messages) - len(middle),
SummaryText: summary,
ProjectedChars: summaryResult.ProjectedChars,
Truncated: summaryResult.Truncated,
Compacted: true,
}, nil
}
// CompactionSummaryResult describes the shared summarization input and output.
type CompactionSummaryResult struct {
SummaryText string
ProjectedChars int
Truncated bool
}
// SummarizeCompactionMessages applies the shared semantic projection before
// invoking a caller-supplied summarizer. Automatic context-pressure compaction
// and manual session compaction both use this path.
func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) {
if summarize == nil {
return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function")
}
projection := projectCompactionInput(messages)
summaryInput := projection.messages
if len(summaryInput) == 0 {
summaryInput = messages
}
summary, err := summarize(summaryInput)
if err != nil {
return CompactionSummaryResult{}, err
}
return CompactionSummaryResult{
SummaryText: strings.TrimSpace(summary),
ProjectedChars: compactionMessageChars(summaryInput),
Truncated: projection.truncated,
}, nil
}
func compactionMessageChars(messages []zeroruntime.Message) int {
total := 0
for _, message := range messages {
total += len(message.Content)
for _, call := range message.ToolCalls {
total += len(call.Name) + len(call.Arguments)
}
}
return total
}
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
// the preserved suffix begins on a user or assistant message rather than a
// tool/tool_result message. A tool result with no preceding assistant tool call
@@ -331,6 +381,8 @@ type compactionState struct {
// but its token COST must still be counted so usage reports and budgets include it.
onUsage func(Usage)
task *taskState
planner *contextPlanner
trace *trace.Recorder
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
@@ -377,6 +429,8 @@ func newCompactionState(options Options, task *taskState) *compactionState {
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
}
return state
}
@@ -432,7 +486,7 @@ func (state *compactionState) maybeCompact(
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: state.preserveLast,
Summarize: summarizeClosure(ctx, provider, state.onUsage),
Summarize: state.summarizeClosure(ctx, provider),
taskState: state.task.snapshotForCompaction(messages),
})
if err != nil {
@@ -483,7 +537,7 @@ func (state *compactionState) recover(
result, compactErr := Compact(messages, CompactionOptions{
PreserveLast: state.preserveLast,
Summarize: summarizeClosure(ctx, provider, state.onUsage),
Summarize: state.summarizeClosure(ctx, provider),
taskState: state.task.snapshotForCompaction(messages),
})
if compactErr != nil {
@@ -517,9 +571,12 @@ 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.
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, 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) {
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -531,7 +588,11 @@ func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage
// Non-context-limit errors (and a single message that still won't fit) surface
// to the caller unchanged.
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
}
func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage, planner, recorder)
if err == nil {
return summary, nil
}
@@ -540,11 +601,11 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
}
mid := len(messages) / 2
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
if leftErr != nil {
return "", leftErr
}
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
if rightErr != nil {
return "", rightErr
}
@@ -557,7 +618,7 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
combined := strings.TrimSpace(left + "\n\n" + right)
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: combined},
}, onUsage)
}, onUsage, planner, recorder)
if reduceErr != nil {
if isContextLimitError(reduceErr.Error()) {
// Even the two combined partials don't fit (extreme): fall back to the
@@ -573,15 +634,15 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
}
// summarizeMessagesOnce performs a single tool-less summarization call.
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
},
// No tools: this is a plain text summarization call.
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
requestMessages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
}
stream, err := provider.StreamCompletion(ctx, request)
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
recordContextPlanTrace(recorder, plan)
stream, err := provider.StreamCompletion(ctx, plan.Request)
if err != nil {
return "", err
}
+43 -2
View File
@@ -43,8 +43,14 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
if result.SummaryText != "manual summary" {
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
}
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
}
if result.Truncated {
t.Fatal("Truncated = true for a projection within its limits")
}
if len(captured) != 1 || !strings.Contains(captured[0].Content, "first question") || !strings.Contains(captured[0].Content, "second question") {
t.Fatalf("summarized projection = %#v, want intent from the non-preserved middle", captured)
}
if len(result.Messages) != 4 {
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
@@ -96,11 +102,46 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
if result.SummaryText != "" {
t.Fatalf("SummaryText = %q, want empty", result.SummaryText)
}
if result.ProjectedChars != 0 || result.Truncated {
t.Fatalf("no-op projection metadata = (%d, %t), want zero values", result.ProjectedChars, result.Truncated)
}
if !reflect.DeepEqual(result.Messages, messages) {
t.Fatalf("Messages changed on no-op: %#v", result.Messages)
}
}
func TestCompactMessagesReturnsTruncatedProjectionMetadata(t *testing.T) {
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}}
for index := range 20 {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: strings.Repeat("contextword ", 256) + string(rune('a'+index)),
})
}
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "recent answer"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "latest question"},
)
var captured []zeroruntime.Message
result, err := CompactMessages(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func(toSummarize []zeroruntime.Message) (string, error) {
captured = append([]zeroruntime.Message(nil), toSummarize...)
return "bounded summary", nil
},
})
if err != nil {
t.Fatal(err)
}
if !result.Compacted || !result.Truncated {
t.Fatalf("compaction metadata = compacted %t, truncated %t; want both true", result.Compacted, result.Truncated)
}
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
}
}
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
+131 -12
View File
@@ -102,7 +102,7 @@ type skillEntry struct {
body string
}
// recentEdits returns the files mutated by write_file/edit_file calls in messages
// recentEdits returns files mutated by editing calls in messages
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
// note}. After compaction elides the editing turns, this tells the model WHAT it
// changed in each file (from the tool's result) so it need not re-read to
@@ -113,6 +113,7 @@ type skillEntry struct {
// rather than an earlier, now-stale entry.
func recentEdits(messages []zeroruntime.Message) []skillEntry {
pathByID := map[string]string{}
noteByPath := map[string]string{}
sequence := make([]string, 0)
for _, message := range messages {
for _, call := range message.ToolCalls {
@@ -129,12 +130,24 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
sequence = append(sequence, path)
}
}
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 {
continue
}
for _, path := range message.ChangedFiles {
path = strings.TrimSpace(path)
if path == "" {
continue
}
sequence = append(sequence, path)
noteByPath[path] = editNote(message.Content)
}
}
order := lastSeenOrder(sequence)
if len(order) == 0 {
return nil
}
noteByPath := map[string]string{}
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
continue
@@ -425,15 +438,20 @@ type preservedState struct {
}
type preservedTaskState struct {
Objective string `json:"objective"`
Status taskStatus `json:"status,omitempty"`
Pending int `json:"pending,omitempty"`
InProgress int `json:"in_progress,omitempty"`
Completed int `json:"completed,omitempty"`
Failed int `json:"failed,omitempty"`
VerificationPassed int `json:"verification_passed,omitempty"`
VerificationFailed int `json:"verification_failed,omitempty"`
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
Objective string `json:"objective"`
Status taskStatus `json:"status,omitempty"`
Pending int `json:"pending,omitempty"`
InProgress int `json:"in_progress,omitempty"`
Completed int `json:"completed,omitempty"`
Failed int `json:"failed,omitempty"`
VerificationPassed int `json:"verification_passed,omitempty"`
VerificationFailed int `json:"verification_failed,omitempty"`
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
Constraints []string `json:"constraints,omitempty"`
ChangedFiles []string `json:"changed_files,omitempty"`
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
Approvals []taskApprovalState `json:"approvals,omitempty"`
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
}
type preservedEdit struct {
@@ -468,7 +486,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
task := priorState.Task
if taskSnapshot != nil {
task = &preservedTaskState{
Objective: capTaskObjective(taskSnapshot.Objective),
Objective: capTaskObjective(taskSnapshot.Objective),
Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints),
ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries),
UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys),
Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries),
Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries),
}
// Plan parity corroborates only the mutable task projection. The objective
// comes directly from the run prompt and is immutable, so it must survive
@@ -484,6 +507,13 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
}
}
freshConstraints := explicitConstraintsFromMessages(middle)
if len(freshConstraints) > 0 {
if task == nil {
task = &preservedTaskState{}
}
task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints)
}
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
// plan preserved by an earlier compaction.
@@ -517,6 +547,95 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
return summary
}
func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string {
var constraints []string
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) {
continue
}
if _, body := projectInstructionBlock(message.Content); body != "" {
continue
}
constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints)
}
return constraints
}
func priorTaskConstraints(task *preservedTaskState) []string {
if task == nil {
return nil
}
return task.Constraints
}
func priorTaskChangedFiles(task *preservedTaskState) []string {
if task == nil {
return nil
}
return task.ChangedFiles
}
func priorTaskFailures(task *preservedTaskState) []taskFailureState {
if task == nil {
return nil
}
return task.UnresolvedFailures
}
func priorTaskApprovals(task *preservedTaskState) []taskApprovalState {
if task == nil {
return nil
}
return task.Approvals
}
func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState {
if task == nil {
return nil
}
return task.Artifacts
}
func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState {
resolvedSet := make(map[string]struct{}, len(resolved))
for _, key := range resolved {
resolvedSet[key] = struct{}{}
}
merged := make([]taskFailureState, 0, len(older)+len(newer))
for _, failure := range append(append([]taskFailureState(nil), older...), newer...) {
if _, ok := resolvedSet[failure.Key]; ok {
continue
}
withoutSameKey := make([]taskFailureState, 0, len(merged))
for _, existing := range merged {
if existing.Key != failure.Key {
withoutSameKey = append(withoutSameKey, existing)
}
}
merged = append(withoutSameKey, failure)
}
if len(merged) > maxTaskEvidenceEntries {
merged = merged[len(merged)-maxTaskEvidenceEntries:]
}
return merged
}
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
merged := make([]T, 0, len(older)+len(newer))
seen := make(map[T]struct{}, len(older)+len(newer))
for _, value := range append(append([]T(nil), older...), newer...) {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
merged = append(merged, value)
}
if len(merged) > limit {
merged = merged[len(merged)-limit:]
}
return merged
}
func capTaskObjective(objective string) string {
objective = strings.TrimSpace(objective)
if len(objective) <= maxTaskObjectiveBytes {
@@ -6,6 +6,7 @@ import (
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -64,6 +65,9 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
objective := strings.Repeat("世", maxTaskObjectiveBytes)
task := newTaskState(objective, nil)
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{
Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"},
}})
messages := stateConversation()
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
@@ -80,6 +84,69 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
}
if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" {
t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles)
}
}
func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) {
task := newTaskState("Please keep the change focused.", nil)
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed",
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
}})
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp",
}})
messages := stateConversation()
first, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
taskState: task.snapshotForCompaction(messages),
})
if err != nil {
t.Fatal(err)
}
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence",
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
}})
secondInput := append(append([]zeroruntime.Message{}, first...),
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
)
second, err := Compact(secondInput, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
taskState: task.snapshotForCompaction(secondInput),
})
if err != nil {
t.Fatal(err)
}
state := parsePreservedStateBlock(second[1].Content)
if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 {
t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task)
}
if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" {
t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures)
}
for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} {
if !containsString(state.Task.Constraints, want) {
t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints)
}
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
+315
View File
@@ -0,0 +1,315 @@
package agent
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
compactionUserWordBudget = 256
compactionAssistantWordBudget = 200
compactionToolCallsPerTurn = 8
compactionToolArgumentBytes = 120
compactionErrorBytes = 150
compactionResultBytes = 1200
compactionPreviousSummaryBytes = 16 * 1024
compactionBriefMaxBytes = 24 * 1024
)
var compactionWordPattern = regexp.MustCompile(`[\p{L}\p{N}]+`)
type compactionProjection struct {
messages []zeroruntime.Message
truncated bool
}
// projectCompactionInput removes reconstructible tool output before the model
// summary call. It retains a bounded transcript of user intent, assistant
// decisions, tool actions, and concise errors. Exact durable state is appended
// separately from the original messages after the summary returns.
func projectCompactionInput(messages []zeroruntime.Message) compactionProjection {
sections := make([]string, 0, len(messages))
previousSummary := ""
truncated := false
for index, message := range messages {
switch message.Role {
case zeroruntime.MessageRoleUser:
content := message.Content
if marker := strings.Index(content, preservedStateLabel); marker >= 0 {
content = content[:marker]
}
trimmed := strings.TrimSpace(content)
if strings.HasPrefix(trimmed, summaryLabel) {
fullSummary := strings.TrimSpace(strings.TrimPrefix(trimmed, summaryLabel))
previousSummary = clipHeadTailBytes(fullSummary, compactionPreviousSummaryBytes)
truncated = truncated || len(fullSummary) > compactionPreviousSummaryBytes
continue
}
if content = clipInformativeWords(content, compactionUserWordBudget); content != "" {
sections = append(sections, fmt.Sprintf("[user #%d]\n%s", index, content))
}
case zeroruntime.MessageRoleAssistant:
lines := make([]string, 0, 1+len(message.ToolCalls))
if content := clipInformativeWords(message.Content, compactionAssistantWordBudget); content != "" {
lines = append(lines, content)
}
calls := message.ToolCalls
omitted := 0
if len(calls) > compactionToolCallsPerTurn {
omitted = len(calls) - compactionToolCallsPerTurn
calls = calls[omitted:]
}
if omitted > 0 {
lines = append(lines, fmt.Sprintf("* (%d earlier tool calls omitted)", omitted))
}
for _, call := range calls {
lines = append(lines, compactionToolCallLine(call))
}
if len(lines) > 0 {
sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n")))
}
case zeroruntime.MessageRoleTool:
// New tool-result messages carry the execution status directly, as the
// source ToolResult does. Keep the text check for older session history
// created before Message exposed IsError.
name := toolNameForResult(messages, index)
mustPreserve := name == "ask_user" || len(message.ChangedFiles) > 0
if !mustPreserve && !message.IsError && !isLikelyToolError(message.Content) {
continue
}
if name == "" {
name = "tool"
}
switch {
case message.IsError || isLikelyToolError(message.Content):
sections = append(sections, fmt.Sprintf("[tool_error #%d] %s\n%s", index, name, clipBytes(firstLine(message.Content), compactionErrorBytes)))
case name == "ask_user":
sections = append(sections, fmt.Sprintf("[user_answer #%d] %s\n%s", index, name, clipBytes(message.Content, compactionResultBytes)))
default:
sections = append(sections, fmt.Sprintf("[tool_result #%d] %s changed %s\n%s", index, name,
clipBytes(strings.Join(message.ChangedFiles, ", "), compactionToolArgumentBytes), clipBytes(firstLine(message.Content), compactionErrorBytes)))
}
}
}
if len(sections) == 0 && previousSummary == "" {
return compactionProjection{}
}
brief, briefTruncated := capCompactionBrief(strings.Join(sections, "\n\n"))
truncated = truncated || briefTruncated
if previousSummary != "" {
brief = "[previous summary]\n" + previousSummary + "\n\n" + brief
}
return compactionProjection{
messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: strings.TrimSpace(brief)}},
truncated: truncated,
}
}
func capCompactionBrief(brief string) (string, bool) {
if len(brief) <= compactionBriefMaxBytes {
return brief, false
}
marker := "\n\n...[middle transcript omitted to fit compaction budget]...\n\n"
available := compactionBriefMaxBytes - len(marker)
// Keep both chronological edges: the oldest material has not necessarily
// been summarized before, while the newest material is most actionable.
headBytes := available * 2 / 5
tailBytes := available - headBytes
head := clipPrefixAtBoundary(brief, headBytes)
tail := clipSuffixAtBoundary(brief, tailBytes)
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail), true
}
func compactionToolCallLine(call zeroruntime.ToolCall) string {
detail := ""
var arguments map[string]any
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
detail = value
break
}
}
if detail == "" && call.Name == "ask_user" {
detail = askUserQuestionsDetail(arguments)
}
if detail == "" && call.Name == "apply_patch" {
if patch, ok := arguments["patch"].(string); ok {
detail = strings.Join(patchPaths(patch), ", ")
}
}
}
if detail == "" {
detail = commandFromArguments(call.Arguments)
}
if detail == "" {
return "* " + call.Name
}
return fmt.Sprintf("* %s %q", call.Name, clipBytes(detail, compactionToolArgumentBytes))
}
func isLikelyToolError(content string) bool {
prefix := strings.TrimSpace(content)
if len(prefix) > compactionErrorBytes {
prefix = prefix[:compactionErrorBytes]
}
lower := strings.ToLower(prefix)
for _, prefix := range []string{"error:", "tool error:", "failed:", "permission denied", "command failed"} {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func clipInformativeWords(text string, limit int) string {
flat := strings.Join(strings.Fields(text), " ")
if flat == "" || limit <= 0 {
return ""
}
count := 0
for _, location := range compactionWordPattern.FindAllStringIndex(flat, -1) {
word := strings.ToLower(flat[location[0]:location[1]])
if compactionStopWords[word] {
continue
}
count++
if count > limit {
end := location[0]
return strings.TrimSpace(flat[:end]) + "...(truncated)"
}
}
return flat
}
func clipBytes(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
if limit <= 3 {
end := limit
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
end--
}
return text[:end]
}
end := limit - 3
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
end--
}
return strings.TrimSpace(text[:end]) + "..."
}
func clipHeadTailBytes(text string, limit int) string {
text = strings.TrimSpace(text)
if len(text) <= limit {
return text
}
if limit <= 0 {
return ""
}
marker := "\n...[middle omitted]...\n"
if limit <= len(marker) {
return clipBytes(text, limit)
}
available := limit - len(marker)
head := clipPrefixAtBoundary(text, available/2)
tail := clipSuffixAtBoundary(text, available-available/2)
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail)
}
func clipPrefixAtBoundary(text string, limit int) string {
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
end := limit
for end > 0 && text[end]&0xc0 == 0x80 {
end--
}
if boundary := strings.LastIndex(text[:end], "\n\n["); boundary > 0 {
end = boundary
}
return text[:end]
}
func clipSuffixAtBoundary(text string, limit int) string {
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
start := len(text) - limit
for start < len(text) && text[start]&0xc0 == 0x80 {
start++
}
if boundary := strings.Index(text[start:], "\n\n["); boundary >= 0 {
start += boundary + 2
}
return text[start:]
}
func askUserQuestionsDetail(arguments map[string]any) string {
raw, ok := arguments["questions"].([]any)
if !ok {
return ""
}
questions := make([]string, 0, len(raw))
for _, item := range raw {
object, ok := item.(map[string]any)
if !ok {
continue
}
if question, ok := object["question"].(string); ok && strings.TrimSpace(question) != "" {
questions = append(questions, strings.TrimSpace(question))
}
}
return strings.Join(questions, " | ")
}
func patchPaths(patch string) []string {
var paths []string
seen := map[string]bool{}
for _, line := range strings.Split(patch, "\n") {
for _, prefix := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} {
if !strings.HasPrefix(line, prefix) {
continue
}
path := strings.TrimSpace(strings.TrimPrefix(line, prefix))
if path != "" && !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
var compactionStopWords = map[string]bool{
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true,
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
"should": true, "may": true, "might": true, "shall": true, "can": true, "need": true, "must": true,
"to": true, "of": true, "in": true, "for": true, "on": true, "with": true, "at": true,
"by": true, "from": true, "as": true, "into": true, "through": true, "during": true,
"before": true, "after": true, "above": true, "below": true, "between": true, "under": true, "over": true,
"and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true,
"both": true, "either": true, "neither": true, "each": true, "every": true, "all": true,
"any": true, "few": true, "more": true, "most": true, "other": true, "some": true, "such": true, "no": true,
"that": true, "this": true, "these": true, "those": true, "it": true, "its": true,
"i": true, "me": true, "my": true, "we": true, "our": true, "you": true, "your": true,
"he": true, "him": true, "his": true, "she": true, "her": true, "they": true, "them": true, "their": true,
"who": true, "which": true, "what": true, "if": true, "then": true, "than": true,
"when": true, "where": true, "how": true, "just": true, "also": true,
}
@@ -0,0 +1,130 @@
package agent
import (
"strings"
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
for _, limit := range []int{0, 1, 2, 3} {
if clipped := clipBytes("世界", limit); len(clipped) > limit || !utf8.ValidString(clipped) {
t.Fatalf("clipBytes limit %d returned invalid result %q", limit, clipped)
}
}
}
func TestBoundaryClippersHandleNonPositiveLimits(t *testing.T) {
for _, clip := range []func(string, int) string{clipPrefixAtBoundary, clipSuffixAtBoundary} {
for _, limit := range []int{-1, 0} {
if got := clip("世界", limit); got != "" {
t.Fatalf("clipper limit %d = %q, want empty", limit, got)
}
}
}
}
func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) {
largeBody := strings.Repeat("recoverable source line\n", 1000)
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "Inspect the parser and preserve its current behavior."},
{Role: zeroruntime.MessageRoleAssistant, Content: "I will inspect before editing.", ToolCalls: []zeroruntime.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"internal/parser.go"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "read", Content: largeBody},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "test", Name: "exec_command", Arguments: `{"cmd":"go test ./internal/parser"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "test", Content: "Error: parser regression\nlong diagnostic details"},
}
projection := projectCompactionInput(messages)
projected := projection.messages
if len(projected) != 1 {
t.Fatalf("projection = %#v, want one compact message", projected)
}
content := projected[0].Content
for _, want := range []string{"Inspect the parser", `read_file "internal/parser.go"`, `exec_command "go test ./internal/parser"`, "Error: parser regression"} {
if !strings.Contains(content, want) {
t.Fatalf("projection missing %q: %q", want, content)
}
}
if strings.Contains(content, "recoverable source line") || strings.Contains(content, "long diagnostic details") {
t.Fatalf("projection retained reconstructible or overlong tool output: %q", content)
}
if estimateTokens(projected)*20 >= estimateTokens(messages) {
t.Fatalf("projection did not materially reduce tokens: before=%d after=%d", estimateTokens(messages), estimateTokens(projected))
}
}
func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true},
}
projected := projectCompactionInput(messages).messages
if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") {
t.Fatalf("structured tool error was dropped from projection: %#v", projected)
}
}
func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "ask", Name: "ask_user", Arguments: `{"questions":[{"question":"Which database should remain?"}]}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "patch", Content: "Done!", ChangedFiles: []string{"internal/db.go"}},
}
content := projectCompactionInput(messages).messages[0].Content
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
if !strings.Contains(content, want) {
t.Fatalf("projection missing %q: %q", want, content)
}
}
}
func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) {
calls := make([]zeroruntime.ToolCall, 12)
for index := range calls {
calls[index] = zeroruntime.ToolCall{Name: "read_file", Arguments: `{"path":"file.go"}`}
}
projected := projectCompactionInput([]zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: calls}}).messages
content := projected[0].Content
if strings.Count(content, "* read_file") != compactionToolCallsPerTurn || !strings.Contains(content, "4 earlier tool calls omitted") {
t.Fatalf("tool-call tail was not bounded: %q", content)
}
}
func TestProjectCompactionInputCarriesPreviousSummaryOutsideBriefCap(t *testing.T) {
previous := "keep the earlier architecture decision\n" + strings.Repeat("prior detail ", 2000) + "\nkeep the newest prior decision"
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\n" + previous + "\n\n" + preservedStateLabel + "\n{}"}}
for index := range 500 {
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("later transcript detail ", 20)})
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "later request " + strings.Repeat("context ", 20) + string(rune('a'+index%26))})
}
projection := projectCompactionInput(messages)
content := projection.messages[0].Content
if !strings.Contains(content, "[previous summary]\nkeep the earlier architecture decision") || !strings.Contains(content, "keep the newest prior decision") {
t.Fatalf("previous summary was lost when the brief tail was capped: %q", content)
}
if !strings.Contains(content, "middle transcript omitted to fit compaction budget") {
t.Fatalf("oversized brief was not head/tail capped: %q", content)
}
if !projection.truncated {
t.Fatal("projection did not structurally report truncation")
}
}
func TestProjectCompactionInputDoesNotInferTruncationFromUserText(t *testing.T) {
projection := projectCompactionInput([]zeroruntime.Message{{
Role: zeroruntime.MessageRoleUser, Content: "Keep this literal marker: [middle omitted]",
}})
if projection.truncated {
t.Fatal("literal user text produced a false truncation signal")
}
}
@@ -20,11 +20,15 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e3", Name: "apply_patch", Arguments: `{"patch":"*** Update File: internal/baz.go"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e3", Content: "Done!", ChangedFiles: []string{"internal/baz.go"}},
}
edits := recentEdits(messages)
if len(edits) != 2 {
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
if len(edits) != 3 {
t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits)
}
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
@@ -32,6 +36,9 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
}
if edits[2].name != "internal/baz.go" || edits[2].body != "Done!" {
t.Fatalf("third edit = %#v, want apply_patch changed file with its note", edits[2])
}
}
// After compaction elides the editing turns, the preserved-state block still
+71 -5
View File
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -58,12 +59,15 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
if last.Content != "most recent question" {
t.Fatalf("expected most recent message preserved, got %q", last.Content)
}
// The summarized middle excludes system and preserved suffix.
if len(captured) != 3 {
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
// The summarizer receives a compact semantic projection of the middle rather
// than reconstructible raw tool output.
if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser {
t.Fatalf("expected one projected summary input, got %#v", captured)
}
if captured[0].Content != "first question" {
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
if !strings.Contains(captured[0].Content, want) {
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
}
}
// Compaction must shrink the conversation.
if estimateTokens(result) >= estimateTokens(messages) {
@@ -425,6 +429,7 @@ type reactiveProvider struct {
summarizeCalls int
turnRequests int
failedOnce bool
connectError bool
bigText string
finalText string
}
@@ -446,6 +451,9 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
case provider.turnRequests == 2 && !provider.failedOnce:
provider.failedOnce = true
if provider.connectError {
return nil, errors.New("context_length_exceeded")
}
return streamEvents([]zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
}), nil
@@ -454,6 +462,39 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
}
}
func TestRunConnectErrorCompactionRecordsReplacementPlan(t *testing.T) {
provider := &reactiveProvider{
connectError: true,
bigText: strings.Repeat("b", 6000),
finalText: "recovered",
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
recorder := trace.NewRecorder("connect-compaction", "run-1", "test")
var contextPlans []ContextBreakdown
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
OnContext: func(breakdown ContextBreakdown) {
contextPlans = append(contextPlans, breakdown)
},
})
if err != nil || result.FinalAnswer != "recovered" {
t.Fatalf("connect-error compaction failed: result=%#v err=%v", result, err)
}
if len(contextPlans) != 3 || contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
t.Fatalf("replacement request context not reported: %#v", contextPlans)
}
gotTrace := recorder.Finish()
if len(gotTrace.PrefixHashes) != 4 || gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
t.Fatalf("replacement request trace not recorded: %#v", gotTrace.PrefixHashes)
}
}
func TestRunReactiveCompactionRecovers(t *testing.T) {
provider := &reactiveProvider{
bigText: strings.Repeat("b", 6000),
@@ -465,6 +506,8 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
// bloats the history.
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
var contextPlans []ContextBreakdown
// ContextWindow large enough that proactive compaction never triggers, so
// only the reactive path can save the run.
@@ -473,6 +516,10 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
PermissionMode: PermissionModeUnsafe,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
OnContext: func(breakdown ContextBreakdown) {
contextPlans = append(contextPlans, breakdown)
},
})
if err != nil {
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
@@ -483,6 +530,25 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
if provider.summarizeCalls == 0 {
t.Fatal("expected reactive compaction to invoke the summarizer")
}
if len(contextPlans) != 3 {
t.Fatalf("main request context callbacks = %d, want initial turns plus compacted retry", len(contextPlans))
}
if contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
t.Fatalf("retry context was not updated after compaction: before=%d after=%d", contextPlans[1].MessageTokens, contextPlans[2].MessageTokens)
}
gotTrace := recorder.Finish()
if len(gotTrace.PrefixHashes) != 4 {
t.Fatalf("prefix evidence count = %d, want two turns, compaction, and retry: %#v", len(gotTrace.PrefixHashes), gotTrace.PrefixHashes)
}
if gotTrace.PrefixHashes[0].InvalidationReason != "initial" ||
gotTrace.PrefixHashes[1].InvalidationReason != "unchanged" ||
gotTrace.PrefixHashes[2].InvalidationReason != "initial" ||
gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
t.Fatalf("unexpected request-specific invalidation evidence: %#v", gotTrace.PrefixHashes)
}
if gotTrace.PrefixHashes[2].CompletePrefixHash == gotTrace.PrefixHashes[1].CompletePrefixHash {
t.Fatalf("compaction fingerprint reused the main request prefix: %#v", gotTrace.PrefixHashes)
}
}
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
+36
View File
@@ -23,6 +23,24 @@ type ContextBreakdown struct {
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
ContextWindow int // model context window; 0 when unknown
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
// Blocks explain why each model-visible category is present without exposing
// its content. The planner currently preserves all three categories.
Blocks []ContextBlock
// CompletePrefixHash identifies the provider-visible system+tool prefix.
CompletePrefixHash string
// PrefixInvalidationReason is "initial", "unchanged", or a stable comma-
// separated list of changed prefix categories.
PrefixInvalidationReason string
}
// ContextBlock is content-free evidence for one model-visible context category.
type ContextBlock struct {
Kind string
Tokens int
CacheClass string
Authority string
Recoverable bool
Reason string
}
// MeasureContext estimates the per-category token footprint of a request: the
@@ -41,6 +59,24 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
ContextWindow: contextWindow,
}
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
if breakdown.SystemTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "system", Tokens: breakdown.SystemTokens, CacheClass: "stable_prefix",
Authority: "required", Reason: "instructions, workspace policy, and safety policy",
})
}
if breakdown.ToolTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "tools", Tokens: breakdown.ToolTokens, CacheClass: "stable_prefix",
Authority: "capability", Reason: "currently permitted and selected tool definitions",
})
}
if breakdown.MessageTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "conversation", Tokens: breakdown.MessageTokens, CacheClass: "dynamic_tail",
Authority: "transcript", Reason: "valid provider replay history and current task context",
})
}
if contextWindow > 0 {
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
}
@@ -32,6 +32,9 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
if breakdown.UsedFraction != wantFraction {
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
}
if len(breakdown.Blocks) != 3 {
t.Fatalf("Blocks = %#v, want system/tools/conversation", breakdown.Blocks)
}
}
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
+185
View File
@@ -0,0 +1,185 @@
package agent
import (
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
contextWindow int
promptCacheKey string
serviceTier string
promptParts systemPromptParts
}
// contextPlanner is the single seam for constructing model-visible requests.
// Planning is deterministic and does not retrieve content, execute tools, or
// change permissions. The initial implementation deliberately preserves every
// message and tool definition while making composition and cache drift
// inspectable; later selection policies must continue to cross this seam.
type contextPlanner struct {
config contextPlannerConfig
previousPrefix prefixFingerprint
hasPrevious bool
toolSnapshotKey string
toolSnapshot []zeroruntime.ToolDefinition
}
type contextPlan struct {
Request zeroruntime.CompletionRequest
Breakdown ContextBreakdown
PrefixFingerprint prefixFingerprint
}
func newContextPlanner(config contextPlannerConfig) *contextPlanner {
return &contextPlanner{config: config}
}
// Plan returns a provider request snapshot plus content-free accounting.
// It intentionally performs no relevance filtering: preserving current model
// capability is the baseline contract for future planner policies.
func (planner *contextPlanner) Plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string) contextPlan {
return planner.plan(messages, toolDefs, reasoningEffort, planner.config.promptParts)
}
// planWithPromptParts plans a request whose stable system sections differ from
// the planner's configured main-run prompt, such as a compaction summary call.
func (planner *contextPlanner) planWithPromptParts(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
return planner.plan(messages, toolDefs, reasoningEffort, parts)
}
func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
requestMessages := copyMessages(messages)
requestSystemPrompt := leadingSystemContent(requestMessages)
if parts.prompt != requestSystemPrompt {
// Component boundaries from a different request would make the detailed
// invalidation reason misleading. Retain the exact request prefix and
// classify it conservatively as an unstructured system prompt instead.
parts = systemPromptParts{prompt: requestSystemPrompt}
}
fingerprint := computePrefixFingerprint(buildPromptSubstringsFromParts(parts, toolDefs))
request := zeroruntime.CompletionRequest{
Messages: requestMessages,
Tools: planner.snapshotTools(toolDefs, fingerprint),
ReasoningEffort: reasoningEffort,
ServiceTier: planner.config.serviceTier,
PromptCacheKey: planner.config.promptCacheKey,
}
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
breakdown.CompletePrefixHash = fingerprint.CompletePrefixHash
var previous *prefixFingerprint
if planner.hasPrevious {
previous = &planner.previousPrefix
}
breakdown.PrefixInvalidationReason = explainPrefixChange(previous, fingerprint)
planner.previousPrefix = fingerprint
planner.hasPrevious = true
return contextPlan{
Request: request,
Breakdown: breakdown,
PrefixFingerprint: fingerprint,
}
}
// snapshotTools freezes provider-visible tool definitions when their semantic
// fingerprint changes. Stable turns reuse the frozen schemas instead of
// recursively cloning every map and slice for every request.
func (planner *contextPlanner) snapshotTools(toolDefs []zeroruntime.ToolDefinition, fingerprint prefixFingerprint) []zeroruntime.ToolDefinition {
key := fingerprint.ToolsHash + "\x00" + fingerprint.SchemaHash
if planner.toolSnapshotKey != key {
planner.toolSnapshot = copyToolDefinitions(toolDefs)
planner.toolSnapshotKey = key
}
return planner.toolSnapshot
}
func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.ToolDefinition {
if toolDefs == nil {
return nil
}
copied := make([]zeroruntime.ToolDefinition, len(toolDefs))
for index, definition := range toolDefs {
copied[index] = definition
copied[index].Parameters = copySchemaMap(definition.Parameters)
}
return copied
}
func copySchemaMap(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
copied := make(map[string]any, len(schema))
for key, value := range schema {
copied[key] = copySchemaValue(value)
}
return copied
}
func copySchemaValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return copySchemaMap(typed)
case []any:
copied := make([]any, len(typed))
for index, item := range typed {
copied[index] = copySchemaValue(item)
}
return copied
case []string:
return append([]string(nil), typed...)
default:
return value
}
}
func leadingSystemContent(messages []zeroruntime.Message) string {
contents := make([]string, 0, 1)
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleSystem {
break
}
contents = append(contents, message.Content)
}
return strings.Join(contents, "\n\n")
}
func explainPrefixChange(previous *prefixFingerprint, current prefixFingerprint) string {
if previous == nil {
return "initial"
}
if previous.CompletePrefixHash == current.CompletePrefixHash {
return "unchanged"
}
reasons := make([]string, 0, 3)
if previous.SystemPromptHash != current.SystemPromptHash {
parts := make([]string, 0, 4)
if previous.BaseInstructionsHash != current.BaseInstructionsHash {
parts = append(parts, "base_instructions")
}
if previous.ConfirmationPolicyHash != current.ConfirmationPolicyHash {
parts = append(parts, "confirmation_policy")
}
if previous.ProjectContextHash != current.ProjectContextHash {
parts = append(parts, "project_context")
}
if previous.SkillsHash != current.SkillsHash {
parts = append(parts, "skills")
}
if len(parts) == 0 {
parts = append(parts, "system_prompt")
}
reasons = append(reasons, parts...)
}
if previous.ToolsHash != current.ToolsHash {
reasons = append(reasons, "tools")
}
if previous.SchemaHash != current.SchemaHash {
reasons = append(reasons, "schema")
}
if len(reasons) == 0 {
return "prefix_changed"
}
return strings.Join(reasons, ",")
}
+187
View File
@@ -0,0 +1,187 @@
package agent
import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "inspect this", Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{1, 2, 3}}}},
{Role: zeroruntime.MessageRoleAssistant, Content: "working", ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}, Reasoning: []zeroruntime.ReasoningBlock{{Provider: "test", Type: "thinking", Signature: "sig"}}},
}
toolDefs := []zeroruntime.ToolDefinition{{
Name: "read_file", Description: "Read a file",
Parameters: map[string]any{
"type": "object",
"required": []string{"path"},
"properties": map[string]any{"path": map[string]any{"type": "string"}},
"anyOf": []any{map[string]any{"type": "object"}},
},
}}
planner := newContextPlanner(contextPlannerConfig{
contextWindow: 128_000,
promptCacheKey: "session-1",
serviceTier: "priority",
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
})
plan := planner.Plan(messages, toolDefs, "medium")
want := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefs,
ReasoningEffort: "medium",
ServiceTier: "priority",
PromptCacheKey: "session-1",
}
if !reflect.DeepEqual(plan.Request, want) {
t.Fatalf("planned request changed provider input:\n got: %#v\nwant: %#v", plan.Request, want)
}
stable := planner.Plan(messages, toolDefs, "medium")
if &stable.Request.Tools[0] != &plan.Request.Tools[0] {
t.Fatal("stable tool fingerprint rebuilt the immutable schema snapshot")
}
// A plan owns its request snapshot; later caller mutation cannot change its
// messages or its recursively nested tool schema.
messages[1].Content = "changed"
messages[1].Images[0].Data[0] = 9
toolDefs[0].Name = "changed_tool"
toolDefs[0].Parameters["required"].([]string)[0] = "other"
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
}
if plan.Request.Tools[0].Name != "read_file" ||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
t.Fatalf("planned tools alias caller state: %#v", plan.Request.Tools[0])
}
next := planner.Plan(messages, toolDefs, "medium")
if next.PrefixFingerprint == plan.PrefixFingerprint || next.Request.Tools[0].Name != "changed_tool" {
t.Fatalf("changed tool schema did not refresh snapshot: %#v", next)
}
if plan.Request.Tools[0].Name != "read_file" {
t.Fatalf("refresh mutated earlier request: %#v", plan.Request.Tools[0])
}
}
func TestContextPlannerReportsInspectableBlocks(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{contextWindow: 100_000})
plan := planner.Plan([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: filler(400)},
{Role: zeroruntime.MessageRoleUser, Content: filler(200)},
}, []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read a file"}}, "")
if plan.Breakdown.PrefixInvalidationReason != "initial" || plan.Breakdown.CompletePrefixHash == "" {
t.Fatalf("prefix evidence = %#v", plan.Breakdown)
}
if len(plan.Breakdown.Blocks) != 3 {
t.Fatalf("blocks = %#v, want system/tools/conversation", plan.Breakdown.Blocks)
}
wantKinds := []string{"system", "tools", "conversation"}
total := 0
for index, block := range plan.Breakdown.Blocks {
if block.Kind != wantKinds[index] || block.Tokens <= 0 || block.Reason == "" || block.Authority == "" || block.CacheClass == "" {
t.Fatalf("block %d = %#v", index, block)
}
total += block.Tokens
}
if total != plan.Breakdown.TotalTokens {
t.Fatalf("block tokens = %d, want total %d", total, plan.Breakdown.TotalTokens)
}
}
func TestContextPlannerFingerprintsActualRequestSystemPrompt(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
prompt: "configured system", baseInstructions: "configured system",
}})
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "request-specific system"},
{Role: zeroruntime.MessageRoleUser, Content: "task"},
}
plan := planner.Plan(messages, nil, "")
want := computePrefixFingerprint(buildPromptSubstringsFromParts(
systemPromptParts{prompt: "request-specific system"}, nil,
))
if plan.PrefixFingerprint != want {
t.Fatalf("fingerprint describes configured prompt instead of request:\n got: %#v\nwant: %#v", plan.PrefixFingerprint, want)
}
}
func TestContextPlannerExplainsPrefixInvalidation(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
prompt: "system", baseInstructions: "base", projectContext: "project",
}})
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}, {Role: zeroruntime.MessageRoleUser, Content: "task"}}
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read", Parameters: map[string]any{"type": "object"}}}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "initial" {
t.Fatalf("first reason = %q, want initial", got)
}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "unchanged" {
t.Fatalf("stable reason = %q, want unchanged", got)
}
tools[0].Description = "Read an exact file"
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "tools" {
t.Fatalf("tool reason = %q, want tools", got)
}
tools[0].Parameters["required"] = []any{"path"}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "schema" {
t.Fatalf("schema reason = %q, want schema", got)
}
planner.config.promptParts.prompt = "system with changed project"
planner.config.promptParts.projectContext = "changed project"
messages[0].Content = "system with changed project"
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "project_context" {
t.Fatalf("project reason = %q, want project_context", got)
}
}
func TestExplainPrefixChangeNamesEveryChangedComponent(t *testing.T) {
baseline := prefixFingerprint{
SystemPromptHash: "system",
BaseInstructionsHash: "base",
ConfirmationPolicyHash: "policy",
ProjectContextHash: "project",
SkillsHash: "skills",
ToolsHash: "tools",
SchemaHash: "schema",
CompletePrefixHash: "complete",
}
tests := []struct {
name string
mutate func(*prefixFingerprint)
want string
}{
{name: "base instructions", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.BaseInstructionsHash = "changed", "changed"
}, want: "base_instructions"},
{name: "confirmation policy", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.ConfirmationPolicyHash = "changed", "changed"
}, want: "confirmation_policy"},
{name: "project context", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.ProjectContextHash = "changed", "changed"
}, want: "project_context"},
{name: "skills", mutate: func(value *prefixFingerprint) { value.SystemPromptHash, value.SkillsHash = "changed", "changed" }, want: "skills"},
{name: "unclassified system prompt", mutate: func(value *prefixFingerprint) { value.SystemPromptHash = "changed" }, want: "system_prompt"},
{name: "tools and schema", mutate: func(value *prefixFingerprint) { value.ToolsHash, value.SchemaHash = "changed", "changed" }, want: "tools,schema"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := baseline
test.mutate(&current)
current.CompletePrefixHash = "changed"
if got := explainPrefixChange(&baseline, current); got != test.want {
t.Fatalf("reason = %q, want %q", got, test.want)
}
})
}
}
+95
View File
@@ -797,3 +797,98 @@ func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
}
}
// fakeDeferredMutatorTool is a deferred-eligible tool with mutating Safety
// (SideEffectWrite), standing in for a real write/mutator MCP tool that would
// be hidden behind tool_search once deferral activates.
type fakeDeferredMutatorTool struct{ name string }
func (t fakeDeferredMutatorTool) Name() string { return t.name }
func (t fakeDeferredMutatorTool) Description() string { return "mutates the workspace, deferred" }
func (t fakeDeferredMutatorTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t fakeDeferredMutatorTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt, Reason: "mutates"}
}
func (t fakeDeferredMutatorTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "mutated"}
}
func (t fakeDeferredMutatorTool) Deferred() bool { return true }
// TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema guards the concern
// raised in PR #642 review (jatmn, P2): that tool_search filters deferred
// candidates only by EnabledTools/DisabledTools, not by plan-mode visibility,
// so a plan-mode model could call `tool_search select:<deferred write tool>`
// and receive that tool's full schema even though a direct call to it is
// correctly denied the following turn.
//
// tool_search's own Safety is SideEffectNone, and ToolAdvertisedForPermissionMode (the
// same gate executeToolCall uses to deny a direct call) requires
// SideEffect==Read to advertise a tool in plan mode. That means tool_search
// itself is never advertised, never activates deferral (loaderUsable in
// partitionToolsCached requires ToolAdvertised(loader, permissionMode)), and
// is denied at dispatch like any other hidden tool if a stale/adversarial
// call reaches it anyway — so a deferred mutator's schema can never reach the
// model through tool_search while in plan mode. This test pins that
// end-to-end: tool_search is absent from the advertised tool list, and a
// forced call to it is denied before rendering any tool schema.
func TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate"})
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate2"})
registry.Register(tools.NewToolSearchTool(registry))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{ // turn 1: force a call to tool_search even though it should not be advertised.
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: tools.ToolSearchToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"query":"select:mcp__srv__mutate"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{ // turn 2: final answer.
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
DeferThreshold: 2, // 2 deferred mutators registered => eligible for deferral.
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
// tool_search (and the deferred mutators) must not be advertised in plan
// mode's turn 1 tool list at all.
for _, def := range provider.requests[0].Tools {
if def.Name == tools.ToolSearchToolName {
t.Fatalf("plan mode must not advertise tool_search, got %#v", provider.requests[0].Tools)
}
if def.Name == "mcp__srv__mutate" || def.Name == "mcp__srv__mutate2" {
t.Fatalf("plan mode must not advertise a deferred mutator, got %#v", provider.requests[0].Tools)
}
}
// The forced call must be denied outright, never a loaded-schema result:
// the tool result must not mention the mutator's name or carry a
// load_tools signal.
var toolMessage string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolMessage = message.Content
break
}
}
if !strings.Contains(toolMessage, "not available in plan mode") {
t.Fatalf("expected tool_search call denied in plan mode, got %q", toolMessage)
}
if strings.Contains(toolMessage, "mcp__srv__mutate") {
t.Fatalf("denial must not leak the deferred mutator's name/schema, got %q", toolMessage)
}
}
+17 -6
View File
@@ -144,9 +144,10 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
var switchedTo string
switchCount := 0
result, err := Run(context.Background(), "go", firstProvider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
ServiceTier: "priority",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
switchCount++
switchedTo = modelID
@@ -167,9 +168,15 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
if len(firstProvider.requests) != 1 {
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
}
if got := firstProvider.requests[0].ServiceTier; got != "priority" {
t.Fatalf("initial request service tier = %q, want priority", got)
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
}
if got := secondProvider.requests[0].ServiceTier; got != "" {
t.Fatalf("post-switch request retained service tier %q", got)
}
if result.FinalAnswer != "answered on the upgraded model" {
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
}
@@ -211,9 +218,10 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
ServiceTier: "priority",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return nil, errors.New("provider build blew up")
},
@@ -225,6 +233,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
if len(provider.requests) != 2 {
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
}
if got := provider.requests[1].ServiceTier; got != "priority" {
t.Fatalf("failed-switch request service tier = %q, want priority", got)
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
}
+183 -89
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
@@ -183,10 +191,23 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
defer runPermissions.cleanup()
promptParts := buildSystemPromptParts(options)
planner := newContextPlanner(contextPlannerConfig{
contextWindow: options.ContextWindow,
promptCacheKey: options.SessionID,
serviceTier: options.ServiceTier,
promptParts: promptParts,
})
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
guards := newGuardState()
task := newTaskState(prompt, options.Trace)
onPermission := options.OnPermission
options.OnPermission = func(event PermissionEvent) {
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: event})
if onPermission != nil {
onPermission(event)
}
}
compactor := newCompactionState(options, task)
defer func() {
// A final transcript comparison is observational. It records drift but
@@ -275,42 +296,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
toolPartitionSpan.End()
// Fingerprint the exact system prompt built at run start plus this turn's
// provider-visible tool definitions in their emitted order. Reusing the
// retained prompt parts avoids rebuilding workspace context while ensuring
// the trace describes the same bytes carried by the request.
if options.Trace != nil {
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
options.Trace.EmitPrefixHash(trace.PrefixHash{
SystemPromptHash: fp.SystemPromptHash,
BaseInstructionsHash: fp.BaseInstructionsHash,
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
ProjectContextHash: fp.ProjectContextHash,
SkillsHash: fp.SkillsHash,
ToolsHash: fp.ToolsHash,
SchemaHash: fp.SchemaHash,
CompletePrefixHash: fp.CompletePrefixHash,
})
}
// PROACTIVE compaction: if the history is approaching the model's
// context window, summarize the oldest middle before building the
// request. A no-op when ContextWindow == 0 (compaction disabled).
compactionSpan := options.Trace.Span(trace.SpanCompaction)
messages = compactor.maybeCompact(ctx, provider, messages, exposed)
compactionSpan.End()
request := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
// Report the per-category context budget for this turn so a surface can
// show utilization. Opt-in: a no-op when OnContext is unset.
if options.OnContext != nil {
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
}
plan := planner.Plan(messages, exposed, options.ReasoningEffort)
request := plan.Request
recordContextPlan(options, plan)
// A transient upstream disconnect on the initial connect is retried with
// backoff (before any content is forwarded, so no OnText is duplicated);
@@ -335,12 +329,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// on registry+loaded, not on the messages, so they stay valid after
// compaction. Using the bare toolDefinitions here would route through an
// empty-loaded partition, re-hiding every already-loaded deferred tool.
request = zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
request = retryPlan.Request
recordContextPlan(options, retryPlan)
// Pre-content connect after a context-limit compaction: route through the
// reconnect helper so a transient upstream hiccup here doesn't fail the
// whole run and re-burn every token (AUDIT-L1).
@@ -424,12 +415,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// Reuse the SAME active-mode partition (exposed) from this turn rather
// than the bare toolDefinitions: exposed depends on registry+loaded (not
// the messages), so it stays valid after compaction.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
retryRequest := retryPlan.Request
recordContextPlan(options, retryPlan)
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
return collected, retryStreamErr
@@ -487,12 +475,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, err
}
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
retryRequest := retryPlan.Request
recordContextPlan(options, retryPlan)
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
@@ -698,7 +683,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
options.Trace.Counter(trace.CounterToolCalls, 1)
recordOutputBudgetTrace(options.Trace, toolResult)
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -712,9 +697,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
turnRequestedModel = toolResult.RequestedModel
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.Output,
ToolCallID: toolResult.ToolCallID,
Role: zeroruntime.MessageRoleTool,
Content: toolResult.ModelOutput(),
ToolCallID: toolResult.ToolCallID,
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
})
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
@@ -743,7 +730,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolResult.Output
result.FinalAnswer = toolResult.ModelOutput()
result.StopReason = stopReason
result.Messages = copyMessages(messages)
return result, nil
@@ -756,7 +743,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// aren't fixed by reformatting the call, so a "match this schema" hint
// would misdirect the model toward JSON shape or blocked behavior.
retriableFailure := isRetriableToolError(toolResult)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.ModelOutput())
posture.observeToolOutcome(outcome, toolResult)
if outcome.Stop {
// The assistant message advertised EVERY collected tool call, but
@@ -772,7 +759,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, nil
}
if outcome.InjectHint && failureHint == "" {
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.ModelOutput())
}
// Collect the files this successful mutating tool changed. Self-correct
@@ -851,6 +838,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
_ = session.Prewarm(ctx)
provider = sessionProvider{session: session}
options.Model = turnRequestedModel
// Service tiers are model capabilities. A successful escalation may
// target a model that does not support the tier selected before the
// switch, so let the destination use its default.
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
@@ -920,7 +912,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
}
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
@@ -944,6 +936,21 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
if recorder == nil || result.Meta["output_budget_category"] == "" {
return
}
if result.Outcome.Finalized() {
diagnostics := result.Outcome.Diagnostics
recorder.EmitOutputBudget(trace.OutputBudgetEvent{
Tool: result.Name,
Category: diagnostics.Category,
OriginalBytes: diagnostics.OriginalBytes,
RetainedBytes: diagnostics.ModelBytes,
EstimatedOriginalTokens: diagnostics.EstimatedOriginalTokens,
EstimatedRetainedTokens: diagnostics.EstimatedModelTokens,
Truncated: diagnostics.Truncated,
Reason: diagnostics.Reason,
SpillCreated: result.Outcome.Artifact != nil,
})
return
}
parseInt := func(key string) int {
value, _ := strconv.Atoi(result.Meta[key])
return value
@@ -962,7 +969,32 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
})
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
func recordContextPlan(options Options, plan contextPlan) {
recordContextPlanTrace(options.Trace, plan)
if options.OnContext != nil {
options.OnContext(plan.Breakdown)
}
}
func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
if recorder == nil {
return
}
fingerprint := plan.PrefixFingerprint
recorder.EmitPrefixHash(trace.PrefixHash{
SystemPromptHash: fingerprint.SystemPromptHash,
BaseInstructionsHash: fingerprint.BaseInstructionsHash,
ConfirmationPolicyHash: fingerprint.ConfirmationPolicyHash,
ProjectContextHash: fingerprint.ProjectContextHash,
SkillsHash: fingerprint.SkillsHash,
ToolsHash: fingerprint.ToolsHash,
SchemaHash: fingerprint.SchemaHash,
CompletePrefixHash: fingerprint.CompletePrefixHash,
InvalidationReason: plan.Breakdown.PrefixInvalidationReason,
})
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -971,12 +1003,9 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
// The max-turns final-answer call is a pre-content connect, often after a long
// autonomous/cron run — route it through the reconnect helper so a single
// transient hiccup doesn't drop the final summary (AUDIT-L1).
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
Messages: copyMessages(finalMessages),
Tools: toolDefs,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}, reconnectNoticeFor(options))
plan := planner.Plan(finalMessages, toolDefs, options.ReasoningEffort)
recordContextPlan(options, plan)
stream, err := streamWithReconnect(ctx, provider, plan.Request, reconnectNoticeFor(options))
if err != nil {
return "", messages, ""
}
@@ -1119,12 +1148,13 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
tool, toolFound := registry.Get(call.Name)
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
modeName := string(permissionMode)
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
Output: `Error: Tool "` + call.Name + `" is not available in ` + modeName + ` mode.`,
DenialReason: DenialFiltered,
}, nil
}
@@ -1342,7 +1372,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
@@ -1457,14 +1487,15 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Output: result.ModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
@@ -1525,7 +1556,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)
@@ -1573,7 +1604,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)
@@ -1802,9 +1833,31 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
}
}
// hooksSuppressed reports whether advisory (non-veto) hooks must not run for
// this run's permission mode. Plan mode promises a read-only turn, but
// sessionStart/sessionEnd/afterTool hooks execute configured host commands
// outside the advertised-tool and sandbox gates, so dispatching them would let
// merely starting a plan session or finishing a read mutate the workspace.
//
// beforeTool is intentionally NOT suppressed: a non-zero exit is a deny gate,
// and skipping it fails open (operators who block secret-file reads via
// beforeTool would lose that protection under /plan on). See dispatchBeforeTool.
//
// Spec-draft keeps the existing trust-gated hook model: project hooks still
// fire when the workspace (or its worktree trust root) is trusted. That is
// intentional; trust inheritance for --use-spec --worktree is covered by
// TestExecSpecWorktreeInheritsTrustEndToEnd.
func hooksSuppressed(options Options) bool {
return options.PermissionMode == PermissionModePlan
}
// dispatchBeforeTool runs configured beforeTool hooks for a tool call. A hook
// that exits non-zero vetoes the call: the returned bool is true and the tool
// must not run. A nil dispatcher (no hooks wired) is a no-op.
//
// Unlike advisory hooks, beforeTool still runs under plan mode. Suppressing it
// would fail open: a project policy that blocks reads of secrets via
// beforeTool would silently stop applying the moment permission mode is plan.
func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, args map[string]any) (hooks.DispatchOutcome, bool) {
if options.Hooks == nil {
return hooks.DispatchOutcome{}, false
@@ -1829,7 +1882,7 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
// returns any advisory output (e.g. a formatter or vet result) to surface back
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
if options.Hooks == nil {
if options.Hooks == nil || hooksSuppressed(options) {
return ""
}
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
@@ -1853,7 +1906,7 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
// model turn. Lifecycle hooks are advisory: dispatcher failures are audited but
// never block the run.
func dispatchSessionStart(ctx context.Context, options Options) {
if options.Hooks == nil {
if options.Hooks == nil || hooksSuppressed(options) {
return
}
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
@@ -1874,7 +1927,7 @@ func dispatchSessionStart(ctx context.Context, options Options) {
// dispatchSessionEnd runs configured sessionEnd hooks once when the agent run
// exits, including early error returns. Lifecycle hooks are advisory.
func dispatchSessionEnd(ctx context.Context, options Options, result Result, runErr error) {
if options.Hooks == nil {
if options.Hooks == nil || hooksSuppressed(options) {
return
}
payload := map[string]any{
@@ -2061,13 +2114,14 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Output: result.ModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
}
}
return ToolResult{
@@ -2173,6 +2227,20 @@ type requestPermissionsArgs struct {
}
func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
// request_permissions is dispatched by name above, before the registry-based
// ToolAdvertised gate runs, so a read-only mode's registry omitting this tool
// (rather than registering it as denied) must not fall through to a real
// grant. Deny it here unconditionally for spec-draft/plan, independent of
// whether the caller's registry happens to contain the tool.
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in ` + string(permissionMode) + ` mode.`,
DenialReason: DenialFiltered,
}, nil
}
parsed, err := parseRequestPermissionsArgs(args)
if err != nil {
return ToolResult{
@@ -2718,9 +2786,36 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
Grant: event.Grant,
CommandPrefix: append([]string(nil), event.CommandPrefix...),
AvailableDecisions: availablePermissionDecisions(event, args, options),
// Computed from the SAME conditions shellExecutionArgsForApproval uses,
// so the prompt cannot claim an escalation that will not happen or stay
// silent about one that will. Keeping the two in step matters more than
// the wording: a label that overstates gets ignored, and one that
// understates is the bug being fixed.
PrefixApprovalEscalates: shellPrefixApprovalEscalates(event.ToolName, args, options),
}
}
// shellPrefixApprovalEscalates reports whether approving a command prefix would
// also take this command out of the sandbox.
//
// Deliberately mirrors shellExecutionArgsForApproval's guards rather than
// re-deriving them: same tool test, same sandbox availability test, and the same
// two opt-outs for a call that already asked for additional permissions or
// escalation. Those already carry their own disclosure, so labelling them again
// would be noise.
func shellPrefixApprovalEscalates(toolName string, args map[string]any, options Options) bool {
if !isShellCommandTool(toolName) {
return false
}
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
return false
}
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
return false
}
return true
}
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
if event.Action != PermissionActionPrompt {
return nil
@@ -3202,11 +3297,17 @@ func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
}
func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
// Denied tools are never advertised in any mode. Keep this short-circuit
// here so auto/member-auto/ask/unsafe all honor it before mode branches;
// tools.ToolAdvertisedForPermissionMode repeats it for the tool_search path
// which never enters this function.
if tool.Safety().Permission == tools.PermissionDeny {
return false
}
if permissionMode == PermissionModeSpecDraft {
return toolAdvertisedInSpecDraft(tool)
// plan/spec-draft policy lives in tools so tool_search and the dispatch
// gate cannot drift. PermissionDeny was already checked above.
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
return tools.ToolAdvertisedForPermissionMode(tool, string(permissionMode))
}
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
@@ -3229,17 +3330,6 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
return true
}
func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
switch tool.Name() {
case "ask_user", "submit_spec":
return true
case "update_plan":
return false
}
safety := tool.Safety()
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
}
func stopReasonFromToolResult(result ToolResult) StopReason {
if result.Meta == nil {
return ""
@@ -3278,6 +3368,7 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag
Role: zeroruntime.MessageRoleTool,
Content: abortedToolResultNotice,
ToolCallID: call.ID,
IsError: true,
})
}
return messages
@@ -3308,6 +3399,9 @@ func copyMessages(messages []Message) []Message {
if message.Reasoning != nil {
copied[index].Reasoning = append([]zeroruntime.ReasoningBlock{}, message.Reasoning...)
}
if message.ChangedFiles != nil {
copied[index].ChangedFiles = append([]string(nil), message.ChangedFiles...)
}
// Deep-copy image attachments (slice AND each Data byte slice) so the
// raw image bytes are never aliased across history/request/result copies.
copied[index].Images = zeroruntime.CloneImageBlocks(message.Images)
+677 -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 != "" {
@@ -3391,6 +3391,378 @@ func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
}
}
func TestPlanModeAdvertisesOnlySafeTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(root, nil) {
registry.Register(tool)
}
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}},
}
_, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
})
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, definition := range provider.requests[0].Tools {
names[definition.Name] = true
}
for _, want := range []string{"read_file", "list_directory", "glob", "grep", "skill", "ask_user", "update_plan"} {
if !names[want] {
t.Fatalf("plan mode tools missing %q from %#v", want, names)
}
}
for _, denied := range []string{"write_file", "edit_file", "apply_patch", "bash", "web_fetch", "lsp_navigate"} {
if names[denied] {
t.Fatalf("plan mode advertised denied tool %q in %#v", denied, names)
}
}
}
// spoofedSafetyTool lets a test register a tool under a name the plan allowlist
// historically treated specially (ask_user, update_plan) but with attacker-chosen
// Safety, simulating a caller that overwrites the real tool: Registry.Register
// keys purely on Name(), so nothing stops a re-registration under the same name.
type spoofedSafetyTool struct {
name string
safety tools.Safety
run func(ctx context.Context, args map[string]any) tools.Result
}
func (tool spoofedSafetyTool) Name() string { return tool.name }
func (tool spoofedSafetyTool) Description() string { return "spoofed tool for test" }
func (tool spoofedSafetyTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (tool spoofedSafetyTool) Safety() tools.Safety { return tool.safety }
func (tool spoofedSafetyTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tool.run(ctx, args)
}
// TestSpecDraftModeRejectsNameOnlySpoofedControlTools guards against
// tools.ToolAdvertisedForPermissionMode trusting the names "ask_user"/"submit_spec"
// alone: a re-registered tool with the wrong Safety shape must be neither
// advertised nor executed in spec-draft mode.
func TestSpecDraftModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
cases := []struct {
name string
safety tools.Safety
}{
{name: "ask_user", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow, Reason: "spoof"}},
{name: "submit_spec", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow, Reason: "spoof"}},
{name: "ask_user", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny, Reason: "spoof"}},
{name: "submit_spec", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionDeny, Reason: "spoof"}},
}
for _, tc := range cases {
t.Run(tc.name+"/"+string(tc.safety.SideEffect)+"/"+string(tc.safety.Permission), func(t *testing.T) {
written := filepath.Join(t.TempDir(), "spoofed.txt")
registry := tools.NewRegistry()
registry.Register(spoofedSafetyTool{
name: tc.name,
safety: tc.safety,
run: func(ctx context.Context, args map[string]any) tools.Result {
_ = os.WriteFile(written, []byte("spoofed"), 0o644)
return tools.Result{Status: tools.StatusOK, Output: "spoofed"}
},
})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: tc.name},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "spec", provider, Options{
Registry: registry,
PermissionMode: PermissionModeSpecDraft,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
for _, definition := range provider.requests[0].Tools {
if definition.Name == tc.name {
t.Fatalf("spec-draft advertised spoofed %s with safety %+v", tc.name, tc.safety)
}
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available") {
t.Fatalf("expected spoofed %s denial, got %q", tc.name, denied)
}
if _, err := os.Stat(written); !os.IsNotExist(err) {
t.Fatalf("spoofed %s should not have run, stat err=%v", tc.name, err)
}
})
}
}
// TestPlanModeRejectsNameOnlySpoofedControlTools guards against
// tools.ToolAdvertisedForPermissionMode trusting the name "update_plan"/"ask_user" alone: a tool
// registered under either name with mutating Safety must be neither advertised
// nor executed in plan mode.
func TestPlanModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
for _, name := range []string{"update_plan", "ask_user"} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
written := filepath.Join(root, "spoofed.txt")
registry := tools.NewRegistry()
registry.Register(spoofedSafetyTool{
name: name,
safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "spoofed"},
run: func(ctx context.Context, args map[string]any) tools.Result {
_ = os.WriteFile(written, []byte("spoofed"), 0o644)
return tools.Result{Status: tools.StatusOK, Output: "spoofed write"}
},
})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: name},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
for _, definition := range provider.requests[0].Tools {
if definition.Name == name {
t.Fatalf("plan mode advertised a spoofed %s carrying mutating Safety", name)
}
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in plan mode") {
t.Fatalf("expected spoofed %s denial, got %q", name, denied)
}
if _, err := os.Stat(written); !os.IsNotExist(err) {
t.Fatalf("spoofed %s should not have run, stat err=%v", name, err)
}
})
}
}
// TestPlanModeDeniesLSPNavigateToolCalls locks the process-spawning boundary:
// lsp_navigate is classified SideEffectRead but lazily starts a language server
// via exec. Even if the model still emits a call (e.g. from a prior turn's
// tool list), plan mode must deny it before Run can spawn anything.
func TestPlanModeDeniesLSPNavigateToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedLSPNavigateTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "lsp_navigate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"op":"definition","path":"main.go","line":1,"character":1}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in plan mode") {
t.Fatalf("expected plan mode lsp_navigate denial, got %q", denied)
}
}
func TestPlanModeDeniesHiddenToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
provider := providerCallingWriteFileThenAnswer("done")
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in plan mode") {
t.Fatalf("expected plan mode denial, got %q", denied)
}
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
t.Fatalf("write_file should not have written notes.txt, stat err=%v", err)
}
}
func TestPlanModeDeniesBashToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"printf ran > ran.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in plan mode") {
t.Fatalf("expected plan mode bash denial, got %q", denied)
}
if _, err := os.Stat(filepath.Join(root, "ran.txt")); !os.IsNotExist(err) {
t.Fatalf("bash should not have written ran.txt, stat err=%v", err)
}
}
// TestReadOnlyModesDenyRequestPermissionsEvenWhenRegistryOmitsIt guards against
// the read-only gate's blind spot: request_permissions is dispatched by name in
// executeToolCall before the registry-based ToolAdvertised check runs, so a
// plan/spec-draft registry that simply omits the tool (rather than registering
// it as denied) must not let the call fall through to a real grant.
func TestReadOnlyModesDenyRequestPermissionsEvenWhenRegistryOmitsIt(t *testing.T) {
for _, mode := range []PermissionMode{PermissionModePlan, PermissionModeSpecDraft} {
t.Run(string(mode), func(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: `{"reason":"need access","permissions":{"file_system":{"write":["/tmp/outside"]}}}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var promptCount int
result, err := Run(context.Background(), "task", provider, Options{
Registry: registry,
PermissionMode: mode,
MaxTurns: 2,
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
promptCount++
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "ok"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
if promptCount != 0 {
t.Fatalf("request_permissions must be denied before reaching OnPermissionRequest, got %d prompts", promptCount)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in "+string(mode)+" mode") {
t.Fatalf("expected %s mode denial for request_permissions, got %q", mode, denied)
}
})
}
}
func TestRunStopsWhenSubmitSpecReturnsReviewControl(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -3592,6 +3964,11 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
if !strings.Contains(strings.ToLower(placeholder), "aborted") {
t.Fatalf("expected the placeholder result to mark the call as aborted, got %q", placeholder)
}
for _, message := range result.Messages {
if message.ToolCallID == "flaky-2" && !message.IsError {
t.Fatalf("aborted placeholder must carry error status: %#v", message)
}
}
// Every tool_use in the final assistant message must have a matching result.
for _, message := range result.Messages {
@@ -3606,6 +3983,33 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
}
}
func TestRunCarriesToolErrorStatusIntoMessageHistory(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "failed-call", ToolName: "flaky"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "failed-call"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
result, err := Run(context.Background(), "go", provider, Options{Registry: registry})
if err != nil {
t.Fatal(err)
}
for _, message := range result.Messages {
if message.ToolCallID == "failed-call" {
if !message.IsError {
t.Fatalf("failed tool result lost its structured status: %#v", message)
}
return
}
}
t.Fatalf("failed tool result missing from message history: %#v", result.Messages)
}
type secretEmittingTool struct{ output string }
func (t secretEmittingTool) Name() string { return "leak" }
@@ -3697,6 +4101,8 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
{Type: zeroruntime.StreamEventDone},
}}}
onUsageCalls := 0
onContextCalls := 0
var contextPlan ContextBreakdown
rec := trace.NewRecorder("tracing-session", "run-1", "test")
if _, err := Run(context.Background(), "hi", provider, Options{
SessionID: "tracing-session",
@@ -3705,6 +4111,10 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
Model: "test-model",
Trace: rec,
OnUsage: func(Usage) { onUsageCalls++ },
OnContext: func(breakdown ContextBreakdown) {
onContextCalls++
contextPlan = breakdown
},
}); err != nil {
t.Fatalf("Run: %v", err)
}
@@ -3731,6 +4141,12 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
if onUsageCalls == 0 {
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
}
if onContextCalls != 1 || len(contextPlan.Blocks) != 2 || contextPlan.PrefixInvalidationReason != "initial" {
t.Fatalf("context plan callback = calls %d, plan %#v", onContextCalls, contextPlan)
}
if len(tr.PrefixHashes) != 1 || tr.PrefixHashes[0].InvalidationReason != "initial" || tr.PrefixHashes[0].CompletePrefixHash != contextPlan.CompletePrefixHash {
t.Fatalf("trace context evidence = %#v, plan %#v", tr.PrefixHashes, contextPlan)
}
}
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
@@ -3756,3 +4172,263 @@ func TestRunNilTraceForwardsUsage(t *testing.T) {
t.Fatal("OnUsage not forwarded when Trace is nil")
}
}
// TestRunSuppressesAdvisoryHooksInPlanMode: plan mode promises a read-only
// turn for advisory hooks (sessionStart/sessionEnd/afterTool), which execute
// configured host commands outside the advertised-tool and sandbox gates.
// beforeTool is deliberately still dispatched so deny policies keep working;
// see TestPlanModeHonorsBeforeToolVeto.
func TestRunSuppressesAdvisoryHooksInPlanMode(t *testing.T) {
goBinary, err := exec.LookPath("go")
if err != nil {
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
goBinary = filepath.Join(goRoot, "bin", "go")
if runtime.GOOS == "windows" {
goBinary += ".exe"
}
if _, statErr := os.Stat(goBinary); statErr != nil {
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
}
}
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
sessionMarker := filepath.Join(t.TempDir(), "session-marker-dir")
afterToolMarker := filepath.Join(t.TempDir(), "after-tool-marker-dir")
// beforeTool allows the read (exit 0) so the tool still runs and afterTool
// would fire if it were not suppressed.
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: goBinary, Args: []string{"mod", "init", "-modfile", filepath.Join(sessionMarker, "go.mod"), "marker"}, Enabled: true},
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
{ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"version"}, Enabled: true},
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "read_file", Command: goBinary, Args: []string{"mod", "init", "-modfile", filepath.Join(afterToolMarker, "go.mod"), "marker"}, Enabled: true},
},
},
Audit: audit,
})
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil {
t.Fatalf("write notes.txt: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "plan drafted"},
{Type: zeroruntime.StreamEventDone},
},
}}
if _, err := Run(context.Background(), "plan something", provider, Options{
SessionID: "session-plan",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
}); err != nil {
t.Fatalf("Run: %v", err)
}
events, err := audit.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
sawBeforeTool := false
for _, event := range events {
if event.Type != "hook_execution_started" {
continue
}
switch event.Event {
case hooks.EventBeforeTool:
sawBeforeTool = true
case hooks.EventSessionStart, hooks.EventSessionEnd, hooks.EventAfterTool:
t.Fatalf("advisory hook %q executed during a plan-mode run", event.Event)
}
}
if !sawBeforeTool {
t.Fatal("expected beforeTool to still dispatch under plan mode (deny-gate must not fail open)")
}
for _, marker := range []string{sessionMarker, afterToolMarker} {
if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) {
t.Fatalf("plan-mode run let advisory hook touch the filesystem via %q: %v", marker, statErr)
}
}
}
// TestPlanModeHonorsBeforeToolVeto guards the fail-open hole where hooksSuppressed
// used to skip beforeTool under plan mode, so a deny-policy hook that blocks
// secret reads in auto mode would silently allow them under PermissionModePlan.
func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
goBinary, err := exec.LookPath("go")
if err != nil {
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
goBinary = filepath.Join(goRoot, "bin", "go")
if runtime.GOOS == "windows" {
goBinary += ".exe"
}
if _, statErr := os.Stat(goBinary); statErr != nil {
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
}
}
// A non-zero exit from beforeTool is a veto. "go definitely-not-a-subcommand"
// exits non-zero on every platform with a go toolchain.
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.veto", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"definitely-not-a-go-subcommand"}, Enabled: true},
},
},
})
root := t.TempDir()
secret := filepath.Join(root, "secret.txt")
if err := os.WriteFile(secret, []byte("SUPERSECRET"), 0o644); err != nil {
t.Fatalf("write secret.txt: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
var toolOutputs []string
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"secret.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "blocked"},
{Type: zeroruntime.StreamEventDone},
},
}}
if _, err := Run(context.Background(), "read the secret", provider, Options{
SessionID: "session-plan-veto",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
PermissionMode: PermissionModePlan,
MaxTurns: 2,
OnToolResult: func(result ToolResult) {
toolOutputs = append(toolOutputs, result.Output)
},
}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(toolOutputs) == 0 {
t.Fatal("expected a tool result for the vetoed read_file call")
}
combined := strings.Join(toolOutputs, "\n")
if strings.Contains(combined, "SUPERSECRET") {
t.Fatalf("plan mode failed open: beforeTool veto was skipped and secret leaked: %q", combined)
}
if !strings.Contains(combined, "blocked") && !strings.Contains(combined, "zero.veto") && !strings.Contains(strings.ToLower(combined), "hook") {
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)
}
})
}
}
@@ -0,0 +1,118 @@
package agent
import (
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// escalatingEngine allows unsandboxed escalation: no denied reads, so
// UnsandboxedExecutionAllowed is true and a prefix approval would rewrite the
// call to require_escalated.
func escalatingEngine(t *testing.T) *sandbox.Engine {
t.Helper()
policy := sandbox.DefaultPolicy()
policy.DenyRead = nil
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
}
// nonEscalatingEngine has a denied read, which is exactly the condition under
// which escalation is refused, so no disclosure should be made.
func nonEscalatingEngine(t *testing.T) *sandbox.Engine {
t.Helper()
root := t.TempDir()
policy := sandbox.DefaultPolicy()
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: policy})
}
// THE POINT OF THE FILE. The prompt's disclosure and the args rewrite must
// agree on every input, because the defect being fixed is precisely that the
// two disagreed: the rewrite escalated out of the sandbox while the prompt said
// only "allow command prefix for session".
//
// Asserting agreement rather than asserting each side's expected value is what
// makes this hold up. If someone later widens or narrows the rewrite's guards
// and forgets the flag, the two diverge here and this fails, which a pair of
// independent expected-value tests would not catch.
func TestPrefixEscalationDisclosureMatchesTheArgsRewrite(t *testing.T) {
escalating := Options{Sandbox: escalatingEngine(t)}
denied := Options{Sandbox: nonEscalatingEngine(t)}
cases := []struct {
name string
toolName string
args map[string]any
options Options
}{
{"plain bash command", "bash", map[string]any{"command": "go test ./..."}, escalating},
{"plain exec_command", "exec_command", map[string]any{"cmd": "go build ./..."}, escalating},
{"not a shell tool", "read_file", map[string]any{"path": "go.mod"}, escalating},
{"no sandbox configured", "bash", map[string]any{"command": "go test ./..."}, Options{}},
{"escalation refused by denied reads", "bash", map[string]any{"command": "go test ./..."}, denied},
{
"call already asks for escalation",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated)},
escalating,
},
{
"call already asks for additional permissions",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions)},
escalating,
},
// An unrecognized value is NOT an opt-out: the rewrite overwrites it, so
// the disclosure has to fire. Included because the obvious reading of
// the guards says otherwise, and a case asserting the wrong thing here
// would have passed silently.
{
"unrecognized sandbox_permissions value",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": "all"},
escalating,
},
{"empty args", "bash", map[string]any{}, escalating},
{"nil args", "bash", nil, escalating},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
disclosed := shellPrefixApprovalEscalates(testCase.toolName, testCase.args, testCase.options)
// What the user's approval would actually be turned into.
rewritten := shellExecutionArgsForApproval(testCase.toolName, testCase.args, PermissionDecisionAllowPrefix, testCase.options)
escalates := shellCommandRequiresEscalated(rewritten) && !shellCommandRequiresEscalated(testCase.args)
if disclosed != escalates {
t.Fatalf("prompt discloses escalation=%v but approving actually escalates=%v; the prompt and the rewrite disagree, which is the consent gap this guards", disclosed, escalates)
}
})
}
}
// The two prefix decisions are the only ones that escalate, so a disclosure
// attached to a request offering neither would be a false alarm. Checked
// against the same predicate the rewrite consults.
func TestOnlyPrefixDecisionsEscalate(t *testing.T) {
escalating := map[PermissionDecisionAction]bool{
PermissionDecisionAllowPrefix: true,
PermissionDecisionAlwaysAllowPrefix: true,
}
for _, action := range []PermissionDecisionAction{
PermissionDecisionAllow,
PermissionDecisionAllowStrict,
PermissionDecisionAllowForSession,
PermissionDecisionAllowPrefix,
PermissionDecisionAlwaysAllowPrefix,
PermissionDecisionAlwaysAllow,
PermissionDecisionDeny,
PermissionDecisionCancel,
} {
if got := shellPrefixApprovalBypassesSandbox(action); got != escalating[action] {
t.Errorf("decision %q bypasses sandbox=%v, want %v", action, got, escalating[action])
}
}
}
+20 -2
View File
@@ -20,7 +20,20 @@ import (
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 3550
// Raised deliberately from 3550 on 2026-08-08. view_image (#843) took the set
// to 3588, and #867 trimmed it back to 3578: still 28 tokens over.
//
// The overrun is a rounding error rather than a regression. view_image costs
// 82 tokens, the second cheapest tool in the set, against exec_command's 909
// and request_permissions' 397; it is simply the one that crossed the line.
// Raised rather than paid down because the alternative was trimming a
// description to claw back 28 tokens, degrading a tool's usability to satisfy
// a line drawn against a 2026-07 measurement that predates two tools.
//
// The ratchet did its job: it caught the creep and forced a decision instead
// of a drift. That is the point of it, so keep raising it deliberately rather
// than reflexively.
maxEagerToolSchemaTokens = 3650
)
func TestSystemPromptTokenBudget(t *testing.T) {
@@ -46,6 +59,11 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
// tool deferred leaves it exposed here and changes nothing. Deferral is
// still worth doing for real sessions, it just cannot move this number.
// The levers that do are a smaller schema, one fewer core tool, or a
// deliberate raise.
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
}
}
+15
View File
@@ -123,6 +123,21 @@ func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
}
}
func TestComputePrefixFingerprintCoversTransientSystemPrompt(t *testing.T) {
base := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
changed := base
changed.TransientSystemPrompt = "turn-only guidance"
fpBase := ComputePrefixFingerprint(base, nil)
fpChanged := ComputePrefixFingerprint(changed, nil)
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
t.Fatal("SystemPromptHash must change for transient system guidance")
}
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
t.Fatal("CompletePrefixHash must cover transient system guidance")
}
}
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
// substrings helper for default Options: which substrings are non-empty,
// which are empty, and that the substring-to-hash round-trip is lossless.
+15
View File
@@ -95,6 +95,9 @@ func buildSystemPromptParts(options Options) systemPromptParts {
core = fallbackSystemPrompt
}
sections := []string{core}
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
sections = append(sections, transient)
}
if addendum := modelPromptAddendum(options.Model); addendum != "" {
sections = append(sections, addendum)
}
@@ -118,6 +121,9 @@ func buildSystemPromptParts(options Options) systemPromptParts {
if project != "" {
sections = append(sections, project)
}
if modeCtx := permissionModeContext(options); modeCtx != "" {
sections = append(sections, modeCtx)
}
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
@@ -679,3 +685,12 @@ func gitBranchForPrompt(cwd string) string {
}
return ref
}
func permissionModeContext(options Options) string {
switch options.PermissionMode {
case PermissionModePlan:
return "Plan mode is active on this session. Your role is read-only exploration and planning: inspect the workspace and shape the plan with update_plan, but do not make changes to files or execute commands."
default:
return ""
}
}
+10
View File
@@ -51,6 +51,16 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
}
}
func TestTransientSystemPromptIsIncludedOnlyWhenProvided(t *testing.T) {
const transient = "Handle this peer request under the current session policy."
if prompt := buildSystemPrompt(Options{}); strings.Contains(prompt, transient) {
t.Fatal("ordinary system prompt unexpectedly contains transient instructions")
}
if prompt := buildSystemPrompt(Options{TransientSystemPrompt: transient}); !strings.Contains(prompt, transient) {
t.Fatalf("transient instructions missing from system prompt:\n%s", prompt)
}
}
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
for _, want := range []string{
+217 -10
View File
@@ -1,10 +1,14 @@
package agent
import (
"crypto/sha256"
"encoding/json"
"fmt"
"reflect"
"regexp"
"sort"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
@@ -52,17 +56,41 @@ type taskVerificationState struct {
LastOutcome Outcome `json:"last_outcome,omitempty"`
}
type taskFailureState struct {
Key string `json:"key,omitempty"`
Tool string `json:"tool"`
Command string `json:"command,omitempty"`
Summary string `json:"summary"`
}
type taskApprovalState struct {
Tool string `json:"tool"`
Decision PermissionDecisionAction `json:"decision,omitempty"`
Scope string `json:"scope,omitempty"`
Reason string `json:"reason,omitempty"`
}
type taskArtifactState struct {
Tool string `json:"tool"`
Path string `json:"path"`
}
type taskStateSnapshot struct {
Revision int `json:"revision"`
Objective string `json:"objective"`
Status taskStatus `json:"status"`
Plan taskPlanState `json:"plan"`
Tools taskToolState `json:"tools"`
Verification taskVerificationState `json:"verification"`
ChangedFiles []string `json:"changed_files,omitempty"`
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
CompletionReason string `json:"completion_reason,omitempty"`
PlanParity taskPlanParity `json:"plan_parity"`
Revision int `json:"revision"`
Objective string `json:"objective"`
Status taskStatus `json:"status"`
Plan taskPlanState `json:"plan"`
Tools taskToolState `json:"tools"`
Verification taskVerificationState `json:"verification"`
ChangedFiles []string `json:"changed_files,omitempty"`
Constraints []string `json:"constraints,omitempty"`
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
Approvals []taskApprovalState `json:"approvals,omitempty"`
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
ResolvedFailureKeys []string `json:"-"`
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
CompletionReason string `json:"completion_reason,omitempty"`
PlanParity taskPlanParity `json:"plan_parity"`
}
type taskStateEventKind int
@@ -72,6 +100,7 @@ const (
taskStateEventToolResult
taskStateEventVerification
taskStateEventCompletion
taskStateEventPermission
)
type taskStateEvent struct {
@@ -80,6 +109,7 @@ type taskStateEvent struct {
toolResult ToolResult
verification Outcome
completion completionEvaluation
permission PermissionEvent
}
// taskState is a deterministic projection of facts the loop already observes.
@@ -101,6 +131,7 @@ func newTaskState(objective string, recorder *trace.Recorder) *taskState {
},
changedFiles: map[string]struct{}{},
}
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
state.recorder = recorder
return state
}
@@ -134,6 +165,7 @@ func (state *taskState) observe(event taskStateEvent) bool {
}
}
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
state.observeToolEvidence(event.arguments, event.toolResult)
changed = true
case taskStateEventVerification:
if event.verification == OutcomeDisabled {
@@ -160,6 +192,10 @@ func (state *taskState) observe(event taskStateEvent) bool {
state.snapshotValue.Status = taskStatusActive
}
changed = true
case taskStateEventPermission:
state.markActive()
state.observePermission(event.permission)
changed = true
}
if changed {
state.snapshotValue.Revision++
@@ -183,9 +219,180 @@ func (state *taskState) snapshot() taskStateSnapshot {
snapshot := state.snapshotValue
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...)
snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...)
snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...)
snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...)
snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...)
return snapshot
}
const (
maxTaskEvidenceEntries = 8
maxTaskEvidenceBytes = 320
maxTaskConstraints = 10
)
func (state *taskState) observeToolEvidence(arguments string, result ToolResult) {
command := capTaskEvidence(commandFromArguments(arguments))
key := taskFailureKey(result.Name, command, arguments)
if result.Status == tools.StatusOK {
if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) {
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries)
}
} else {
state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key)
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{
Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)),
}, maxTaskEvidenceEntries)
}
for _, metaKey := range []string{"spill_path", "artifact_path"} {
if path := strings.TrimSpace(result.Meta[metaKey]); path != "" {
state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts,
taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries)
}
}
}
func taskFailureKey(toolName, command, arguments string) string {
identity := command
if identity == "" {
canonical := []byte(strings.TrimSpace(arguments))
var value any
if json.Unmarshal(canonical, &value) == nil {
if encoded, err := json.Marshal(value); err == nil {
canonical = encoded
}
}
digest := sha256.Sum256(canonical)
identity = fmt.Sprintf("args:%x", digest[:8])
}
return strings.TrimSpace(toolName) + "\x00" + identity
}
func (state *taskState) observePermission(event PermissionEvent) {
decision := event.DecisionAction
if decision == "" {
decision = PermissionDecisionAction(event.Action)
}
state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{
Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope),
Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)),
}, maxTaskEvidenceEntries)
}
func commandFromArguments(arguments string) string {
var object map[string]json.RawMessage
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
return ""
}
for _, key := range []string{"cmd", "command", "script", "shell"} {
var command string
if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil {
return strings.TrimSpace(command)
}
}
return ""
}
func firstLine(value string) string {
value = strings.TrimSpace(value)
if index := strings.IndexByte(value, '\n'); index >= 0 {
value = value[:index]
}
return value
}
func capTaskEvidence(value string) string {
value = strings.TrimSpace(value)
if len(value) <= maxTaskEvidenceBytes {
return value
}
limit := maxTaskEvidenceBytes - 3
for limit > 0 && !utf8.RuneStart(value[limit]) {
limit--
}
return strings.TrimSpace(value[:limit]) + "..."
}
var explicitConstraintPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`),
regexp.MustCompile(`(?i)\bdon'?t want\b`),
regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`),
regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`),
regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`),
regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`),
}
func extractExplicitConstraints(text string) []string {
var constraints []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") {
continue
}
for _, pattern := range explicitConstraintPatterns {
if pattern.MatchString(line) {
constraints = appendBoundedUnique(constraints, line, maxTaskConstraints)
break
}
}
if len(constraints) == maxTaskConstraints {
break
}
}
return constraints
}
func appendBounded[T any](values []T, value T, limit int) []T {
values = append(values, value)
if len(values) > limit {
values = values[len(values)-limit:]
}
return values
}
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T {
filtered := values[:0]
for _, existing := range values {
if existing != value {
filtered = append(filtered, existing)
}
}
return appendBounded(filtered, value, limit)
}
func hasTaskFailure(values []taskFailureState, key string) bool {
for _, value := range values {
if value.Key == key {
return true
}
}
return false
}
func removeTaskFailure(values []taskFailureState, key string) []taskFailureState {
out := values[:0]
for _, value := range values {
if value.Key != key {
out = append(out, value)
}
}
return out
}
func removeString(values []string, target string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if value != target {
out = append(out, value)
}
}
return out
}
// observePlanParity compares only the latest plan projection with the plan tool
// calls still present in messages. It mutates the snapshot and emits when the
// parity value changes; objective, tool, and verification fields are not part of
+62
View File
@@ -115,6 +115,68 @@ func TestTaskStateSnapshotIsImmutable(t *testing.T) {
}
}
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
arguments := `{"cmd":"go test ./internal/agent"}`
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
}})
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
Scope: "/tmp", DecisionReason: "write a temporary fixture",
}})
snapshot := state.snapshot()
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
}
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
}
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
}
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
}})
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
}})
snapshot = state.snapshot()
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
}
for _, key := range snapshot.ResolvedFailureKeys {
if key == snapshot.UnresolvedFailures[0].Key {
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
}
}
}
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
+53 -3
View File
@@ -26,6 +26,15 @@ const (
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode. It applies
// to the CURRENT session (unlike spec-draft, which drafts in a separate
// session): the agent may inspect the workspace and shape the plan with
// update_plan/ask_user, but no mutating tool is advertised, so it cannot
// write files, run shell, or implement while planning. Entry points:
// the TUI's /plan on (exit with /plan off, which restores whatever mode
// was active before), `zero exec --plan`, and the ACP session mode
// selector ("plan").
PermissionModePlan PermissionMode = "plan"
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
// advertises the in-workspace mutators a member needs to build (write/edit +
// shell) on top of the Auto set, while the sandbox engine still gates them at
@@ -79,6 +88,7 @@ type ToolResult struct {
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Display tools.Display
Outcome tools.ToolOutcome
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
@@ -99,6 +109,24 @@ type ToolResult struct {
RequestedModel string
}
// ModelOutput returns the bounded provider-facing result while preserving
// compatibility with synthetic and restored results created before outcomes
// were finalized.
func (result ToolResult) ModelOutput() string {
if result.Outcome.Finalized() {
return result.Outcome.ModelView
}
return result.Output
}
// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() tools.Display {
if result.Outcome.Finalized() {
return result.Outcome.HumanView
}
return result.Display
}
// DenialCategory classifies why a tool call was blocked before it executed.
type DenialCategory string
@@ -176,6 +204,22 @@ type PermissionRequest struct {
Grant *sandbox.Grant `json:"grant,omitempty"`
CommandPrefix []string `json:"commandPrefix,omitempty"`
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
// PrefixApprovalEscalates reports that approving a command prefix will also
// run the command OUTSIDE the sandbox, not merely stop asking about it.
//
// It exists because that consequence was real but invisible. Approving a
// prefix rewrites the call to sandbox_permissions: require_escalated, which
// resolves to a nil engine and genuinely unsandboxed execution, and the
// engine's own escalation prompt is then satisfied by the approval just
// given for the sandboxed form. So the operator authorized one thing and got
// a wider one, having been shown only "allow command prefix for session".
//
// The escalation itself is deliberate and guarded: proposedCommandPrefix
// refuses to offer a prefix while any other segment of the command is not
// known-safe, precisely so an unreviewed segment cannot ride out of the
// sandbox on it. What was missing was telling the person deciding, so this
// surfaces it rather than removing it.
PrefixApprovalEscalates bool `json:"prefixApprovalEscalates,omitempty"`
}
type PermissionDecision struct {
@@ -281,8 +325,12 @@ type Options struct {
ProviderName string
Model string
ReasoningEffort string
ServiceTier string
Cwd string
SystemPrompt string
// TransientSystemPrompt adds trusted runtime guidance for this run only.
// Empty preserves the ordinary system prompt byte-for-byte.
TransientSystemPrompt string
// ResponseStyle is the operator-selected reply style from the TUI /style
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
// system prompt as a short directive. Empty or "balanced" adds nothing — the
@@ -333,9 +381,11 @@ type Options struct {
// specialist child process emits while running. The toolCallID identifies
// which Task tool call the progress belongs to. nil is a no-op.
OnToolProgress func(toolCallID string, event streamjson.Event)
// OnContext, when set, is called once per turn with the per-category context
// budget of the request about to be sent, so a surface (TUI/CLI) can show
// context utilization. Opt-in like the other callbacks; nil is a no-op.
// OnContext, when set, is called for each main agent request with its
// per-category context budget, including a replacement request after
// compaction or a stall retry. Internal summarizer requests are excluded so
// surfaces keep showing the active conversation budget. Opt-in like the other
// callbacks; nil is a no-op.
OnContext func(ContextBreakdown)
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
// mid-run: the loop calls it with the requested model id and, on success,
+18
View File
@@ -39,3 +39,21 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
func terminateProcess(pid int) error {
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
}
// terminateOwnedProcess terminates cmd's process group directly when its launch
// configuration proves it was made a group leader. This avoids fragile Getpgid
// rediscovery after an owned leader exits. Ordinary commands fall back to the
// safe PID/tree path rather than assuming their PID is also a process-group ID.
//
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
// own convention. A command made its own session leader via Setsid (as opposed
// to Setpgid) is also its own process-group leader in practice, but takes the
// slower rediscovery path here since Setsid isn't checked. Harmless today
// because TerminateCommand has exactly one caller in this codebase; worth
// covering explicitly if Setsid-configured commands start using this path too.
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
}
return false, terminateProcess(cmd.Process.Pid)
}
+75 -4
View File
@@ -147,11 +147,12 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
t.Fatalf("terminateProcess: %v", err)
}
// The forked child must be gone too (poll until reaped by init).
// The forked child must no longer be running. An orphaned zombie is already
// dead but may remain visible briefly until the platform's init reaps it.
deadline := time.Now().Add(2 * time.Second)
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
// processStopped treats only ESRCH or an observed zombie state as stopped;
// other errors (for example EPERM) remain live and keep polling.
for !processStopped(childPID) {
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
@@ -159,3 +160,73 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
time.Sleep(20 * time.Millisecond)
}
}
func TestTerminateCommandStopsUnconfiguredCommand(t *testing.T) {
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
if cmd.ProcessState == nil {
_ = cmd.Process.Kill()
}
})
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
if cmd.ProcessState == nil {
t.Fatal("unconfigured command was not reaped")
}
}
func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
grace, poll := terminationGracePeriod, terminationPollInterval
terminationGracePeriod, terminationPollInterval = 2*time.Second, 20*time.Millisecond
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
// The leader exits immediately after launching the child. TerminateCommand
// must capture and signal the process group before Wait reaps the leader and
// makes its group identity unavailable.
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
ConfigureChildProcessGroup(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read forked child pid: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse forked child pid %q: %v", line, err)
}
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for !processStopped(childPID) {
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived TerminateCommand", childPID)
}
time.Sleep(20 * time.Millisecond)
}
}
func processStopped(pid int) bool {
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
return true
}
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
}
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
}
+34
View File
@@ -3,11 +3,20 @@
package background
import (
"fmt"
"os/exec"
"github.com/Gitlawb/zero/internal/execution"
"golang.org/x/sys/windows"
)
// stillActiveExitCode is the documented GetExitCodeProcess value for a process
// that has not terminated. x/sys/windows does not expose Windows' STILL_ACTIVE
// constant.
const stillActiveExitCode uint32 = 259
var terminateProcessForTest = terminateProcess
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
// delegated to execution.TerminateProcessTree, so no launch-time process-group
// setup is required (the POSIX build sets Setpgid here instead).
@@ -16,3 +25,28 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup
func terminateProcess(pid int) error {
return execution.TerminateProcessTree(pid, 0, 0)
}
// terminateOwnedProcess asks taskkill /T to terminate the tree rooted at cmd,
// with KillProcessTree's direct Process.Kill fallback if taskkill fails. taskkill
// can discover descendants only while the root PID still exists; unlike a POSIX
// process group, a Windows tree has no independently addressable identity after
// its root exits.
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
var (
alreadyExited bool
terminateErr error
)
err := cmd.Process.WithHandle(func(handle uintptr) {
var exitCode uint32
if windows.GetExitCodeProcess(windows.Handle(handle), &exitCode) == nil {
alreadyExited = exitCode != stillActiveExitCode
}
// Keep the exact process identity pinned while the PID-based taskkill
// operation runs, preventing the PID from being recycled underneath it.
terminateErr = terminateProcessForTest(cmd.Process.Pid)
})
if err != nil {
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
}
return alreadyExited, terminateErr
}
+106
View File
@@ -0,0 +1,106 @@
//go:build windows
package background
import (
"bufio"
"errors"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
func TestTerminateCommandReturnsTreeFailureAfterRootFallbackReaps(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 300")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
treeErr := errors.New("taskkill /T failed")
originalTerminateProcess := terminateProcessForTest
terminateProcessForTest = func(pid int) error {
if err := cmd.Process.Kill(); err != nil {
return errors.Join(treeErr, err)
}
return treeErr
}
t.Cleanup(func() { terminateProcessForTest = originalTerminateProcess })
err := TerminateCommand(cmd)
if !errors.Is(err, treeErr) {
t.Fatalf("TerminateCommand error = %v, want tree failure", err)
}
if cmd.ProcessState == nil {
t.Fatal("root process was not reaped")
}
}
func TestTerminateCommandReapsExitedLeader(t *testing.T) {
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
// Let the child exit without calling Wait so cleanup exercises the Windows
// taskkill/TerminateProcess race while it still owns the process handle.
time.Sleep(500 * time.Millisecond)
// jatmn's #774 finding: taskkill /T racing an already-exited PID (and the
// Process.Kill fallback) both fail here, but the reap below still succeeds —
// the process-handle exit-code check proves the leader was already gone, so
// TerminateCommand must not surface the expected kill-attempt error.
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v, want nil (leader was already gone and got reaped)", err)
}
if cmd.ProcessState == nil {
t.Fatal("exited process was not reaped")
}
}
func TestTerminateCommandDeadLeaderCannotIdentifyDescendantTree(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
`$p = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 300' -PassThru; $p.Id`)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read descendant pid: %v", err)
}
descendantPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse descendant pid %q: %v", line, err)
}
t.Cleanup(func() {
_ = exec.Command("taskkill.exe", "/F", "/PID", strconv.Itoa(descendantPID)).Run()
})
// Wait until the leader is observably exited but deliberately leave it
// unreaped, matching the POSIX regression's ordering.
deadline := time.Now().Add(5 * time.Second)
for processExistsOnWindows(cmd.Process.Pid) {
if time.Now().After(deadline) {
t.Fatal("leader did not exit")
}
time.Sleep(20 * time.Millisecond)
}
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
// Windows has no persistent group ID analogous to POSIX: once the leader is
// gone, taskkill /T cannot rediscover this descendant. Assert that platform
// distinction rather than claiming TerminateCommand can provide it here.
if !processExistsOnWindows(descendantPID) {
t.Fatal("descendant unexpectedly stopped after its root exited")
}
}
func processExistsOnWindows(pid int) bool {
return exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
"Get-Process -Id "+strconv.Itoa(pid)+" -ErrorAction Stop | Out-Null").Run() == nil
}
+75
View File
@@ -1,5 +1,14 @@
package background
import (
"errors"
"fmt"
"os/exec"
"time"
)
const commandReapTimeout = 3 * time.Second
// TerminateProcess stops a background process by PID — on Windows its process
// tree; on POSIX its whole process group when the PID leads its own group (the
// invariant ConfigureChildProcessGroup establishes for processes started through
@@ -12,3 +21,69 @@ package background
func TerminateProcess(pid int) error {
return terminateProcess(pid)
}
// TerminateCommand stops a started command and reaps its leader. The caller must
// have exclusive ownership of cmd: it must not have previously called Wait or
// Process.Release, and no goroutine may call either concurrently. On POSIX it
// stops the whole group when the command was configured as its leader; ordinary
// commands safely fall back to PID/tree discovery. On Windows, success for a
// leader that was already dead confirms only that the leader was reaped;
// descendants may survive because Windows cannot rediscover a tree from a dead
// root. `zero daemon start` needs this operation when readiness times out: it
// launched the child, so it must both stop the tree and collect the leader.
//
// The order matters: the tree is signalled first, because Wait releases the
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
// to a recycled PID). Termination stays in the shared execution lifecycle
// primitives rather than introducing a second platform-specific killer.
//
// If the bounded reap times out, the Wait call already started by this function
// continues in its goroutine. From that point onward the caller must not access
// cmd at all, including ProcessState, because that would race with Wait.
func TerminateCommand(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return errors.New("terminate command: process was never started")
}
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
if reapErr != nil {
if terminateErr != nil {
return fmt.Errorf("%v (reap failed: %w)", terminateErr, reapErr)
}
return reapErr
}
if terminateErr != nil && !leaderAlreadyExited {
return terminateErr
}
// Only discard a termination error when the platform demonstrated before
// attempting termination that the leader had already exited. A successful
// reap alone says nothing about whether a live descendant tree was stopped.
return nil
}
// classifyWaitError treats a non-zero exit status as success: a process being
// terminated is expected to report one (or a signal), so the only interesting
// failure is Wait itself not working.
func classifyWaitError(waitErr error) error {
var exitErr *exec.ExitError
if waitErr != nil && !errors.As(waitErr, &exitErr) {
return fmt.Errorf("reap process: %w", waitErr)
}
return nil
}
// waitForTerminatedCommandWithin bounds the reap so a child that somehow survives
// termination cannot hang the caller — the daemon-start cleanup path runs while a
// user waits at the CLI. On timeout its Wait goroutine is intentionally left to
// finish: exec.Cmd permits only one Wait call, and abandoning it without a reaper
// would leak the child once it eventually exits.
func waitForTerminatedCommandWithin(cmd *exec.Cmd, timeout time.Duration) error {
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
select {
case waitErr := <-waitDone:
return classifyWaitError(waitErr)
case <-time.After(timeout):
return fmt.Errorf("process %d did not reap after termination", cmd.Process.Pid)
}
}
+166 -38
View File
@@ -23,6 +23,7 @@ import (
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/peermsg"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
@@ -63,37 +64,53 @@ type appDeps struct {
// getenv reads a process environment variable (production: os.Getenv, set in
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
getenv func(string) string
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
getenv func(string) string
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
releaseWorktree func(context.Context, worktrees.Options, string) error
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
createBranch func(context.Context, zerogit.BranchOptions) (zerogit.BranchResult, error)
isDefaultBranch func(context.Context, zerogit.DefaultBranchOptions) (bool, string, string, error)
currentGitUser func(context.Context, string) string
headCommitSubject func(context.Context, string) string
commitsAhead func(context.Context, string, string, string) (int, error)
isUnbornRemote func(context.Context, string, string) (bool, error)
refreshTrackingRef func(context.Context, string, string, string) error
branchUpstreamRemote func(context.Context, string, string) string
branchUpstreamRemoteAndMerge func(context.Context, string, string) (string, string)
resolveRemoteBranchTip func(context.Context, string, string, string) (string, error)
remoteHasBranch func(context.Context, string, string, string) (bool, error)
currentGitBranch func(context.Context, string) string
currentBranchTip func(context.Context, string) string
deleteBranch func(context.Context, string, string, string) error
resetBranchRef func(context.Context, string, string, string, string) error
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
}
type mcpToolRuntime interface {
@@ -187,6 +204,7 @@ func defaultAppDeps() appDeps {
return mcp.RegisterTools(ctx, registry, cfg, options)
},
prepareWorktree: worktrees.Prepare,
releaseWorktree: worktrees.Release,
detectVerifyPlan: verify.DetectPlan,
runVerify: verify.Run,
runSelfVerify: selfverify.Run,
@@ -195,11 +213,52 @@ func defaultAppDeps() appDeps {
commitChanges: zerogit.Commit,
pushChanges: zerogit.Push,
createPR: zerogit.CreatePR,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
createBranch: zerogit.CreateBranch,
isDefaultBranch: zerogit.IsDefaultBranch,
currentGitUser: func(ctx context.Context, cwd string) string {
return zerogit.CurrentGitUser(ctx, cwd, nil)
},
headCommitSubject: func(ctx context.Context, cwd string) string {
return zerogit.HeadCommitSubject(ctx, cwd, nil)
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return zerogit.CommitsAhead(ctx, cwd, remote, branch, nil)
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
return zerogit.IsUnbornRemote(ctx, cwd, remote, nil)
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
return zerogit.RefreshTrackingRef(ctx, cwd, remote, branch, nil)
},
branchUpstreamRemote: func(ctx context.Context, cwd, branch string) string {
return zerogit.UpstreamRemote(ctx, cwd, branch, nil)
},
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
return zerogit.UpstreamRemoteAndMergeBranch(ctx, cwd, branch, nil)
},
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
return zerogit.ResolveRemoteBranchTip(ctx, cwd, remote, branch, nil)
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
return zerogit.RemoteHasBranch(ctx, cwd, remote, branch, nil)
},
currentGitBranch: func(ctx context.Context, cwd string) string {
return zerogit.CurrentBranch(ctx, cwd, nil)
},
currentBranchTip: func(ctx context.Context, cwd string) string {
return zerogit.CurrentBranchTip(ctx, cwd, nil)
},
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
return zerogit.DeleteBranch(ctx, cwd, fallbackBranch, branchToDelete, nil)
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
return zerogit.ResetBranchRef(ctx, cwd, branch, newTip, nil, expectedOld)
},
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -532,6 +591,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.prepareWorktree == nil {
deps.prepareWorktree = defaults.prepareWorktree
}
if deps.releaseWorktree == nil {
deps.releaseWorktree = defaults.releaseWorktree
}
if deps.detectVerifyPlan == nil {
deps.detectVerifyPlan = defaults.detectVerifyPlan
}
@@ -556,6 +618,45 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.createPR == nil {
deps.createPR = defaults.createPR
}
if deps.createBranch == nil {
deps.createBranch = defaults.createBranch
}
if deps.isDefaultBranch == nil {
deps.isDefaultBranch = defaults.isDefaultBranch
}
if deps.currentGitUser == nil {
deps.currentGitUser = defaults.currentGitUser
}
if deps.headCommitSubject == nil {
deps.headCommitSubject = defaults.headCommitSubject
}
if deps.commitsAhead == nil {
deps.commitsAhead = defaults.commitsAhead
}
if deps.isUnbornRemote == nil {
deps.isUnbornRemote = defaults.isUnbornRemote
}
if deps.refreshTrackingRef == nil {
deps.refreshTrackingRef = defaults.refreshTrackingRef
}
if deps.branchUpstreamRemote == nil {
deps.branchUpstreamRemote = defaults.branchUpstreamRemote
}
if deps.branchUpstreamRemoteAndMerge == nil {
deps.branchUpstreamRemoteAndMerge = defaults.branchUpstreamRemoteAndMerge
}
if deps.remoteHasBranch == nil {
deps.remoteHasBranch = defaults.remoteHasBranch
}
if deps.currentGitBranch == nil {
deps.currentGitBranch = defaults.currentGitBranch
}
if deps.currentBranchTip == nil {
deps.currentBranchTip = defaults.currentBranchTip
}
// resolveRemoteBranchTip, deleteBranch, and resetBranchRef stay nil when
// unset so unit tests that mock createBranch without a real git tree do not
// hit real git restore/delete commands.
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
@@ -786,6 +887,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if permissionMode == "" {
permissionMode = agent.PermissionModeAsk
}
sessionStore := deps.newSessionStore()
peerClass := peermsg.PermissionPrompting
if permissionMode == agent.PermissionModeUnsafe {
peerClass = peermsg.PermissionBypass
}
peerService, peerErr := peermsg.New(peermsg.Options{
Identity: peermsg.Identity{
Cwd: workspaceRoot,
PermissionClass: peerClass,
},
InboundPolicy: peermsg.InboundPolicy(resolved.CrossSessionInbound),
})
if peerErr != nil {
fmt.Fprintf(stderr, "warning: cross-session messaging unavailable: %s\n", peerErr)
peerService = nil
} else {
for _, tool := range tools.NewPeerSessionTools(peerService) {
registry.Register(tool)
}
}
// Activate deferred MCP-tool loading for the interactive run only when the
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
// The registry is complete (core + specialist + MCP + plugins) here, so the
@@ -816,6 +937,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Version: version,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
SavedPet: resolved.Preferences.Pet,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -837,7 +959,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return scratchFileWarning(workspaceRoot, scratchBaseline)
},
Registry: registry,
SessionStore: deps.newSessionStore(),
SessionStore: sessionStore,
PeerService: peerService,
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
@@ -1199,7 +1322,8 @@ Commands:
mcp Manage MCP backend settings
auth Log in to model providers via OAuth
sandbox Inspect sandbox policy and persistent grants
update Check for Zero CLI updates
update Check or apply Zero CLI updates (requires --check or --apply)
upgrade Download, verify, and install available Zero CLI updates
worktrees Prepare isolated git worktrees
verify Detect and run local verification checks
eval Validate offline agent eval suites
@@ -1361,6 +1485,10 @@ Flags:
--spec-model <model> Override the draft model when --use-spec is set
--spec-reasoning-effort <effort>
Override draft reasoning effort when --use-spec is set
--plan Read-only planning mode: write and shell tools are hidden
--permission-mode <mode> Set permission mode directly (plan, spec-draft, auto, member,
ask, unsafe). Outranks --auto; prefer --plan / --auto for
interactive use. Used by specialist/swarm child processes.
--max-turns <number> Override the maximum agent loop turns
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
posture only (turn budget, effort, self-correction, escalation);
+15
View File
@@ -1457,6 +1457,20 @@ func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) {
}
}
func TestTopLevelHelpDocumentsRequiredUpdateMode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if got := stdout.String(); !strings.Contains(got, "update Check or apply Zero CLI updates (requires --check or --apply)") {
t.Fatalf("expected top-level help to document required update mode, got %q", got)
}
}
func TestRunUpdateReportsUpToDate(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1660,6 +1674,7 @@ func assertHelpOutput(t *testing.T, args []string) {
"mcp",
"sandbox",
"update",
"upgrade",
"worktrees",
"verify",
"serve",
+2 -2
View File
@@ -23,7 +23,7 @@ import (
func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T) {
cwd := t.TempDir()
secret := "sk-proj-" + strings.Repeat("a", 24)
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
@@ -161,7 +161,7 @@ func TestRunBackendsTextAndHelp(t *testing.T) {
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
cwd := t.TempDir()
secret := "sk-proj-" + strings.Repeat("b", 24)
secret := "sk-proj-" + strings.Repeat("b", 23) + "0"
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
+3 -2
View File
@@ -24,7 +24,8 @@ var completionRoot = completionNode{
children: []completionNode{
{names: []string{"exec"}, flags: []string{
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--max-turns", "--exec-profile",
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--plan", "--permission-mode",
"--max-turns", "--exec-profile",
"--auto", "--enabled-tools", "--disabled-tools", "--list-tools", "--profile", "-r",
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
@@ -84,7 +85,7 @@ var completionRoot = completionNode{
}},
{names: []string{"update"}},
{names: []string{"upgrade"}},
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare")},
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare", "release")},
{names: []string{"verify"}},
{names: []string{"trust"}, children: leafNodes("list", "remove")},
{names: []string{"eval"}, children: leafNodes("validate", "run", "bench")},
+2
View File
@@ -155,6 +155,8 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
assertCandidates(t, byPath["worktrees"], "prepare", "release")
assertCandidates(t, byPath["worktree"], "prepare", "release")
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
+52 -10
View File
@@ -1,6 +1,7 @@
package cli
import (
"errors"
"fmt"
"io"
"os"
@@ -160,20 +161,61 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
cmd.Stdout = logFile
cmd.Stderr = logFile
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
if err := cmd.Start(); err != nil {
if err := startAndAwaitDaemonProcess(cmd, func() bool { return daemonReachable(paths) }, 5*time.Second, 25*time.Millisecond); err != nil {
if errors.Is(err, errDaemonStartTimeout) {
return writeAppError(stderr, err.Error()+"; see "+logPath, exitCrash)
}
return writeAppError(stderr, err.Error(), exitCrash)
}
_ = cmd.Process.Release()
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
return exitSuccess
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if daemonReachable(paths) {
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
return exitSuccess
}
time.Sleep(25 * time.Millisecond)
var errDaemonStartTimeout = errors.New("daemon did not come up within timeout")
func startAndAwaitDaemonProcess(cmd *exec.Cmd, reachable func() bool, timeout time.Duration, pollInterval time.Duration) error {
if err := cmd.Start(); err != nil {
return err
}
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
if waitForDaemonReadiness(reachable, timeout, pollInterval) {
if err := cmd.Process.Release(); err != nil {
cleanupErr := terminateAndReapDaemonProcess(cmd)
if cleanupErr != nil {
return fmt.Errorf("release daemon process: %w (cleanup failed: %v)", err, cleanupErr)
}
return fmt.Errorf("release daemon process: %w", err)
}
return nil
}
if err := terminateAndReapDaemonProcess(cmd); err != nil {
return fmt.Errorf("%w; cleanup failed: %v", errDaemonStartTimeout, err)
}
return errDaemonStartTimeout
}
func waitForDaemonReadiness(reachable func() bool, timeout time.Duration, pollInterval time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if reachable() {
return true
}
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
time.Sleep(min(pollInterval, remaining))
}
// The loop's last reachable() call already ran immediately before this one
// (right after the final Sleep, with the same deadline check separating
// them), so this adds no extra waiting time and is not a grace window for a
// slow-starting daemon — it only re-checks the same instant in case
// reachable() itself is non-deterministic. To tolerate a genuinely slow
// start, raise the timeout passed to startAndAwaitDaemonProcess instead.
return reachable()
}
func terminateAndReapDaemonProcess(cmd *exec.Cmd) error {
return background.TerminateCommand(cmd)
}
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
+88
View File
@@ -2,8 +2,15 @@ package cli
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/background"
)
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
@@ -121,3 +128,84 @@ func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
}
}
}
func TestWaitForDaemonReadinessChecksTimeoutBoundary(t *testing.T) {
checks := 0
ready := waitForDaemonReadiness(func() bool {
checks++
return checks == 2
}, 0, time.Millisecond)
if !ready {
t.Fatal("daemon that became reachable at the timeout boundary was not detected")
}
if checks != 2 {
t.Fatalf("reachability checks = %d, want initial and final checks", checks)
}
}
func TestTerminateAndReapDaemonProcess(t *testing.T) {
cmd := daemonDetachedChildCommand("hang")
if err := cmd.Start(); err != nil {
t.Fatalf("start helper process: %v", err)
}
if err := terminateAndReapDaemonProcess(cmd); err != nil {
t.Fatalf("terminate and reap helper process: %v", err)
}
if cmd.ProcessState == nil {
t.Fatalf("helper process was not reaped: state=%v", cmd.ProcessState)
}
if cmd.ProcessState.Success() {
t.Fatalf("helper process unexpectedly exited successfully: %v", cmd.ProcessState)
}
}
func TestStartAndAwaitDaemonProcessTimeoutTerminatesAndReaps(t *testing.T) {
cmd := daemonDetachedChildCommand("hang")
if err := startAndAwaitDaemonProcess(cmd, func() bool { return false }, 0, time.Millisecond); !errors.Is(err, errDaemonStartTimeout) {
t.Fatalf("start and await error = %v, want timeout", err)
}
if cmd.ProcessState == nil {
t.Fatal("timed-out helper process was not reaped")
}
}
func TestStartAndAwaitDaemonProcessReleasesReadyChild(t *testing.T) {
marker := filepath.Join(t.TempDir(), "child-finished")
cmd := daemonDetachedChildCommand("mark", "ZERO_TEST_DAEMON_CHILD_MARKER="+marker)
if err := startAndAwaitDaemonProcess(cmd, func() bool { return true }, time.Second, time.Millisecond); err != nil {
t.Fatalf("start and await ready helper: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(marker); err == nil {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("released helper process did not remain alive to write its marker")
}
func daemonDetachedChildCommand(mode string, extraEnv ...string) *exec.Cmd {
cmd := exec.Command(os.Args[0], "-test.run=TestDaemonDetachedChildProcess")
cmd.Env = append(os.Environ(), "ZERO_TEST_DAEMON_DETACHED_CHILD="+mode)
cmd.Env = append(cmd.Env, extraEnv...)
background.ConfigureChildProcessGroup(cmd)
return cmd
}
func TestDaemonDetachedChildProcess(t *testing.T) {
switch os.Getenv("ZERO_TEST_DAEMON_DETACHED_CHILD") {
case "":
return
case "mark":
if err := os.WriteFile(os.Getenv("ZERO_TEST_DAEMON_CHILD_MARKER"), []byte("ready"), 0o600); err != nil {
os.Exit(2)
}
os.Exit(0)
}
for {
time.Sleep(time.Hour)
}
}
+49 -10
View File
@@ -78,11 +78,18 @@ type execOptions struct {
// fill-only-if-unset rule, so precedence is explicit flag > mode > profile.
// Distinct from the mode preset also named "fast" (which picks a model) and
// from the legacy inert --profile above. See internal/execprofile.
execProfile string
reasoningEffort string
useSpec bool
specModel string
specReasoningEffort string
execProfile string
reasoningEffort string
useSpec bool
specModel string
specReasoningEffort string
// plan selects PermissionModePlan for this run: the same read-only,
// in-session planning mode the TUI enters with /plan on. Unlike --use-spec
// (a separate draft session with its own review flow), this only swaps the
// permission mode for the run already being made — ToolAdvertised gates
// write/shell tools generically for any mode, so no other exec plumbing
// needs to know about it.
plan bool
maxTurns int
cwd string
inputFormat execInputFormat
@@ -104,6 +111,7 @@ type execOptions struct {
worktreeName string
worktreeDir string
skipPermissionsUnsafe bool
permissionMode string
// allowEscalation opts the run into mid-run model escalation: it registers
// the escalate_model tool and wires agent.Options.ModelSwitcher. Off by
// default — a run without the flag is byte-identical to before (no tool, nil
@@ -199,11 +207,33 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Name: options.worktreeName,
BaseDir: options.worktreeDir,
Now: deps.now,
// The worktree's lifetime is bound to this process (the deferred
// release below), so record the PID: if this process dies without
// releasing, Clean can expire the lease instead of skipping the
// locked worktree forever.
LeasePID: os.Getpid(),
})
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
workspaceRoot = preparedWorktree.Path
// When this run's own Prepare call took the worktree lock, its
// lifetime is bound to this function: release the lock once it returns
// so Clean can reclaim the worktree later if it goes stale. A reused
// worktree whose lock an external `zero worktrees prepare` caller
// still holds reports LockAcquired=false; releasing it here would
// clear that caller's lease and expose its workspace to Clean, so the
// matching release stays that caller's responsibility. A failed unlock
// leaves a lock Clean will permanently skip, so it must not pass
// silently; the run's primary result has already been emitted by the
// time the defer runs, so surface it as a diagnostic.
if preparedWorktree.LockAcquired {
defer func() {
if releaseErr := deps.releaseWorktree(context.Background(), worktrees.Options{Cwd: trustRoot}, preparedWorktree.Path); releaseErr != nil {
fmt.Fprintf(stderr, "zero: failed to release worktree lock on %s: %s\n", redactCLIString(preparedWorktree.Path), redactCLIString(releaseErr.Error()))
}
}()
}
}
registry := newCoreRegistry(workspaceRoot)
@@ -240,6 +270,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
if options.plan {
permissionMode = agent.PermissionModePlan
}
var mcpRuntime mcpToolRuntime
var mcpSkip trustSkip
// Make local plugins live for this run: register their declared tools into the
@@ -306,11 +339,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
executionRunner.SetPreparer(sandboxEngine)
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
if permissionMode != agent.PermissionModePlan {
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
}
defer closeMCPRuntime(stderr, mcpRuntime)
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
if err := validateExecToolFilters(options, registry); err != nil {
@@ -578,8 +613,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// warned, so --auto high silently ran without notice.
if permissionMode == agent.PermissionModeUnsafe {
reason := "--auto high"
if options.skipPermissionsUnsafe {
switch {
case options.skipPermissionsUnsafe:
reason = "--skip-permissions-unsafe"
case strings.EqualFold(strings.TrimSpace(options.permissionMode), "unsafe"),
strings.EqualFold(strings.TrimSpace(options.permissionMode), "high"):
reason = "--permission-mode unsafe"
}
writer.warning(fmt.Sprintf("Unsafe permissions are active for this run because %s was passed.", reason))
if writer.err != nil {
+36
View File
@@ -20,6 +20,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--permission-mode":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.permissionMode = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--permission-mode="):
value, err := requiredInlineFlagValue(arg, "--permission-mode")
if err != nil {
return options, false, err
}
options.permissionMode = value
case arg == "--skip-permissions-unsafe":
options.skipPermissionsUnsafe = true
case arg == "--list-tools":
@@ -171,6 +184,8 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
options.reasoningEffort = strings.TrimSpace(strings.TrimPrefix(arg, "--reasoning-effort="))
case arg == "--use-spec":
options.useSpec = true
case arg == "--plan":
options.plan = true
case arg == "--spec-model":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -437,6 +452,27 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
if !options.useSpec && options.specReasoningEffort != "" {
return options, false, execUsageError{"--spec-reasoning-effort requires --use-spec."}
}
// Plan mode may be entered via --plan or --permission-mode plan. Combination
// guards must key off both: otherwise --permission-mode plan bypasses the
// worktree/unsafe/use-spec rejects that --plan alone enforces, and worktree
// prep mutates the filesystem before the read-only mode is assigned.
planMode := options.plan || strings.EqualFold(strings.TrimSpace(options.permissionMode), "plan")
if planMode && options.useSpec {
return options, false, execUsageError{"Use either --plan or --use-spec, not both."}
}
if planMode && options.skipPermissionsUnsafe {
return options, false, execUsageError{"Use either --plan or --skip-permissions-unsafe, not both."}
}
if planMode && options.worktree {
// Worktree prep (copying/branching the workspace) runs before the plan
// permission mode is assigned, so it would happen even under plan mode's
// read-only, no-side-effects promise. Reject the combination outright
// rather than let a mutation slip in ahead of the mode gate.
return options, false, execUsageError{"--plan cannot be combined with --worktree."}
}
if options.plan && options.permissionMode != "" && strings.ToLower(options.permissionMode) != "plan" {
return options, false, execUsageError{"--plan cannot be combined with --permission-mode=" + options.permissionMode + "."}
}
if options.initSessionID != "" && (options.resume != "" || options.resumeLatest) {
return options, false, execUsageError{"Use --init-session-id only when creating or forking a session."}
}
+177
View File
@@ -0,0 +1,177 @@
package cli
import (
"bytes"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
)
func TestParseExecArgsRecognizesPlanFlag(t *testing.T) {
options, _, err := parseExecArgs([]string{"--plan", "draft a plan"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if !options.plan {
t.Fatal("expected --plan to set options.plan")
}
}
func TestParseExecArgsRejectsPlanWithUseSpec(t *testing.T) {
_, _, err := parseExecArgs([]string{"--plan", "--use-spec", "draft a plan"})
if err == nil || !strings.Contains(err.Error(), "not both") {
t.Fatalf("expected --plan/--use-spec validation, got %v", err)
}
}
func TestParseExecArgsRejectsPlanWithSkipPermissionsUnsafe(t *testing.T) {
_, _, err := parseExecArgs([]string{"--plan", "--skip-permissions-unsafe", "draft a plan"})
if err == nil || !strings.Contains(err.Error(), "not both") {
t.Fatalf("expected --plan/--skip-permissions-unsafe validation, got %v", err)
}
}
// TestParseExecArgsRejectsPlanWithWorktree guards against worktree
// preparation (a filesystem mutation) running ahead of the plan mode gate:
// options.worktree is processed in runExec before the plan permission mode is
// assigned, so the combination must be rejected during option validation,
// before any worktree prep can occur.
func TestParseExecArgsRejectsPlanWithWorktree(t *testing.T) {
_, _, err := parseExecArgs([]string{"--plan", "--worktree", "draft a plan"})
if err == nil || !strings.Contains(err.Error(), "--worktree") {
t.Fatalf("expected --plan/--worktree validation, got %v", err)
}
}
func TestParseExecArgsRejectsPlanWithNonPlanPermissionMode(t *testing.T) {
_, _, err := parseExecArgs([]string{"--plan", "--permission-mode=ask", "draft a plan"})
if err == nil || !strings.Contains(err.Error(), "--permission-mode") {
t.Fatalf("expected --plan/--permission-mode validation, got %v", err)
}
options, _, err := parseExecArgs([]string{"--plan", "--permission-mode=plan", "draft a plan"})
if err != nil {
t.Fatalf("expected --plan with --permission-mode=plan to succeed, got %v", err)
}
if !options.plan || options.permissionMode != "plan" {
t.Fatalf("expected options.plan=true and permissionMode=plan, got plan=%v mode=%q", options.plan, options.permissionMode)
}
}
// TestParseExecArgsPermissionModePlanSharesCombinationGuards ensures the
// combination rejects that key off --plan also fire for --permission-mode plan,
// which reaches PermissionModePlan without setting options.plan.
func TestParseExecArgsPermissionModePlanSharesCombinationGuards(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"worktree", []string{"--permission-mode", "plan", "--worktree", "draft a plan"}, "--worktree"},
{"use-spec", []string{"--permission-mode=plan", "--use-spec", "draft a plan"}, "not both"},
{"skip-permissions-unsafe", []string{"--permission-mode", "plan", "--skip-permissions-unsafe", "draft a plan"}, "not both"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := parseExecArgs(tc.args)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected --permission-mode plan / %s validation containing %q, got %v", tc.name, tc.want, err)
}
})
}
// Bare --permission-mode plan (no conflicting flags) still parses.
options, _, err := parseExecArgs([]string{"--permission-mode", "plan", "draft a plan"})
if err != nil {
t.Fatalf("expected bare --permission-mode plan to succeed, got %v", err)
}
if options.permissionMode != "plan" || options.plan {
t.Fatalf("expected permissionMode=plan and plan=false, got mode=%q plan=%v", options.permissionMode, options.plan)
}
}
// TestRunExecPlanHidesWriteAndShellToolsFromListing drives the real --plan
// flag through runExec (via --list-tools, so no provider is needed) and
// confirms write_file and bash — advertised under every other mode covered by
// TestRunExecListToolsAppliesModeBeforeListing-style tests — are hidden,
// mirroring the TUI /plan on gating end to end from the CLI entry point.
func TestRunExecPlanHidesWriteAndShellToolsFromListing(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--plan", "--list-tools"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
listing := stdout.String()
if !strings.Contains(listing, "Tools visible to model") {
t.Fatalf("expected --plan --list-tools to list tools, got %q", listing)
}
if !strings.Contains(listing, "read_file") {
t.Fatalf("expected plan mode to still list read_file, got %q", listing)
}
for _, hidden := range []string{"write_file", "edit_file", "apply_patch", "bash"} {
if strings.Contains(listing, hidden) {
t.Fatalf("expected --plan to hide %q from the tool listing, got %q", hidden, listing)
}
}
}
// TestRunExecPermissionModePlanHidesWriteAndShellToolsFromListing covers the
// --permission-mode plan entry path, which does not set options.plan and must
// still hide write and shell tools the same way --plan does.
func TestRunExecPermissionModePlanHidesWriteAndShellToolsFromListing(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--permission-mode", "plan", "--list-tools"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
listing := stdout.String()
if !strings.Contains(listing, "Tools visible to model") {
t.Fatalf("expected --permission-mode plan --list-tools to list tools, got %q", listing)
}
if !strings.Contains(listing, "read_file") {
t.Fatalf("expected plan mode to still list read_file, got %q", listing)
}
for _, hidden := range []string{"write_file", "edit_file", "apply_patch", "bash"} {
if strings.Contains(listing, hidden) {
t.Fatalf("expected --permission-mode plan to hide %q from the tool listing, got %q", hidden, listing)
}
}
}
func TestResolveExecPermissionModePlanOverride(t *testing.T) {
options := execOptions{autonomy: "low", plan: true}
mode, err := resolveExecPermissionMode(options)
if err != nil {
t.Fatalf("resolveExecPermissionMode: %v", err)
}
// resolveExecPermissionMode itself only resolves --auto; the --plan override
// is applied by the caller (runExec) afterward, same as --use-spec. This
// pins the precondition: --plan must not interfere with autonomy resolution.
if mode != agent.PermissionModeAuto {
t.Fatalf("resolveExecPermissionMode with --plan = %q, want auto (override applied by the caller)", mode)
}
}
+18
View File
@@ -73,6 +73,24 @@ func toolListContains(names []string, want string) bool {
}
func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error) {
if pm := strings.ToLower(strings.TrimSpace(options.permissionMode)); pm != "" {
switch pm {
case "plan":
return agent.PermissionModePlan, nil
case "spec-draft", "spec_draft":
return agent.PermissionModeSpecDraft, nil
case "auto":
return agent.PermissionModeAuto, nil
case "member", "member-auto", "member_auto":
return agent.PermissionModeMemberAuto, nil
case "ask":
return agent.PermissionModeAsk, nil
case "unsafe", "high":
return agent.PermissionModeUnsafe, nil
default:
return "", execUsageError{fmt.Sprintf("Invalid permission mode %q. Expected plan, spec-draft, auto, member, ask, or unsafe.", options.permissionMode)}
}
}
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
// invalid autonomy value is always rejected. (Previously the unsafe path
// short-circuited before validation, letting "--auto bogus" slip through
+9 -7
View File
@@ -142,13 +142,15 @@ func (writer *execEventWriter) checkpoint(event sessions.Event) {
}
func (writer *execEventWriter) toolResult(result agent.ToolResult) {
modelOutput := result.ModelOutput()
display := result.HumanDisplay()
if writer.format == execOutputJSON {
payload := map[string]any{
"type": "tool_result",
"tool_call_id": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
"output": modelOutput,
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
@@ -162,14 +164,14 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
if len(result.ChangedFiles) > 0 {
payload["changed_files"] = result.ChangedFiles
}
if result.Display.Summary != "" || result.Display.Kind != "" {
payload["display"] = map[string]string{"summary": result.Display.Summary, "kind": result.Display.Kind}
if display.Summary != "" || display.Kind != "" {
payload["display"] = map[string]string{"summary": display.Summary, "kind": display.Kind}
}
writer.writeJSON(payload)
return
}
if writer.format == execOutputStreamJSON {
output, surfaceTruncated := truncateForStreamJSONOutput(result.Output)
output, surfaceTruncated := truncateForStreamJSONOutput(modelOutput)
truncated := result.Truncated || surfaceTruncated
event := streamjson.Event{
Type: streamjson.EventToolResult,
@@ -186,13 +188,13 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
redacted := true
event.Redacted = &redacted
}
if result.Display.Summary != "" || result.Display.Kind != "" {
event.Display = &streamjson.Display{Summary: result.Display.Summary, Kind: result.Display.Kind}
if display.Summary != "" || display.Kind != "" {
event.Display = &streamjson.Display{Summary: display.Summary, Kind: display.Kind}
}
writer.writeStreamJSON(event)
return
}
writer.writeStderr("[result] " + truncateForStatus(result.Output) + "\n")
writer.writeStderr("[result] " + truncateForStatus(modelOutput) + "\n")
}
func (writer *execEventWriter) permission(event agent.PermissionEvent) {
+1 -1
View File
@@ -78,7 +78,7 @@ func TestRunPluginsListsJSONAndText(t *testing.T) {
}
func TestRunHooksListsRedactedJSONAndText(t *testing.T) {
secret := "sk-proj-" + strings.Repeat("a", 24)
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
result := hooks.LoadResult{
Config: hooks.Config{
Enabled: true,
+1 -1
View File
@@ -87,7 +87,7 @@ func TestRunHooksAddRejectsUnknownEvent(t *testing.T) {
func TestRunHooksAddJSONRedactsSecretArgs(t *testing.T) {
cwd := t.TempDir()
secret := "sk-proj-" + strings.Repeat("z", 24)
secret := "sk-proj-" + strings.Repeat("z", 23) + "0"
var stdout, stderr bytes.Buffer
code := runHooksAdd([]string{"h1", "--event", "beforeTool", "--command", "sh", "--arg", "-c", "--arg", "echo " + secret, "--json"}, &stdout, &stderr, hooksManageDeps(cwd))
if code != exitSuccess {
+1 -1
View File
@@ -516,7 +516,7 @@ func TestRunMCPRemovePreservesUnrelatedConfigFields(t *testing.T) {
func TestRunMCPListRedactsURLCredentialsAndSensitiveQueryParams(t *testing.T) {
cwd := t.TempDir()
serverURL := "https://user:[email protected]/mcp?access_token=secret-token&api_key=secret-key&safe=value#access_token=fragment-secret"
commandSecret := "sk-proj-" + strings.Repeat("a", 24)
commandSecret := "sk-proj-" + strings.Repeat("a", 23) + "0"
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
+2 -48
View File
@@ -1,13 +1,8 @@
package cli
import (
"context"
"errors"
"net/http"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
@@ -28,46 +23,5 @@ import (
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
// resolver, and the onboarding presence check never diverge.
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil, ""
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
// No login under any candidate (or unreadable/invalid keys) → API-key
// auth, no resolver.
return nil, ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// Refreshing a token the user logged into (possibly a preset provider like
// xAI) re-resolves that provider's OAuth config, which needs the preset.
AllowPresets: true,
})
if err != nil {
return nil, ""
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
token, rerr = manager.Handle401(ctx, key)
} else {
token, rerr = manager.GetFresh(ctx, key)
}
if errors.Is(rerr, oauth.ErrNoToken) {
// The login was removed since construction → fall back to the API key.
return "", "", false, nil
}
if rerr != nil {
return "", "", false, rerr
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
return providers.OAuthLoginForProfile(profile)
}
+12
View File
@@ -73,6 +73,18 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
if description := strings.TrimSpace(model.Description); description != "" {
entry["description"] = description
}
if len(model.ReasoningEfforts) > 0 {
entry["reasoning_efforts"] = append([]string{}, model.ReasoningEfforts...)
}
if effort := strings.TrimSpace(model.DefaultReasoningEffort); effort != "" {
entry["default_reasoning_effort"] = effort
}
if len(model.ServiceTiers) > 0 {
entry["service_tiers"] = append([]string{}, model.ServiceTiers...)
}
if tier := strings.TrimSpace(model.DefaultServiceTier); tier != "" {
entry["default_service_tier"] = tier
}
items = append(items, entry)
}
payload := map[string]any{
+35 -21
View File
@@ -18,6 +18,8 @@ type sandboxCommandOptions struct {
path string
}
const permissionProfileScopeNote = "permissionProfile is derived from this process's environment and working directory; a command run with its own environment can add further credential deny-read entries."
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
@@ -91,16 +93,25 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
return runSandboxPolicyEffective(options, workspaceRoot, policy, backend, plan, store.FilePath(), writeRoots, writeRootsErr, stdout)
}
if options.json {
// The profile here is built from Zero's own environment and working
// directory, because that is all this command has: no command has been
// chosen yet. A nested command carries its own cwd and env, and the
// credential baseline resolves relative token overrides against those, so
// an executed command can carry MORE denyReadIfExists entries than this
// view lists. Say so in the payload rather than leaving a consumer to
// assume it is the whole picture (see PermissionProfileFromPolicy).
payload := struct {
Policy zeroSandbox.Policy `json:"policy"`
Backend zeroSandbox.Backend `json:"backend"`
Plan zeroSandbox.BackendPlan `json:"plan"`
GrantsPath string `json:"grantsPath"`
Policy zeroSandbox.Policy `json:"policy"`
Backend zeroSandbox.Backend `json:"backend"`
Plan zeroSandbox.BackendPlan `json:"plan"`
GrantsPath string `json:"grantsPath"`
PermissionProfileNote string `json:"permissionProfileNote"`
}{
Policy: policy,
Backend: backend,
Plan: plan,
GrantsPath: store.FilePath(),
Policy: policy,
Backend: backend,
Plan: plan,
GrantsPath: store.FilePath(),
PermissionProfileNote: permissionProfileScopeNote,
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
@@ -247,20 +258,22 @@ func runSandboxPolicyEffective(options sandboxCommandOptions, workspaceRoot stri
// JSON consumers see the same signal as the text write_roots_error
// line: a stale sandbox.additionalWriteRoots entry means the real
// entrypoints would refuse to launch, not run workspace-only.
WriteRootsError string `json:"writeRootsError,omitempty"`
Policy zeroSandbox.Policy `json:"policy"`
Backend zeroSandbox.Backend `json:"backend"`
Plan zeroSandbox.BackendPlan `json:"plan"`
Guards sandboxGuards `json:"guards"`
GrantsPath string `json:"grantsPath"`
WriteRootsError string `json:"writeRootsError,omitempty"`
Policy zeroSandbox.Policy `json:"policy"`
Backend zeroSandbox.Backend `json:"backend"`
Plan zeroSandbox.BackendPlan `json:"plan"`
Guards sandboxGuards `json:"guards"`
GrantsPath string `json:"grantsPath"`
PermissionProfileNote string `json:"permissionProfileNote"`
}{
WorkspaceRoot: workspaceRoot,
WriteRoots: writeRoots,
Policy: policy,
Backend: backend,
Plan: plan,
Guards: guards,
GrantsPath: grantsPath,
WorkspaceRoot: workspaceRoot,
WriteRoots: writeRoots,
Policy: policy,
Backend: backend,
Plan: plan,
Guards: guards,
GrantsPath: grantsPath,
PermissionProfileNote: permissionProfileScopeNote,
}
if writeRootsErr != nil {
payload.WriteRootsError = writeRootsErr.Error()
@@ -284,6 +297,7 @@ func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Polic
"network: " + string(policy.Network),
"enforce_workspace: " + fmt.Sprintf("%t", policy.EnforceWorkspace),
"write_roots: " + strings.Join(writeRoots, ", "),
"permission_profile_note: " + permissionProfileScopeNote,
}
if writeRootsErr != nil {
// Fail soft, visibly: a stale sandbox.additionalWriteRoots entry must
+91 -7
View File
@@ -9,6 +9,8 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"testing"
@@ -483,14 +485,23 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// Point HOME at an empty directory so the default credential-store
// deny-read entries (which depend on what exists in the real home, e.g.
// ~/.aws on the macOS CI image) cannot leak host paths into the golden
// comparison.
// Point all user-config roots at an empty directory so the platform-specific
// credential deny baseline can be asserted without leaking host paths into
// the platform-neutral golden comparison.
emptyHome := t.TempDir()
t.Setenv("HOME", emptyHome)
t.Setenv("USERPROFILE", emptyHome)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(emptyHome, ".config"))
t.Setenv("CLOUDSDK_CONFIG", "")
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
t.Setenv("NPM_CONFIG_USERCONFIG", "")
t.Setenv("npm_config_userconfig", "")
t.Setenv("GH_CONFIG_DIR", "")
t.Setenv("NETRC", "")
t.Setenv("DOCKER_CONFIG", "")
t.Setenv("KUBECONFIG", "")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", "")
t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "")
store := newSandboxTestStore(t)
workspace := t.TempDir()
deps := appDeps{
@@ -518,7 +529,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace)
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace, emptyHome)
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
if err != nil {
t.Fatalf("read golden: %v", err)
@@ -528,7 +539,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
}
}
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte {
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte {
t.Helper()
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
@@ -549,6 +560,56 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
plan, _ := value.(map[string]any)["plan"].(map[string]any)
profile, _ := plan["permissionProfile"].(map[string]any)
fileSystem, _ := profile["fileSystem"].(map[string]any)
wantDenyRead := []string(nil)
credentialHome := emptyHome
if runtime.GOOS != "windows" {
if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil {
credentialHome = resolved
}
wantDenyRead = []string{
filepath.Join(credentialHome, ".aws"),
filepath.Join(credentialHome, ".azure"),
// git's cleartext credential stores, in both the home and XDG
// layouts (#816). Listed here so the exported policy JSON is what
// catches a regression: this baseline is the contract a user reads
// with `zero sandbox policy --json`.
filepath.Join(credentialHome, ".git-credentials"),
filepath.Join(credentialHome, ".config", "git", "credentials"),
filepath.Join(credentialHome, ".npmrc"),
filepath.Join(credentialHome, ".netrc"),
filepath.Join(credentialHome, ".kube", "config"),
filepath.Join(credentialHome, ".docker", "config.json"),
filepath.Join(credentialHome, ".config", "gh", "hosts.yml"),
filepath.Join(credentialHome, ".config", "gcloud"),
filepath.Join(credentialHome, ".config", "zero"),
}
}
gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"])
sort.Strings(gotDenyRead)
sort.Strings(wantDenyRead)
if !reflect.DeepEqual(gotDenyRead, wantDenyRead) {
t.Fatalf("manager credential deny baseline = %#v, want %#v", gotDenyRead, wantDenyRead)
}
wantCarveouts := []string(nil)
wantEnsureDirs := []string(nil)
if runtime.GOOS != "windows" {
zeroDir := filepath.Join(credentialHome, ".config", "zero")
wantCarveouts = []string{
filepath.Join(zeroDir, "plugins"),
filepath.Join(zeroDir, "specialists"),
filepath.Join(zeroDir, "commands"),
}
wantEnsureDirs = []string{zeroDir}
}
if gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"]); !reflect.DeepEqual(gotCarveouts, wantCarveouts) {
t.Fatalf("manager credential carveouts = %#v, want %#v", gotCarveouts, wantCarveouts)
}
if gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"]); !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) {
t.Fatalf("manager credential ensure dirs = %#v, want %#v", gotEnsureDirs, wantEnsureDirs)
}
delete(fileSystem, "denyReadIfExists")
delete(fileSystem, "denyReadCarveouts")
delete(fileSystem, "ensureDenyReadDirs")
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
normalized, err := json.MarshalIndent(value, "", " ")
@@ -558,6 +619,22 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
return append(normalized, '\n')
}
func jsonStringSlice(value any) []string {
values, ok := value.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
return nil
}
out = append(out, text)
}
return out
}
func filterJSONStringRoots(value any, excluded map[string]struct{}) any {
roots, ok := value.([]any)
if !ok {
@@ -720,7 +797,8 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
Network bool `json:"network"`
Workspace bool `json:"workspace"`
} `json:"guards"`
GrantsPath string `json:"grantsPath"`
GrantsPath string `json:"grantsPath"`
PermissionProfileNote string `json:"permissionProfileNote"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
@@ -737,6 +815,9 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
if payload.Plan.SupportLevel != string(sandbox.BackendSupportUnavailable) || payload.GrantsPath == "" {
t.Fatalf("unexpected effective plan/grants: %#v %q", payload.Plan, payload.GrantsPath)
}
if payload.PermissionProfileNote != permissionProfileScopeNote {
t.Fatalf("permissionProfileNote = %q, want %q", payload.PermissionProfileNote, permissionProfileScopeNote)
}
})
}
@@ -748,6 +829,9 @@ func TestEffectiveSandboxPolicyListsWriteRoots(t *testing.T) {
if !strings.Contains(output, "enforce_workspace: true\nwrite_roots: /ws, /extra") {
t.Fatalf("write_roots should directly follow enforce_workspace, got:\n%s", output)
}
if !strings.Contains(output, "permission_profile_note: "+permissionProfileScopeNote) {
t.Fatalf("effective text missing permission profile scope warning:\n%s", output)
}
if strings.Contains(output, "write_roots_error") {
t.Fatalf("unexpected write_roots_error line without an error:\n%s", output)
}
+10 -1
View File
@@ -286,7 +286,11 @@ func runSpecialistCreate(paths specialist.Paths, name string, options specialist
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if options.json {
return writeSpecialistCreateResult(manifest, options.json, stdout, stderr)
}
func writeSpecialistCreateResult(manifest specialist.Manifest, jsonOutput bool, stdout io.Writer, stderr io.Writer) int {
if jsonOutput {
if err := writePrettyJSON(stdout, manifest); err != nil {
return exitCrash
}
@@ -295,6 +299,11 @@ func runSpecialistCreate(paths specialist.Paths, name string, options specialist
if _, err := fmt.Fprintf(stdout, "Created specialist %s at %s\n", manifest.Metadata.Name, manifest.FilePath); err != nil {
return exitCrash
}
for _, warning := range manifest.Warnings {
if _, err := fmt.Fprintf(stderr, "warning: %s\n", warning); err != nil {
return exitCrash
}
}
return exitSuccess
}
+37
View File
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/specialist"
)
func TestRunSpecialistListShowAndPath(t *testing.T) {
@@ -187,6 +188,42 @@ func TestRunSpecialistCreateDeleteAndEdit(t *testing.T) {
}
}
func TestWriteSpecialistCreateResultSurfacesWarningsOnlyForHumanOutput(t *testing.T) {
manifest := specialist.Manifest{
Metadata: specialist.Metadata{Name: "triage"},
FilePath: "/specialists/triage.md",
Warnings: []string{"replacement backup /specialists/.triage.bak retained: access denied"},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := writeSpecialistCreateResult(manifest, false, &stdout, &stderr); code != exitSuccess {
t.Fatalf("human output exit code = %d", code)
}
if got, want := stdout.String(), "Created specialist triage at /specialists/triage.md\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
if got, want := stderr.String(), "warning: replacement backup /specialists/.triage.bak retained: access denied\n"; got != want {
t.Fatalf("stderr = %q, want %q", got, want)
}
stdout.Reset()
stderr.Reset()
if code := writeSpecialistCreateResult(manifest, true, &stdout, &stderr); code != exitSuccess {
t.Fatalf("JSON output exit code = %d", code)
}
var payload specialist.Manifest
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if len(payload.Warnings) != 1 || payload.Warnings[0] != manifest.Warnings[0] {
t.Fatalf("JSON warnings = %#v", payload.Warnings)
}
if stderr.Len() != 0 {
t.Fatalf("JSON warning duplicated to stderr: %q", stderr.String())
}
}
func TestRunSpecialistEditRejectsSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows")
@@ -100,5 +100,6 @@
"shell commands are not wrapped by a native platform sandbox"
]
},
"grantsPath": "$GRANTS"
"grantsPath": "$GRANTS",
"permissionProfileNote": "permissionProfile is derived from this process's environment and working directory; a command run with its own environment can add further credential deny-read entries."
}
+4 -2
View File
@@ -44,7 +44,8 @@ func runTrust(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
func trustCurrentDir(stdout io.Writer, stderr io.Writer, deps appDeps) int {
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
// Flatten with %s: ErrorMessage stringifies inline, so %w would be a no-op.
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %s", err), redaction.Options{}), exitCrash)
}
if err := workspacetrust.Trust(cwd); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
@@ -86,7 +87,8 @@ func trustRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
case 0:
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
// Flatten with %s: ErrorMessage stringifies inline, so %w would be a no-op.
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %s", err), redaction.Options{}), exitCrash)
}
target = cwd
case 1:
+2 -2
View File
@@ -202,8 +202,8 @@ Flags:
--check Check the latest GitHub release without installing
--apply Download, verify, and install the latest release
--json Print the update result as JSON
--repo <owner/repo> Repository to check when no endpoint is provided
--endpoint <url|repo> Release API URL or owner/repo slug to check
--repo <owner/repo> Repository to use when no endpoint is provided (--check and --apply; ignored for the install source by an npm-managed apply)
--endpoint <url|repo> Release API URL or owner/repo slug to use (--check and --apply; ignored for the install source by an npm-managed apply)
--timeout <duration> Release check timeout (default 5s)
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
File diff suppressed because it is too large. Load diff
+635 -9
View File
@@ -2,8 +2,12 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -63,6 +67,9 @@ func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDep
}
return exitSuccess
}
if command == "release" {
return runWorktreesRelease(args, stdout, stderr, deps)
}
if command != "prepare" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees command %q", command))
}
@@ -102,6 +109,85 @@ func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return exitSuccess
}
// runWorktreesRelease unlocks a worktree `zero worktrees prepare` created, so
// Zero's automatic Clean pass can reclaim it once it goes stale. Prepare locks
// every worktree it creates and nothing else in that command's own lifetime
// unlocks it (its process exits right after printing the path, long before
// whatever external caller actually finishes using the worktree), so that
// caller is responsible for running this once it is done.
func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
var path string
var cwdFlag string
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
if err := writeWorktreesHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
case arg == "-C" || arg == "--cwd":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
cwdFlag = value
index = next
case strings.HasPrefix(arg, "--cwd="):
cwdFlag = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
case strings.HasPrefix(arg, "-"):
return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees release flag %q", arg))
default:
if path != "" {
return writeExecUsageError(stderr, "worktree path was provided more than once")
}
path = arg
}
}
path = strings.TrimSpace(path)
if path == "" {
return writeExecUsageError(stderr, "worktrees release requires a worktree path")
}
// git worktree unlock matches against the path git recorded when the
// worktree was created, so a relative argument here (resolved against
// whatever directory the caller happens to be running in, not
// necessarily the worktree's own) can fail to match. Resolve to absolute
// up front so the unlock target is unambiguous regardless of cwd.
absPath, err := filepath.Abs(path)
if err != nil {
return writeExecUsageError(stderr, redactCLIString(err.Error()))
}
// Release falls back to Options.Cwd as git's working directory when the
// worktree directory itself was deleted by hand instead of released; with
// no Cwd wired in, that recovery path runs `git worktree unlock` from
// wherever the caller happens to be, which outside the source repository
// leaves the orphaned lock behind forever (Clean skips locked entries).
// -C/--cwd names the source repository explicitly for exactly that case,
// since the deleted worktree path itself carries no way back to the repo;
// without the flag, resolve the launch directory best-effort (the
// ordinary existing-path case does not need it, so a resolution failure
// is not an error here).
releaseOptions := worktrees.Options{}
if cwdFlag != "" {
workspaceRoot, err := resolveWorkspaceRoot(cwdFlag, deps)
if err != nil {
return writeExecUsageError(stderr, redactCLIString(err.Error()))
}
releaseOptions.Cwd = workspaceRoot
} else if workspaceRoot, rootErr := resolveWorkspaceRoot("", deps); rootErr == nil {
releaseOptions.Cwd = workspaceRoot
}
if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil {
return writeExecUsageError(stderr, redactCLIString(err.Error()))
}
// Print the absolute path that was released so relative arguments still
// show an unambiguous target in the confirmation line.
if _, err := fmt.Fprintf(stdout, "released %s\n", redactCLIString(absPath)); err != nil {
return exitCrash
}
return exitSuccess
}
func runVerifyCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseVerifyCommandArgs(args)
if err != nil {
@@ -519,18 +605,20 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
if command != "commit" && options.message != "" {
if command != "commit" && options.hasMessage {
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
}
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
}
if command == "commit" && options.hasMessage && options.auto {
return options, false, execUsageError{"cannot specify both --message and --auto"}
}
if command != "commit" && command != "push" && options.dryRun {
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
}
// --auto on push/pr is the explicit opt-in for LLM branch naming (see
// ensureFeatureBranch); on commit it opts into the LLM commit message.
if command != "commit" && command != "push" && command != "pr" && options.auto {
return options, false, execUsageError{"--auto is only valid with commit, push, or pr"}
}
if command != "inspect" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
}
@@ -786,15 +874,28 @@ func formatCommitResult(result zerogit.CommitResult) string {
func writeWorktreesHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero worktrees prepare [flags] [name]
zero worktrees release [flags] <path>
Prepares an isolated git worktree for a Zero task.
Flags:
prepare locks the worktree it creates so Zero's automatic cleanup never
removes it out from under you. release unlocks a worktree prepare created,
once you are done with it, so cleanup can reclaim it later if it goes stale.
If the worktree directory was deleted by hand, run release with -C pointing
at the source repository so the orphaned lock can still be cleared.
prepare flags:
--name <name> Worktree name; defaults to a timestamped task name
--dir <path> Base directory for Zero worktrees
-C, --cwd <path> Source repository directory
--json Print JSON output
-h, --help Show this help
release flags:
-C, --cwd <path> Source repository directory; needed when the
worktree directory was already deleted and you
are not running inside the source repository
-h, --help Show this help
`)
return err
}
@@ -838,8 +939,7 @@ Flags:
--fill Automatically populate PR title and body from commits
--draft Create PR as a draft
--yes Confirm pushing to a default/protected branch
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
--dry-run Preview commit metadata without mutating git state
-a, --auto Use the LLM: commit generates the message, push/pr name the auto-created branch (sends the diff to the provider)
--json Print JSON output
-h, --help Show this help
`)
@@ -862,11 +962,31 @@ func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appD
return writeExecUsageError(stderr, err.Error())
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
JSONMode: options.json,
AllowDefaultBranch: options.yes,
DryRun: options.dryRun,
AutoNaming: options.auto,
MaxDiffBytes: options.maxDiffBytes,
}, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
// context.Background() is deliberate: the 2-minute ctx above is the
// feature-branch preflight (inspection, remote probes, branch creation).
// The push itself must not be cancelled mid-transfer just because the
// preflight budget elapsed; a large push can legitimately take longer.
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: options.remote,
Remote: firstNonEmptyString(options.remote, remote),
Branch: branch,
Force: options.force,
DryRun: options.dryRun,
RequireNewRemoteBranch: created,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
@@ -914,15 +1034,62 @@ func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return writeExecUsageError(stderr, err.Error())
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
JSONMode: options.json,
AllowDefaultBranch: options.yes,
DryRun: false,
AutoNaming: options.auto,
MaxDiffBytes: options.maxDiffBytes,
}, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
// Resolve the target remote for the unborn-remote check. Explicit --remote wins,
// followed by the remote resolved by ensureFeatureBranch. When --yes is set,
// fall back to the branch upstream or "origin".
targetRemote := strings.TrimSpace(options.remote)
if targetRemote == "" {
targetRemote = remote
}
if targetRemote == "" {
currentBranch := ""
if deps.currentGitBranch != nil {
currentBranch = deps.currentGitBranch(context.Background(), workspaceRoot)
}
if deps.branchUpstreamRemote != nil && currentBranch != "" {
targetRemote = deps.branchUpstreamRemote(context.Background(), workspaceRoot, currentBranch)
}
if targetRemote == "" {
targetRemote = "origin"
}
}
// Probe errors are intentionally ignored so the flow can continue; if the
// probe fails, the subsequent push or PR operation provides fail-closed behavior.
if deps.isUnbornRemote != nil {
if unborn, unbornErr := deps.isUnbornRemote(context.Background(), workspaceRoot, targetRemote); unbornErr == nil && unborn {
return writeExecUsageError(stderr, fmt.Sprintf("cannot create pull request on unborn remote %s: push the initial default branch first", targetRemote))
}
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
return exitCrash
}
}
// context.Background() is deliberate here too: runChangesPR reuses the
// 2-minute ctx for preflight (branch creation and remote probes), but the
// push must not be cancelled mid-transfer if that budget elapsed.
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: options.remote,
Remote: firstNonEmptyString(options.remote, remote),
Branch: branch,
Force: options.force,
RequireNewRemoteBranch: created,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
@@ -1004,3 +1171,462 @@ func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provide
}
return msg, nil
}
type featureBranchOptions struct {
JSONMode bool
AllowDefaultBranch bool
DryRun bool
AutoNaming bool
MaxDiffBytes int
}
// branchPushPlan holds the resolved parameters for a push or PR workflow
// before any branches are created or refs mutated. Separating plan resolution
// from mutation allows tests to assert the resolution matrix directly without
// touching a git repository.
type branchPushPlan struct {
ShouldBranch bool
CurrentBranch string
PushRemote string
RequireNewRemoteBranch bool
TrackingRemote string
TrackingBranch string
RestoreTip string
PublishabilityBase string
Summary zerogit.ChangeSummary
Slug string
}
// resolveBranchPushPlan resolves the branch, remotes, restore tip, and naming
// slug for changes push / pr workflows. It performs all checks (clean working
// tree, unborn remote detection, ahead count, restore tip resolution) but
// executes no mutation (no branch creation, ref resets, or deletions).
func resolveBranchPushPlan(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (branchPushPlan, error) {
if opts.AllowDefaultBranch || opts.DryRun {
return branchPushPlan{
ShouldBranch: false,
PushRemote: strings.TrimSpace(requestedRemote),
RequireNewRemoteBranch: false,
}, nil
}
if deps.isDefaultBranch == nil {
return branchPushPlan{}, fmt.Errorf("isDefaultBranch dependency missing")
}
isDefault, currentBranch, remote, err := deps.isDefaultBranch(ctx, zerogit.DefaultBranchOptions{Cwd: workspaceRoot, Remote: requestedRemote})
if err != nil {
return branchPushPlan{}, err
}
if !isDefault {
// Whether this push needs the nonexistence lease is decided by one
// live check against the destination remote, not by inference from
// local git state (upstream config, a generated-branch marker):
// every local signal that stood in for that check turned out to be
// wrong in some ordering (inherited config from checkout -b, a
// config write that failed after a successful push, a push aimed at
// a different remote than the one local config describes). A branch
// missing on the remote gets the nonexistence lease, protecting a
// concurrent creator of the same name; a branch already there gets a
// plain push, and git's own fast-forward check is the safety net —
// if the remote history isn't ours, the push fails with a clear
// non-fast-forward error instead of an attempted silent recovery.
if deps.remoteHasBranch == nil {
return branchPushPlan{
ShouldBranch: false,
CurrentBranch: currentBranch,
PushRemote: remote,
RequireNewRemoteBranch: true,
}, nil
}
targetRemote := firstNonEmptyString(requestedRemote, remote)
exists, existsErr := deps.remoteHasBranch(ctx, workspaceRoot, targetRemote, currentBranch)
if existsErr != nil {
return branchPushPlan{}, fmt.Errorf("cannot check whether %s already exists on remote %s: %w", currentBranch, targetRemote, existsErr)
}
return branchPushPlan{
ShouldBranch: false,
CurrentBranch: currentBranch,
PushRemote: remote,
RequireNewRemoteBranch: !exists,
}, nil
}
// CreateBranch and Push publish commits only. A dirty working tree would
// leave uncommitted edits behind under a branch/PR that does not include
// them, so refuse until the tree is clean (commit or stash first).
workingTree, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot})
if err != nil {
return branchPushPlan{}, fmt.Errorf("failed to inspect working tree: %w", err)
}
if !workingTree.Clean {
return branchPushPlan{}, fmt.Errorf("working tree has uncommitted changes; commit or stash them before pushing from the default branch")
}
// Capture the source default branch's own upstream before refreshing or
// leaving it. --remote selects the push destination; restore must not
// rewrite local main to upstream/main when main still tracks origin/main
// (fork setups), and refreshing must update the exact tracking ref that
// will supply both the ahead count and the restore target.
trackingRemote := remote
trackingBranch := currentBranch
if deps.branchUpstreamRemoteAndMerge != nil {
if r, m := deps.branchUpstreamRemoteAndMerge(ctx, workspaceRoot, currentBranch); r != "" && m != "" {
trackingRemote = r
trackingBranch = m
}
}
// Refresh the local remote-tracking ref before trusting it: IsDefaultBranch
// already contacted the remote for its symref check, but the tracking ref
// commitsAhead reads from is only a local cache (written at clone or the
// last fetch) and can sit behind the remote's live tip. Left stale, a real
// publishable range could look like zero commits ahead, and the diff
// derived below (which reuses this same ref as its base) would be stale
// too. A failure here (offline, or a genuinely unborn remote with no such
// ref to fetch yet) is folded into the same fail-closed path as an
// unresolvable ahead count below.
var fetchErr error
if deps.refreshTrackingRef != nil {
fetchErr = deps.refreshTrackingRef(ctx, workspaceRoot, trackingRemote, trackingBranch)
}
// The ahead check gates on the source upstream (trackingRemote), but the
// feature branch is pushed to `pushRemote`, which can be a different
// destination (--remote on a fork). Refuse to auto-branch onto an unborn
// destination: without an initial default branch the new feature branch
// becomes the destination's first ref and its HEAD is left pointing at it.
// This is checked once, here, against the push destination, before resolving
// commit tips or creating any branches.
pushRemote := firstNonEmptyString(requestedRemote, remote)
if deps.isUnbornRemote != nil {
if unbornDest, unbornDestErr := deps.isUnbornRemote(ctx, workspaceRoot, pushRemote); unbornDestErr == nil && unbornDest {
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", pushRemote)
}
}
// Branching off the default branch only makes sense when HEAD carries a
// commit that is not already on the remote default branch. A clean,
// up-to-date default branch would otherwise publish a feature branch at the
// exact default tip. If the ahead count cannot be determined (for example
// the remote-tracking ref was never fetched), fail rather than guess. When
// the remote is confirmed unborn, refuse auto-branching entirely: the
// initial default branch must be established first (changes push --yes)
// so the remote has a real HEAD before any feature branch is published.
ahead, aheadErr := deps.commitsAhead(ctx, workspaceRoot, trackingRemote, trackingBranch)
if fetchErr != nil || aheadErr != nil {
var unbornRemote bool
var unbornErr error
if deps.isUnbornRemote != nil {
unbornRemote, unbornErr = deps.isUnbornRemote(ctx, workspaceRoot, trackingRemote)
}
if unbornErr == nil && unbornRemote {
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", trackingRemote)
}
cause := aheadErr
if cause == nil {
cause = fetchErr
}
return branchPushPlan{}, fmt.Errorf("cannot determine whether HEAD is ahead of %s/%s: %w; fetch the remote tracking branch first", trackingRemote, trackingBranch, cause)
}
if ahead == 0 {
return branchPushPlan{}, fmt.Errorf("no changes to publish: HEAD is not ahead of %s/%s; commit your work before pushing", trackingRemote, trackingBranch)
}
// Resolve the restore tip to a concrete commit before creating any branch.
// It must never be a bare local branch name: resolving "main" yields the
// very commit the feature branch is about to own, so restore becomes a
// silent no-op and the publishable commits stay on the default branch. A
// named remote resolves through its just-refreshed remote-tracking ref; a
// direct URL or path asks the remote itself. Fail closed rather than
// substitute a local branch name.
restoreTip := ""
if deps.resolveRemoteBranchTip != nil {
tip, tipErr := deps.resolveRemoteBranchTip(ctx, workspaceRoot, trackingRemote, trackingBranch)
if tipErr != nil {
return branchPushPlan{}, fmt.Errorf("cannot resolve restore tip for %s/%s: %w", trackingRemote, trackingBranch, tipErr)
}
restoreTip = tip
}
if restoreTip == "" {
restoreTip = trackingRemote + "/" + trackingBranch
}
// Name the branch (and, with --auto, send the provider) from what HEAD is
// actually ahead of the resolved remote branch by, using the same ref
// commitsAhead just checked. A working-tree snapshot can describe edits a
// commit-only push will never include.
baseRef := restoreTip
summary, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot, BaseRef: baseRef, MaxDiffBytes: opts.MaxDiffBytes})
if err != nil {
return branchPushPlan{}, fmt.Errorf("failed to inspect changes: %w", err)
}
slug := fallbackBranchSlug(summary)
if len(summary.Files) == 0 {
// An empty commit (or one whose only content the diff omits) leaves
// nothing to derive a slug from; name the branch from the commit
// subject instead.
if subject := deps.headCommitSubject(ctx, workspaceRoot); subject != "" {
slug = zerogit.SlugifyBranchComponent(subject)
}
}
if opts.AutoNaming && strings.TrimSpace(summary.Diff) != "" {
llmSlug := ""
if resolved, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil && config.HasProviderProfile(resolved.Provider) {
if provider, provErr := deps.newProvider(resolved.Provider); provErr == nil {
if !opts.JSONMode {
fmt.Fprintln(stdout, "Generating branch name using LLM...")
}
genCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
generated, genErr := generateAutoBranchSlug(genCtx, provider, resolved.Provider.Model, redactChangeSummary(summary))
cancel()
if genErr == nil && generated != "" {
llmSlug = generated
}
}
}
if llmSlug != "" {
slug = llmSlug
} else if !opts.JSONMode {
// Cover resolveConfig failures, missing provider profiles,
// newProvider errors, generation errors, and empty results so the
// user is not left guessing after "Generating..." or silence.
fmt.Fprintln(stdout, "LLM branch naming unavailable; using deterministic name.")
}
}
return branchPushPlan{
ShouldBranch: true,
CurrentBranch: currentBranch,
PushRemote: pushRemote,
RequireNewRemoteBranch: true,
TrackingRemote: trackingRemote,
TrackingBranch: trackingBranch,
RestoreTip: restoreTip,
PublishabilityBase: baseRef,
Summary: summary,
Slug: slug,
}, nil
}
// ensureFeatureBranch is the branch-naming step `zero changes push`/`pr` run
// before pushing: pushing straight to the default branch is refused deeper in
// zerogit.Push, so rather than surface that as a dead end, create and switch
// to a conventionally named "<user>/<slug>" branch first. It returns the
// branch push/pr should target, or "" to mean "current HEAD branch, unchanged"
// (zerogit.Push already treats an empty Branch that way), plus the remote the
// preflight resolved (requestedRemote, then the original branch's configured
// upstream, then "origin"), plus whether Push should require that branch not
// already exist on that remote. Callers must pass the remote to Push: a
// freshly created branch has no tracking configuration, so Push's own
// fallback would silently retarget "origin" even when the work came from a
// branch tracking a different remote. Callers must also pass that third value
// through as Push's RequireNewRemoteBranch: CreateBranch's own
// remote-collision probe runs before this returns, and closing that race
// requires Push's push itself to assert the destination is still new. That
// requirement also carries across a retry: if the current branch is already
// non-default (this call didn't create it) but has not been successfully
// published to the exact target remote under the same branch name, keep the
// nonexistence lease. branch.<name>.remote alone is not enough: with
// branch.autoSetupMerge=inherit, checkout -b copies remote=origin from the
// source branch before any push. After a successful create, the original
// default branch ref is moved back to its own upstream tip (not necessarily
// the push destination) so the feature branch exclusively owns the
// publishable commits; a failed restore rolls the feature branch back.
// allowDefaultBranch (the --yes flag) and dryRun both opt out via the ""
// branch / false return, leaving Push's own guard/preview behavior on the
// default branch unaffected.
//
// autoNaming gates the LLM naming path (--auto): these commands were
// git-only, and sending the change diff to a configured provider on every
// default-branch push would silently export source code nobody asked to
// share. Without the opt-in the name comes from deterministic local
// information only. maxDiffBytes caps the committed-range diff Inspect
// returns, so a user who passed --diff-bytes to bound the proprietary source
// sent for LLM naming has that cap honored here just as the commit path does.
// The working tree must be clean and HEAD must be ahead of the resolved
// remote branch; both are verified before any branch is created. The
// inspectChanges, commitsAhead, headCommitSubject, currentGitUser, and
// createBranch dependencies are required on the default-branch path and are
// called without nil guards; fillAppDeps populates all of them. The
// refreshTrackingRef, resolveRemoteBranchTip, isUnbornRemote, resetBranchRef,
// and deleteBranch dependencies are optional and are skipped when nil.
func ensureFeatureBranch(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (string, string, bool, error) {
plan, err := resolveBranchPushPlan(ctx, stdout, workspaceRoot, requestedRemote, opts, deps)
if err != nil {
return "", "", false, err
}
if !plan.ShouldBranch {
return plan.CurrentBranch, plan.PushRemote, plan.RequireNewRemoteBranch, nil
}
expectedRestoreTip := ""
if deps.currentBranchTip != nil {
expectedRestoreTip = deps.currentBranchTip(ctx, workspaceRoot)
}
name := zerogit.BuildBranchName(deps.currentGitUser(ctx, workspaceRoot), plan.Slug)
result, err := deps.createBranch(ctx, zerogit.BranchOptions{Cwd: workspaceRoot, Name: name, Remote: plan.PushRemote})
if err != nil {
return "", "", false, fmt.Errorf("failed to create branch: %w", err)
}
// The normal flow commits on the default branch first, then creates the
// feature branch at the same HEAD. Without moving the original default
// ref back, local main keeps the pre-squash commits and diverges from
// its upstream after a squash-merge (and a later pull/push can re-publish
// them). Once the feature branch owns those commits, restore the default
// branch to the source branch's own upstream tip captured above.
// Failure-safe: if the restore fails, delete the feature branch and
// check the default branch back out so the tree is not left half-moved.
if deps.resetBranchRef != nil {
if err := deps.resetBranchRef(ctx, workspaceRoot, plan.CurrentBranch, plan.RestoreTip, expectedRestoreTip); err != nil {
// A compare-and-swap conflict means the default branch moved
// while we were branching, so restoreTip is no longer the tip we
// meant to restore. The rollback below deletes the branch that
// now exclusively owns the user's commits, which would destroy
// work to recover from a race we can simply report. Keep the
// branch and let the user reconcile the default branch.
if errors.Is(err, zerogit.ErrCompareAndSwapConflict) {
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; generated branch %s was preserved because the default branch changed concurrently", plan.CurrentBranch, plan.RestoreTip, err, result.Branch)
}
var rollbackErr error
if deps.deleteBranch != nil {
rollbackErr = deps.deleteBranch(ctx, workspaceRoot, plan.CurrentBranch, result.Branch)
}
if rollbackErr != nil {
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; rollback of %s also failed: %v", plan.CurrentBranch, plan.RestoreTip, err, result.Branch, rollbackErr)
}
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w", plan.CurrentBranch, plan.RestoreTip, err)
}
}
if !opts.JSONMode {
fmt.Fprintf(stdout, "Created branch %s (was on %s)\n", result.Branch, plan.CurrentBranch)
}
return result.Branch, plan.PushRemote, true, nil
}
// fallbackBranchSlug derives a deterministic branch-name slug from a change
// summary without calling an LLM, so ensureFeatureBranch still works when no
// provider is configured.
func fallbackBranchSlug(summary zerogit.ChangeSummary) string {
switch len(summary.Files) {
case 0:
return "changes"
case 1:
// Git paths are always slash-separated regardless of platform, so
// path.Base (not filepath.Base) extracts the filename portably.
return zerogit.SlugifyBranchComponent(path.Base(summary.Files[0].Path))
default:
return fmt.Sprintf("update-%d-files", len(summary.Files))
}
}
// generateAutoBranchSlug asks the model for a short kebab-case slug
// describing the diff, mirroring generateAutoCommitMessage's prompt shape.
func generateAutoBranchSlug(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
var promptBuilder strings.Builder
promptBuilder.WriteString("Analyze the following git diff and generate a short git branch name slug for it.\n")
promptBuilder.WriteString("The slug must be 2 to 5 lowercase words separated by hyphens (kebab-case), using only letters, digits, and hyphens, with no prefix like \"feature/\" or \"fix/\" and no surrounding quotes.\n")
promptBuilder.WriteString("Output ONLY the raw slug text, nothing else.\n\n")
promptBuilder.WriteString("Git Diff:\n")
promptBuilder.WriteString(summary.Diff)
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
},
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", fmt.Errorf("%s", collected.Error)
}
slug := zerogit.SlugifyBranchComponent(extractBranchSlug(collected.Text))
if slug == "" {
return "", fmt.Errorf("provider returned empty branch slug")
}
return slug, nil
}
// slugLineRe matches a line that already reads as a kebab-case slug (letters,
// digits, and internal single hyphens only). extractBranchSlug prefers such a
// line so a preamble sentence is never mistaken for the slug.
var slugLineRe = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
func isPreambleText(s string) bool {
lower := strings.ToLower(strings.TrimSpace(s))
if strings.HasSuffix(lower, ":") || strings.HasSuffix(lower, ".") || strings.HasSuffix(lower, "!") || strings.HasSuffix(lower, "?") {
return true
}
preambles := []string{
"here is", "here's", "below is", "below are", "sure", "certainly",
"i suggest", "suggested branch", "branch name", "recommended branch",
"how about", "you could use",
}
for _, p := range preambles {
if strings.HasPrefix(lower, p) {
return true
}
}
return false
}
// extractBranchSlug pulls the intended slug out of a model response that didn't
// follow the "output only the raw slug" instruction exactly. It drops Markdown
// code-fence lines, then prefers a line that already looks like a kebab-case
// slug: that skips a leading preamble such as "Here is a suggested branch
// name:" in favor of the "add-login-page" line that follows it. One-word
// acknowledgements that happen to match the slug regex ("Sure", "Certainly")
// are filtered as preamble before the early return, so "Sure\nadd login page"
// yields the real suggestion. Inline labeled forms ("Branch name: add-login-page")
// still work: the preamble check runs on the extracted value after the label
// is stripped. If no line is strictly kebab-cased, it prefers a plausible
// non-preamble line so plain multi-word replies still slugify correctly.
func extractBranchSlug(text string) string {
var firstLine string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "```") {
continue
}
line = strings.TrimSpace(strings.Trim(line, `"'`))
if line == "" {
continue
}
candidate := line
if idx := strings.Index(line, ":"); idx != -1 {
after := strings.TrimSpace(strings.Trim(line[idx+1:], `"'`))
if after != "" {
candidate = after
}
}
// Models often terminate a suggestion with sentence punctuation;
// strip it so "add login page." is recovered as the slug rather than
// rejected as preamble (isPreambleText treats a trailing '.' as a
// full sentence). True preamble lines still match the prefix list.
candidate = strings.TrimSpace(strings.TrimRight(candidate, ".!?"))
if candidate == "" {
continue
}
// Preamble classification runs before accepting a slug-shaped line so
// "Sure" does not win over a later "add-login-page". The check is on
// candidate (post label-strip) so "Branch name: add-login-page" still
// returns the extracted slug.
if slugLineRe.MatchString(candidate) && !isPreambleText(candidate) {
return candidate
}
if firstLine == "" && !isPreambleText(candidate) {
firstLine = candidate
}
}
return firstLine
}
+2
View File
@@ -197,8 +197,10 @@ func shellCommand(command string) *exec.Cmd {
if strings.HasPrefix(strings.TrimSpace(command), `"`) {
command = "call " + command
}
/* #nosec G204 -- Provider commands are intentionally evaluated by a shell to support features like pipes and env expansions. The command string originates from the user's trusted local config. */
return exec.Command("cmd", "/C", command)
}
/* #nosec G204 -- Provider commands are intentionally evaluated by a shell to support features like pipes and env expansions. The command string originates from the user's trusted local config. */
return exec.Command("sh", "-c", command)
}
+294
View File
@@ -0,0 +1,294 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
)
type jsonObjectSpan struct {
start int
end int
members []jsonMemberSpan
}
type jsonMemberSpan struct {
key string
leadingStart int
keyStart int
valueStart int
valueEnd int
commaAfter int
}
// setPetPreferenceJSON changes only preferences.pet. It keeps the original
// bytes for every unrelated member so choosing a companion does not reorder or
// reformat the user's configuration file.
func setPetPreferenceJSON(data []byte, pet string) ([]byte, error) {
rootStart := skipJSONSpace(data, 0)
root, err := parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
preferencesIndex := lastJSONMember(root.members, "preferences")
if preferencesIndex < 0 {
if pet == "" {
return data, nil
}
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
return insertJSONMember(data, root, "preferences", petJSONObject(encodedPet)), nil
}
preferenceMember := root.members[preferencesIndex]
preferenceValue := bytes.TrimSpace(data[preferenceMember.valueStart:preferenceMember.valueEnd])
if bytes.Equal(preferenceValue, []byte("null")) {
if pet == "" {
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
return data, nil
}
return removeJSONMember(data, root, preferencesIndex), nil
}
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
return replaceJSONRange(data, preferenceMember.valueStart, preferenceMember.valueEnd, petJSONObject(encodedPet)), nil
}
preferences, err := parseJSONObject(data, preferenceMember.valueStart)
if err != nil {
return nil, fmt.Errorf("preferences must be a JSON object: %w", err)
}
petIndex := lastJSONMember(preferences.members, "pet")
if pet != "" {
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
if petIndex >= 0 {
member := preferences.members[petIndex]
return replaceJSONRange(data, member.valueStart, member.valueEnd, encodedPet), nil
}
return insertJSONMember(data, preferences, "pet", encodedPet), nil
}
for petIndex >= 0 {
data = removeJSONMember(data, preferences, petIndex)
root, err = parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
preferencesIndex = lastJSONMember(root.members, "preferences")
if preferencesIndex < 0 {
return data, nil
}
preferenceMember = root.members[preferencesIndex]
preferences, err = parseJSONObject(data, preferenceMember.valueStart)
if err != nil {
return nil, err
}
petIndex = lastJSONMember(preferences.members, "pet")
}
if len(preferences.members) == 0 {
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
return data, nil
}
return removeJSONMember(data, root, preferencesIndex), nil
}
return data, nil
}
func parseJSONObject(data []byte, start int) (jsonObjectSpan, error) {
if start < 0 || start >= len(data) || data[start] != '{' {
return jsonObjectSpan{}, fmt.Errorf("expected JSON object")
}
object := jsonObjectSpan{start: start}
leadingStart := start + 1
for {
i := skipJSONSpace(data, leadingStart)
if i >= len(data) {
return jsonObjectSpan{}, fmt.Errorf("unterminated JSON object")
}
if data[i] == '}' {
object.end = i
return object, nil
}
if data[i] != '"' {
return jsonObjectSpan{}, fmt.Errorf("expected JSON object key")
}
keyStart := i
keyEnd, err := scanJSONString(data, i)
if err != nil {
return jsonObjectSpan{}, err
}
var key string
if err := json.Unmarshal(data[keyStart:keyEnd], &key); err != nil {
return jsonObjectSpan{}, err
}
i = skipJSONSpace(data, keyEnd)
if i >= len(data) || data[i] != ':' {
return jsonObjectSpan{}, fmt.Errorf("expected colon after JSON object key")
}
valueStart := skipJSONSpace(data, i+1)
valueEnd, err := scanJSONValue(data, valueStart)
if err != nil {
return jsonObjectSpan{}, err
}
i = skipJSONSpace(data, valueEnd)
member := jsonMemberSpan{
key: key,
leadingStart: leadingStart,
keyStart: keyStart,
valueStart: valueStart,
valueEnd: valueEnd,
commaAfter: -1,
}
if i < len(data) && data[i] == ',' {
member.commaAfter = i
object.members = append(object.members, member)
leadingStart = i + 1
continue
}
if i >= len(data) || data[i] != '}' {
return jsonObjectSpan{}, fmt.Errorf("expected comma or end of JSON object")
}
object.members = append(object.members, member)
object.end = i
return object, nil
}
}
func scanJSONString(data []byte, start int) (int, error) {
for i := start + 1; i < len(data); i++ {
switch data[i] {
case '\\':
i++
case '"':
return i + 1, nil
}
}
return 0, fmt.Errorf("unterminated JSON string")
}
func scanJSONValue(data []byte, start int) (int, error) {
if start >= len(data) {
return 0, fmt.Errorf("missing JSON value")
}
if data[start] == '"' {
return scanJSONString(data, start)
}
if data[start] == '{' || data[start] == '[' {
stack := []byte{data[start]}
for i := start + 1; i < len(data); i++ {
if data[i] == '"' {
end, err := scanJSONString(data, i)
if err != nil {
return 0, err
}
i = end - 1
continue
}
switch data[i] {
case '{', '[':
stack = append(stack, data[i])
case '}', ']':
open := stack[len(stack)-1]
if (open == '{' && data[i] != '}') || (open == '[' && data[i] != ']') {
return 0, fmt.Errorf("mismatched JSON delimiter")
}
stack = stack[:len(stack)-1]
if len(stack) == 0 {
return i + 1, nil
}
}
}
return 0, fmt.Errorf("unterminated JSON value")
}
for i := start; i < len(data); i++ {
switch data[i] {
case ',', '}', ']', ' ', '\t', '\r', '\n':
if i == start {
return 0, fmt.Errorf("missing JSON value")
}
return i, nil
}
}
return len(data), nil
}
func skipJSONSpace(data []byte, start int) int {
for start < len(data) {
switch data[start] {
case ' ', '\t', '\r', '\n':
start++
default:
return start
}
}
return start
}
func lastJSONMember(members []jsonMemberSpan, key string) int {
for i := len(members) - 1; i >= 0; i-- {
if members[i].key == key {
return i
}
}
return -1
}
func petJSONObject(encodedPet []byte) []byte {
result := append([]byte(`{"pet":`), encodedPet...)
return append(result, '}')
}
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
result = append(result, data[:start]...)
result = append(result, replacement...)
return append(result, data[end:]...)
}
func removeJSONMember(data []byte, object jsonObjectSpan, index int) []byte {
member := object.members[index]
start, end := member.leadingStart, member.valueEnd
if member.commaAfter >= 0 {
end = member.commaAfter + 1
} else if index > 0 {
start = object.members[index-1].commaAfter
}
return replaceJSONRange(data, start, end, nil)
}
func insertJSONMember(data []byte, object jsonObjectSpan, key string, value []byte) []byte {
encodedKey, _ := json.Marshal(key)
entry := append(append(encodedKey, ':', ' '), value...)
if len(object.members) == 0 {
interior := data[object.start+1 : object.end]
if newline := bytes.LastIndexByte(interior, '\n'); newline >= 0 {
closingIndent := interior[newline+1:]
indentUnit := []byte(" ")
if bytes.Contains(closingIndent, []byte("\t")) {
indentUnit = []byte("\t")
}
lineBreak := []byte("\n")
if newline > 0 && interior[newline-1] == '\r' {
lineBreak = []byte("\r\n")
}
insert := append(append(append(lineBreak, closingIndent...), indentUnit...), entry...)
return replaceJSONRange(data, object.start+1, object.start+1, insert)
}
return replaceJSONRange(data, object.end, object.end, entry)
}
last := object.members[len(object.members)-1]
separator := []byte(", ")
if bytes.Contains(data[last.valueEnd:object.end], []byte("\n")) {
lineStart := bytes.LastIndexByte(data[:last.keyStart], '\n') + 1
indent := data[lineStart:last.keyStart]
separator = append([]byte(",\n"), indent...)
}
insert := append(separator, entry...)
return replaceJSONRange(data, last.valueEnd, last.valueEnd, insert)
}
+252
View File
@@ -0,0 +1,252 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSetPetPreservesUnrelatedConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := FileConfig{
ActiveProvider: "test",
Providers: []ProviderProfile{{Name: "test", Model: "example"}},
Preferences: PreferencesConfig{Theme: "dracula"},
}
data, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
updated, err := SetPet(path, "boba")
if err != nil {
t.Fatal(err)
}
if updated.Preferences.Pet != "boba" || updated.Preferences.Theme != "dracula" || updated.ActiveProvider != "test" || len(updated.Providers) != 1 {
t.Fatalf("SetPet lost config fields: %#v", updated)
}
reloadedData, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var reloaded FileConfig
if err := json.Unmarshal(reloadedData, &reloaded); err != nil {
t.Fatal(err)
}
if reloaded.Preferences.Pet != "boba" || reloaded.Preferences.Theme != "dracula" || reloaded.ActiveProvider != "test" || len(reloaded.Providers) != 1 {
t.Fatalf("persisted config lost fields: %#v", reloaded)
}
}
func TestSetPetRejectsInvalidInputs(t *testing.T) {
if _, err := SetPet(" ", "boba"); err == nil {
t.Fatal("SetPet accepted a blank config path")
}
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"preferences":`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err == nil || !strings.Contains(err.Error(), "invalid config JSON") {
t.Fatalf("SetPet malformed JSON error = %v", err)
}
}
func TestSetPetCreatesNestedConfigAndClearsEmptyPreferences(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "zero", "config.json")
if _, err := SetPet(path, " boba "); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
var preferences struct {
Pet string `json:"pet"`
}
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
t.Fatal(err)
}
if preferences.Pet != "boba" {
t.Fatalf("persisted pet = %q, want %q", preferences.Pet, "boba")
}
root["future"] = json.RawMessage(`{"enabled":true}`)
encoded, err := json.Marshal(root)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, " "); err != nil {
t.Fatal(err)
}
data, err = os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
root = nil
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["preferences"]; ok {
t.Fatalf("empty preferences were retained: %s", data)
}
var future struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(root["future"], &future); err != nil {
t.Fatal(err)
}
if !future.Enabled {
t.Fatalf("unrelated future member was lost: %s", data)
}
}
func TestSetPetPreservesUnknownJSONMembers(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := []byte(`{
"activeProvider": "test",
"futureTopLevel": {"enabled": true},
"preferences": {
"theme": "dracula",
"futurePreference": {"mode": "extended"}
}
}`)
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
var futureTopLevel struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(root["futureTopLevel"], &futureTopLevel); err != nil {
t.Fatal(err)
}
if !futureTopLevel.Enabled {
t.Fatalf("unknown top-level member = %s", root["futureTopLevel"])
}
var preferences map[string]json.RawMessage
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
t.Fatal(err)
}
var futurePreference struct {
Mode string `json:"mode"`
}
if err := json.Unmarshal(preferences["futurePreference"], &futurePreference); err != nil {
t.Fatal(err)
}
if futurePreference.Mode != "extended" {
t.Fatalf("unknown preference member = %s", preferences["futurePreference"])
}
if got := string(preferences["pet"]); got != `"boba"` {
t.Fatalf("pet preference = %s, want boba", got)
}
}
func TestSetPetPreservesExistingJSONLayoutAndMemberOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := []byte("{\n\t\"zFuture\": {\"enabled\":true},\n\t\"preferences\": {\n\t\t\"theme\" : \"dracula\",\n\t\t\"pet\" : \"old\",\n\t\t\"futurePreference\": {\"mode\":\"extended\"}\n\t},\n\t\"activeProvider\": \"test\"\n}\n")
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(string(original), `"old"`, `"boba"`, 1)
if string(got) != want {
t.Fatalf("SetPet reformatted or reordered config:\n--- got ---\n%s--- want ---\n%s", got, want)
}
}
func TestSetPetMinimallyEditsExistingJSON(t *testing.T) {
tests := []struct {
name string
original string
pet string
want string
}{
{
name: "add pet to existing preferences",
original: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\"\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
},
{
name: "add preferences without reordering root",
original: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\",\n \"preferences\": {\"pet\":\"boba\"}\n}\n",
},
{
name: "remove pet but retain preferences",
original: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
pet: "",
want: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
},
{
name: "remove empty preferences member",
original: "{\n \"zFuture\": true,\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "",
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
},
{
name: "do not expose an older duplicate preference",
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{\"pet\":\"dog\"}}\n",
pet: "",
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{}}\n",
},
{
name: "do not expose an older duplicate behind null",
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
pet: "",
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
},
{
name: "preserve multiline empty preferences layout",
original: "{\n \"preferences\": {\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(test.original), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, test.pet); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != test.want {
t.Fatalf("SetPet output:\n--- got ---\n%s--- want ---\n%s", got, test.want)
}
})
}
}
+47 -13
View File
@@ -103,6 +103,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
// here rather than in mergeConfig because that helper is shared with the
// trusted global-config merge, which must keep honouring the setting.
commandConfig.Sandbox.Enabled = nil
commandConfig.CrossSessionInbound = ""
mergeConfig(&cfg, commandConfig)
}
@@ -140,6 +141,13 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
}
}
if inbound := strings.TrimSpace(cfg.CrossSessionInbound); inbound != "" {
switch inbound {
case "accept", "hold", "refuse":
default:
return ResolvedConfig{}, fmt.Errorf("invalid crossSessionInbound %q: expected accept, hold, or refuse", inbound)
}
}
if err := validateSTTConfig(cfg.STT); err != nil {
return ResolvedConfig{}, err
}
@@ -154,19 +162,20 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
}
return ResolvedConfig{
ActiveProvider: active.Name,
Providers: providers,
Provider: active,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
STT: cfg.STT,
ActiveProvider: active.Name,
Providers: providers,
Provider: active,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
STT: cfg.STT,
CrossSessionInbound: cfg.CrossSessionInbound,
}, nil
}
@@ -264,9 +273,15 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
if strings.TrimSpace(src.Preferences.Pet) != "" {
dst.Preferences.Pet = strings.TrimSpace(src.Preferences.Pet)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
mergeSTTConfig(&dst.STT, src.STT)
if inbound := strings.TrimSpace(src.CrossSessionInbound); inbound != "" {
dst.CrossSessionInbound = inbound
}
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -325,6 +340,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
if inbound := strings.TrimSpace(src.CrossSessionInbound); inboundPolicyRank(inbound) > inboundPolicyRank(dst.CrossSessionInbound) {
dst.CrossSessionInbound = inbound
}
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
// Local control is intentionally user-config/override only. A cloned project
// must not be able to make browser, desktop, or terminal automation tools
@@ -727,6 +745,9 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
mergeSTTConfig(&cfg.STT, overrides.STT)
if inbound := strings.TrimSpace(overrides.CrossSessionInbound); inbound != "" {
cfg.CrossSessionInbound = inbound
}
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -736,6 +757,19 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
mergeMCPConfig(&cfg.MCP, overrides.MCP, true)
}
func inboundPolicyRank(policy string) int {
switch strings.TrimSpace(policy) {
case "refuse":
return 3
case "hold":
return 2
case "accept":
return 0
default:
return 1 // permission-mode parity
}
}
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
if src.enabledSet {
dst.Enabled = src.Enabled
+53
View File
@@ -168,6 +168,22 @@ func TestResolveLoadsRecentModelsFromUserConfigOnly(t *testing.T) {
}
}
func TestResolveLoadsPetFromUserConfigOnly(t *testing.T) {
userPath := writeConfig(t, `{"preferences":{"pet":" boba "}}`)
projectPath := writeConfig(t, `{"preferences":{"pet":"project-pet"}}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{},
})
if err != nil {
t.Fatal(err)
}
if resolved.Preferences.Pet != "boba" {
t.Fatalf("Preferences.Pet = %q, want user preference boba", resolved.Preferences.Pet)
}
}
// recentModels must never be merged from project config (same posture as
// favoriteModels): a project setting it with no corresponding user setting
// should leave the resolved value empty, not pick up the project's list.
@@ -2199,3 +2215,40 @@ func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
t.Fatalf("error leaked API key: %q", err.Error())
}
}
func TestCrossSessionInboundProjectConfigCanOnlyTighten(t *testing.T) {
tests := []struct {
name string
user string
project string
want string
}{
{name: "hold tightens accept", user: "accept", project: "hold", want: "hold"},
{name: "refuse stays refuse", user: "refuse", project: "accept", want: "refuse"},
{name: "accept cannot weaken parity", project: "accept", want: ""},
{name: "refuse tightens parity", project: "refuse", want: "refuse"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := FileConfig{CrossSessionInbound: test.user}
if err := mergeProjectConfig(&cfg, FileConfig{CrossSessionInbound: test.project}); err != nil {
t.Fatal(err)
}
if cfg.CrossSessionInbound != test.want {
t.Fatalf("crossSessionInbound = %q, want %q", cfg.CrossSessionInbound, test.want)
}
})
}
}
func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "openai",
"providers": [{"name":"openai","provider_kind":"openai","apiKey":"sk-x","model":"gpt-5"}],
"crossSessionInbound": "sometimes"
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err == nil || !strings.Contains(err.Error(), "expected accept, hold, or refuse") {
t.Fatalf("error = %v", err)
}
}
+82 -72
View File
@@ -112,6 +112,9 @@ type PreferencesConfig struct {
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Pet is the terminal companion selected through /pets. Empty leaves pets
// off until the user chooses one; "disabled" is the explicit off state.
Pet string `json:"pet,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
@@ -340,46 +343,49 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
}
type FileConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
}
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
type rawConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
STT *STTConfig `json:"stt,omitempty"`
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
STT *STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
}
raw := rawConfig{
ActiveProvider: cfg.ActiveProvider,
Providers: cfg.Providers,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
ActiveProvider: cfg.ActiveProvider,
Providers: cfg.Providers,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
CrossSessionInbound: cfg.CrossSessionInbound,
}
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
@@ -404,33 +410,35 @@ type ResolveOptions struct {
}
type Overrides struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
CrossSessionInbound string
}
type ResolvedConfig struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
CrossSessionInbound string
}
type MCPConfig struct {
@@ -480,20 +488,21 @@ type MCPOAuthConfig struct {
func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
type rawConfig struct {
ActiveProvider string `json:"activeProvider"`
Providers []ProviderProfile `json:"providers"`
MaxTurns int `json:"maxTurns"`
MCP MCPConfig `json:"mcp"`
Sandbox SandboxConfig `json:"sandbox"`
Notify NotifyConfig `json:"notify"`
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
STT STTConfig `json:"stt"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
ActiveProvider string `json:"activeProvider"`
Providers []ProviderProfile `json:"providers"`
MaxTurns int `json:"maxTurns"`
MCP MCPConfig `json:"mcp"`
Sandbox SandboxConfig `json:"sandbox"`
Notify NotifyConfig `json:"notify"`
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
STT STTConfig `json:"stt"`
CrossSessionInbound string `json:"crossSessionInbound"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
var raw rawConfig
@@ -520,6 +529,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.KeyBindings = raw.KeyBindings
cfg.LocalControl = raw.LocalControl
cfg.STT = raw.STT
cfg.CrossSessionInbound = raw.CrossSessionInbound
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
}
+39 -4
View File
@@ -591,6 +591,35 @@ func SetTheme(path string, theme string) (FileConfig, error) {
return cfg, nil
}
// SetPet persists only the terminal-pet preference while preserving every
// unrelated user setting through the config writer's atomic replace path.
func SetPet(path string, pet string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
data := []byte("{}")
if existing, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(existing, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
data = existing
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
pet = strings.TrimSpace(pet)
cfg.Preferences.Pet = pet
data, err := setPetPreferenceJSON(data, pet)
if err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
if err := writeConfigData(path, data); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
// SetSTTModel persists the dictation model and its provider, mirroring
// SetTheme (read-modify-atomic-write). provider must be one of the known STT
// provider kinds; a local provider stores the model as stt.localModelPath,
@@ -736,17 +765,23 @@ func NormalizeRecentModels(entries []RecentModelEntry) []RecentModelEntry {
}
func writeConfigFile(path string, cfg FileConfig) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config JSON: %w", err)
}
return writeConfigData(path, data)
}
func writeConfigData(path string, data []byte) error {
dir := filepath.Dir(path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory %s: %w", dir, err)
}
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config JSON: %w", err)
if len(data) == 0 || data[len(data)-1] != '\n' {
data = append(data, '\n')
}
data = append(data, '\n')
// Write-to-temp + rename: an in-place write interrupted mid-way (crash,
// disk full) would leave the user's only config truncated or corrupt.
tmp, err := os.CreateTemp(dir, ".zero-config-*.tmp")
+246
View File
@@ -0,0 +1,246 @@
package credstore
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
)
func fileStore(t *testing.T, dir string) *Store {
t.Helper()
store, err := New(Options{Dir: dir, Storage: "file"})
if err != nil {
t.Fatalf("New: %v", err)
}
return store
}
// THE REPRODUCTION, kept as the regression. Before the lock this reliably kept
// 1 of 100: every writer read the same map, added its own provider, and the
// last rename published a file missing all the others.
func TestConcurrentSetKeepsEveryKey(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
const n = 100
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if err := store.Set(fmt.Sprintf("p%03d", i), fmt.Sprintf("k%03d", i)); err != nil {
errs <- err
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("Set: %v", err)
}
got, err := store.Providers()
if err != nil {
t.Fatalf("Providers: %v", err)
}
if len(got) != n {
t.Fatalf("kept %d of %d concurrent writes", len(got), n)
}
// ...and every value is the one its own writer wrote, not a neighbour's.
for i := 0; i < n; i++ {
key, ok, err := store.Get(fmt.Sprintf("p%03d", i))
if err != nil {
t.Fatalf("Get: %v", err)
}
if !ok || key != fmt.Sprintf("k%03d", i) {
t.Fatalf("p%03d = %q (present=%v); a writer's own value must survive", i, key, ok)
}
}
}
// DELETE RACES SET, and an unlocked delete loses the other writer's key.
//
// A first version of this only checked that keys nobody deleted survived — and
// every reader sees those, so it passed with the lock removed from Delete. The
// case that actually breaks is a Delete whose stale read predates a concurrent
// Set: it writes back a map that never contained the new key, and the Set is
// gone. So the assertion is on the SETS surviving, not on the bystanders.
func TestADeleteCannotClobberAConcurrentSet(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
// Throwaway keys for the deleters to remove, written first so a delete
// always has something to do.
const churn = 40
for i := 0; i < churn; i++ {
if err := store.Set(fmt.Sprintf("churn%02d", i), "v"); err != nil {
t.Fatal(err)
}
}
const adds = 60
var wg sync.WaitGroup
for i := 0; i < adds; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = store.Set(fmt.Sprintf("added%02d", i), "v")
}(i)
}
for i := 0; i < churn; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, _ = store.Delete(fmt.Sprintf("churn%02d", i))
}(i)
}
wg.Wait()
got, err := store.Providers()
if err != nil {
t.Fatal(err)
}
survivors := map[string]bool{}
for _, name := range got {
survivors[name] = true
}
missing := []string{}
for i := 0; i < adds; i++ {
name := fmt.Sprintf("added%02d", i)
if !survivors[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("%d of %d concurrent Set calls were clobbered by a racing Delete: %v",
len(missing), adds, missing)
}
// ...and every churn key really was removed, so the deletes were not
// no-ops that would make the check above vacuous.
for i := 0; i < churn; i++ {
if survivors[fmt.Sprintf("churn%02d", i)] {
t.Fatalf("churn%02d survived; the deletes did nothing and this test proves nothing", i)
}
}
}
// ACROSS PROCESSES, which is the case that matters: a plan running with
// max_workers > 1 spawns child PROCESSES, and an in-process mutex would not
// have helped any of them. The race detector cannot see this one either — it is
// two OS processes — so it is driven for real.
func TestConcurrentSetAcrossProcesses(t *testing.T) {
if testing.Short() {
t.Skip("spawns processes")
}
helper := os.Getenv("ZERO_CREDSTORE_HELPER_DIR")
if helper != "" {
// Child mode: write our slice of the keys and exit.
store, err := New(Options{Dir: helper, Storage: "file"})
if err != nil {
os.Exit(3)
}
prefix := os.Getenv("ZERO_CREDSTORE_HELPER_PREFIX")
for i := 0; i < 25; i++ {
if err := store.Set(fmt.Sprintf("%s%02d", prefix, i), "v"); err != nil {
os.Exit(4)
}
}
os.Exit(0)
}
dir := t.TempDir()
const children = 4
var wg sync.WaitGroup
failures := make(chan string, children)
for c := 0; c < children; c++ {
wg.Add(1)
go func(c int) {
defer wg.Done()
cmd := exec.Command(os.Args[0], "-test.run=TestConcurrentSetAcrossProcesses", "-test.v")
cmd.Env = append(os.Environ(),
"ZERO_CREDSTORE_HELPER_DIR="+dir,
fmt.Sprintf("ZERO_CREDSTORE_HELPER_PREFIX=c%d_", c),
)
if out, err := cmd.CombinedOutput(); err != nil {
failures <- fmt.Sprintf("child %d: %v\n%s", c, err, out)
}
}(c)
}
wg.Wait()
close(failures)
for failure := range failures {
t.Fatal(failure)
}
store := fileStore(t, dir)
got, err := store.Providers()
if err != nil {
t.Fatal(err)
}
if len(got) != children*25 {
t.Fatalf("kept %d of %d keys written by %d concurrent processes", len(got), children*25, children)
}
}
// The lock lives BESIDE the data file, never on it. write publishes by rename,
// so a lock taken on the data file would be attached to an inode the next
// writer has already replaced — every writer would appear to hold it.
func TestTheLockIsNotTheDataFile(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
if store.lockPath() == store.file {
t.Fatal("the lock is the data file; rename would carry it away")
}
if !strings.HasPrefix(filepath.Base(store.lockPath()), filepath.Base(store.file)) {
t.Fatalf("lock %q is not beside the data file %q", store.lockPath(), store.file)
}
if err := store.Set("p", "k"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.lockPath()); err != nil {
t.Fatalf("the lock file was not created: %v", err)
}
// ...and it survives a write, since the rename replaces only the data file.
if err := store.Set("q", "k"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.lockPath()); err != nil {
t.Fatalf("the lock file did not survive a write: %v", err)
}
}
// The lock is a security boundary, so the path where it CANNOT be taken needs
// coverage too: an operation that could not lock must report that, never proceed
// unserialized and never report success. Replacing the credential directory with
// a regular file makes the lock's MkdirAll fail on every platform.
func TestOperationsFailWhenTheLockCannotBeAcquired(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "creds")
store := fileStore(t, dir)
// Where the store expects its directory, put a regular file.
if err := os.WriteFile(dir, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
if err := store.Set("openai", "sk-test"); err == nil {
t.Error("Set reported success although the lock could not be acquired")
}
if _, err := store.Delete("openai"); err == nil {
t.Error("Delete reported success although the lock could not be acquired")
}
if _, _, err := store.Get("openai"); err == nil {
t.Error("Get reported success although the lock could not be acquired")
}
if _, err := store.Providers(); err == nil {
t.Error("Providers reported success although the lock could not be acquired")
}
// And nothing may have been written through the failed path.
if raw, err := os.ReadFile(dir); err != nil || string(raw) != "not a directory" {
t.Errorf("the blocking file was modified: %q (err %v)", raw, err)
}
}
+49 -5
View File
@@ -9,6 +9,7 @@ package credstore
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -125,14 +126,27 @@ func (s *Store) Backend() string { return s.backend }
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
// Set stores (or overwrites) the API key for provider.
func (s *Store) Set(provider, key string) error {
func (s *Store) Set(provider, key string) (err error) {
provider = normalizeProvider(provider)
if provider == "" {
return fmt.Errorf("credstore: provider is required")
}
if s.backend == "keyring" {
// The OS keyring serializes its own writes; the file lock guards the
// file backends only.
return s.kr.Set(keyringService, keyringPrefix+provider, key)
}
// READ AND WRITE UNDER ONE LOCK. Separately locking each half would still
// lose keys: two writers would each read the same map, each add their own
// provider, and the second rename would publish a file missing the first.
// Measured before this existed: 1 of 100 concurrent Set calls survived.
release, err := s.acquireFileLock(true)
if err != nil {
return err
}
// Joined, not chosen between: a release failure annotates the result instead
// of masking the write error that actually explains what went wrong.
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return err
@@ -142,7 +156,7 @@ func (s *Store) Set(provider, key string) error {
}
// Get returns the API key for provider and whether one is stored.
func (s *Store) Get(provider string) (string, bool, error) {
func (s *Store) Get(provider string) (value string, found bool, err error) {
provider = normalizeProvider(provider)
if provider == "" {
return "", false, nil
@@ -150,6 +164,15 @@ func (s *Store) Get(provider string) (string, bool, error) {
if s.backend == "keyring" {
return s.kr.Get(keyringService, keyringPrefix+provider)
}
// A shared lock, so a read serializes against a writer's publish. On unix
// the rename is atomic and this only prevents reading an empty file mid-swap;
// on Windows an unsynchronized reader holding the file open would block the
// writer's rename outright, so readers must coordinate through the same lock.
release, err := s.acquireFileLock(false)
if err != nil {
return "", false, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return "", false, err
@@ -159,7 +182,7 @@ func (s *Store) Get(provider string) (string, bool, error) {
}
// Delete removes the API key for provider, reporting whether one existed.
func (s *Store) Delete(provider string) (bool, error) {
func (s *Store) Delete(provider string) (existed bool, err error) {
provider = normalizeProvider(provider)
if provider == "" {
return false, nil
@@ -167,6 +190,13 @@ func (s *Store) Delete(provider string) (bool, error) {
if s.backend == "keyring" {
return s.kr.Delete(keyringService, keyringPrefix+provider)
}
// The same lock as Set: a delete is a read-modify-write too, and one racing
// a set would otherwise resurrect or erase an unrelated provider.
release, err := s.acquireFileLock(true)
if err != nil {
return false, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return false, err
@@ -179,17 +209,22 @@ func (s *Store) Delete(provider string) (bool, error) {
}
// Providers lists providers with a stored key (sorted), for display/audit.
func (s *Store) Providers() ([]string, error) {
func (s *Store) Providers() (names []string, err error) {
if s.backend == "keyring" {
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
// that need a list pass known provider names to Get. Return empty here.
return nil, nil
}
release, err := s.acquireFileLock(false)
if err != nil {
return nil, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return nil, err
}
names := make([]string, 0, len(data))
names = make([]string, 0, len(data))
for name := range data {
names = append(names, name)
}
@@ -264,6 +299,15 @@ func (s *Store) write(data map[string]string) error {
return nil
}
// lockPath is the advisory lock guarding a read-modify-write of the credential
// file. Beside the data file, never the data file itself — write publishes by
// rename and would carry the lock away with the old inode.
func (s *Store) lockPath() string { return s.file + ".lock" }
// filepathDir is filepath.Dir, named locally so the two platform lock files can
// share it without either importing path/filepath for one call.
func filepathDir(path string) string { return filepath.Dir(path) }
func normalizeProvider(provider string) string {
return strings.ToLower(strings.TrimSpace(provider))
}
+55
View File
@@ -0,0 +1,55 @@
//go:build !windows
package credstore
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/unix"
)
// acquireFileLock takes an exclusive advisory lock (flock) so a read-modify-write
// of the credential file is serialized against every other one — across
// processes AND across goroutines, since flock is held per open file
// description and two opens in one process contend exactly as two processes do.
//
// THE LOCK FILE IS SEPARATE FROM THE DATA FILE, and that is not tidiness. write
// publishes by os.Rename, which replaces the inode; a lock taken on the data
// file would be attached to an inode that the next writer has already replaced,
// so every writer would appear to hold it. The lock lives on a file nothing
// renames.
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
path := s.lockPath()
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
return nil, fmt.Errorf("credstore: lock dir: %w", err)
}
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("credstore: open lock: %w", err)
}
// Writers take LOCK_EX; readers take LOCK_SH so they run concurrently with
// each other but still serialize against a writer's publish (see Get).
how := unix.LOCK_SH
if exclusive {
how = unix.LOCK_EX
}
if err := unix.Flock(int(file.Fd()), how); err != nil {
_ = file.Close()
return nil, fmt.Errorf("credstore: lock: %w", err)
}
return func() error {
// Close alone drops the flock, so the explicit unlock is belt-and-braces —
// but its failure is reported rather than swallowed, because a cleanup that
// did not complete must not be indistinguishable from one that did. The
// callers join this into their result, so it annotates a successful write
// instead of masking it.
unlockErr := unix.Flock(int(file.Fd()), unix.LOCK_UN)
closeErr := file.Close()
if err := errors.Join(unlockErr, closeErr); err != nil {
return fmt.Errorf("credstore: release lock: %w", err)
}
return nil
}, nil
}
+55
View File
@@ -0,0 +1,55 @@
//go:build windows
package credstore
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/windows"
)
// acquireFileLock takes an exclusive OS lock (LockFileEx) so a read-modify-write
// of the credential file is serialized, matching the flock behaviour on unix.
//
// The lock file is SEPARATE from the data file for the same reason: write
// publishes by rename, and a lock on the renamed file would be attached to
// something the next writer has already replaced.
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
path := s.lockPath()
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
return nil, fmt.Errorf("credstore: lock dir: %w", err)
}
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("credstore: open lock: %w", err)
}
handle := windows.Handle(file.Fd())
overlapped := new(windows.Overlapped)
// A fixed 1-byte region, blocking (no LOCKFILE_FAIL_IMMEDIATELY) so a
// waiter queues rather than failing the operation. Writers pass
// LOCKFILE_EXCLUSIVE_LOCK; readers omit it (a shared lock) so they still
// serialize against a writer's publish — which matters more on Windows,
// where an unsynchronized reader holding the file open blocks the rename.
flags := uint32(0)
if exclusive {
flags = windows.LOCKFILE_EXCLUSIVE_LOCK
}
if err := windows.LockFileEx(handle, flags, 0, 1, 0, overlapped); err != nil {
_ = file.Close()
return nil, fmt.Errorf("credstore: lock: %w", err)
}
return func() error {
// Reported rather than swallowed, for the same reason as the unix side: a
// cleanup that did not complete must not look identical to one that did.
// It matters more here — on Windows the handle staying open is what blocks
// the next writer's rename, so a failed release has a visible consequence.
unlockErr := windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
closeErr := file.Close()
if err := errors.Join(unlockErr, closeErr); err != nil {
return fmt.Errorf("credstore: release lock: %w", err)
}
return nil
}, nil
}
+39 -8
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/coder/websocket"
)
@@ -60,7 +61,15 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
})
if err != nil {
return "", fmt.Errorf("connecting to Deepgram: %w", err)
// Preserve the context.Canceled sentinel (it carries no key) so an
// Esc-abort during dial still matches the UI's errors.Is check. Key
// on ctx.Err() itself rather than unwrapping err: a cancellation can
// surface as a plain transport error (e.g. "closed network
// connection") rather than context.Canceled directly.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
}
defer conn.CloseNow()
@@ -68,15 +77,26 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
// so no conversion (unlike sherpa-onnx, which wants float32).
writeErrCh := make(chan error, 1)
go func() {
for chunk := range chunks {
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
writeErrCh <- err
// Select on ctx.Done() while waiting for the next chunk so a cancelled
// session does not leave this goroutine blocked on an open idle channel.
for {
select {
case <-ctx.Done():
writeErrCh <- ctx.Err()
return
case chunk, ok := <-chunks:
if !ok {
// CloseStream flushes any buffered audio and returns final
// results before Deepgram closes the socket.
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
return
}
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
writeErrCh <- err
return
}
}
}
// CloseStream flushes any buffered audio and returns final results before
// Deepgram closes the socket.
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
}()
// Deepgram results are per-utterance segments, not cumulative: accumulate the
@@ -100,7 +120,18 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
}
default:
}
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
// A user abort cancels the streaming context; the UI matches it
// with errors.Is(err, context.Canceled), so return the sentinel
// itself (it carries no key) instead of a flat redacted string.
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
// swap above can replace err with a plain transport error (e.g.
// "closed network connection") that doesn't itself unwrap to
// context.Canceled even though the cancellation is what caused it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
//nolint:staticcheck // Preserve established user-facing error text.
return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
}
if typ != websocket.MessageText {
continue
@@ -0,0 +1,278 @@
package dictation
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/coder/websocket"
)
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
// equal to the API key so the dial error embeds the key and redaction must strip it.
func TestDeepgramDialFailureRedactsKey(t *testing.T) {
const key = "sk-test-dial-key-abcdef"
baseURL := dialFailServerWithUpgrade(t, key)
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: key, BaseURL: baseURL})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected dial failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from dial failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "connecting to Deepgram") {
t.Errorf("expected dial-path error prefix, got: %v", ferr)
}
}
// dialFailServerWithUpgrade returns a ws URL whose handshake responds 101 with
// Upgrade set to upgradeValue (not "websocket"), so websocket.Dial fails with
// that value in the error string. Hijacks and closes immediately to avoid the
// library's 3s body-drain timeout on failed dials.
func dialFailServerWithUpgrade(t *testing.T, upgradeValue string) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijack not supported", http.StatusInternalServerError)
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
_, _ = bufrw.WriteString("Connection: Upgrade\r\n")
_, _ = bufrw.WriteString("Upgrade: " + upgradeValue + "\r\n\r\n")
_ = bufrw.Flush()
}))
t.Cleanup(srv.Close)
return "ws" + strings.TrimPrefix(srv.URL, "http")
}
func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Drain the client's audio frames, then wait for CloseStream so the
// client has flushed its writes before we reject the connection. Closing
// immediately (before CloseStream) risks the client failing on a write
// error and never observing this API-key-bearing close reason.
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-test-key") {
t.Errorf("API key leaked: %v", ferr)
}
}
func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) {
firstFrame := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open, never answering, so the client blocks in
// Read until its context is cancelled (the Esc-abort path).
var once sync.Once
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
once.Do(func() { close(firstFrame) })
}
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 320)
// The channel stays open: the session is live when the user aborts.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-firstFrame:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can cancel while the WebSocket dial is still pending. The dial error is
// redacted with %s, so we must return ctx.Err() rather than a flat string.
func TestDeepgramStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
tr, err := NewDeepgramTranscriber(DeepgramConfig{
APIKey: "sk-test-key",
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
chunks := make(chan []byte)
defer close(chunks)
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
}
}
// Esc right after accept can race the first write or the first Read; both
// setup/write redaction sites must still yield context.Canceled.
func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
accepted := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
close(accepted)
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
// No frames yet: the writer blocks on chunks or the reader blocks on Read.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-accepted:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
}
}
// TestDeepgramStreamErrorRedactsHeaderShapedKey exercises the same stream-error
// redaction path as TestDeepgramStreamTranscribeErrorRedaction, with a close
// reason that embeds the key next to an X-Api-Key-shaped header label.
func TestDeepgramStreamErrorRedactsHeaderShapedKey(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-custom-secret-key-1234567890", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-custom-secret-key-1234567890") {
t.Errorf("Custom header API key leaked: %v", ferr)
}
}
func TestDeepgramSinglePassRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-key1234567890abcdef1234", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
got := ferr.Error()
if strings.Contains(got, "sk-key1234567890abcdef1234") {
t.Fatalf("API key leaked into stream error: %q", got)
}
// One key occurrence must produce exactly one redaction marker (no double wrap).
if strings.Count(got, "[REDACTED]") != 1 {
t.Fatalf("redaction markers in %q: want exactly 1 [REDACTED]", got)
}
}
@@ -8,6 +8,7 @@ import (
"net/http"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/coder/websocket"
)
@@ -22,6 +23,10 @@ type OpenAIRealtimeConfig struct {
Model string // default "gpt-4o-transcribe"
// BaseURL overrides the wss endpoint (tests point it at a fake server).
BaseURL string
// writeErrInjector, when set, is consulted before each JSON websocket write.
// Used by same-package tests to force controllable write failures that embed
// secrets; production callers leave it nil.
writeErrInjector func() error
}
type openAIRealtimeTranscriber struct {
@@ -59,7 +64,15 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
},
})
if err != nil {
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
// Preserve the context.Canceled sentinel (it carries no key) so an
// Esc-abort during dial still matches the UI's errors.Is check. Key
// on ctx.Err() itself rather than unwrapping err: a cancellation can
// surface as a plain transport error (e.g. "closed network
// connection") rather than context.Canceled directly.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
}
defer conn.CloseNow()
@@ -73,24 +86,53 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
},
},
}
if err := writeJSON(ctx, conn, sessionUpdate); err != nil {
return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err)
if err := o.writeJSON(ctx, conn, sessionUpdate); err != nil {
// Same cancellation short-circuit as the dial above: an Esc-abort
// while the session update is in flight must stay context.Canceled.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
}
writeErrCh := make(chan error, 1)
go func() {
for chunk := range chunks {
appendMsg := map[string]any{
"type": "input_audio_buffer.append",
"audio": base64.StdEncoding.EncodeToString(chunk),
}
if err := writeJSON(ctx, conn, appendMsg); err != nil {
writeErrCh <- err
// Select on ctx.Done() while waiting for the next chunk so a cancelled
// session does not leave this goroutine blocked on an open idle channel.
for {
select {
case <-ctx.Done():
writeErrCh <- ctx.Err()
return
case chunk, ok := <-chunks:
if !ok {
// Commit the buffered audio to force final transcription.
if err := o.writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"}); err != nil {
writeErrCh <- err
// Unblock the reader: without a close, conn.Read stays
// parked on a silent peer and the error is never drained
// from writeErrCh. CloseNow aborts instead of waiting on
// the close handshake, which a silent peer never answers.
conn.CloseNow()
return
}
writeErrCh <- nil
return
}
appendMsg := map[string]any{
"type": "input_audio_buffer.append",
"audio": base64.StdEncoding.EncodeToString(chunk),
}
if err := o.writeJSON(ctx, conn, appendMsg); err != nil {
writeErrCh <- err
// Same unblocking close as the commit path: a server that
// goes silent after session setup would otherwise leave the
// reader blocked in conn.Read with the error undrained.
conn.CloseNow()
return
}
}
}
// Commit the buffered audio to force final transcription of the utterance.
writeErrCh <- writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"})
}()
// OpenAI deltas are incremental (append), unlike Deepgram/sherpa's cumulative
@@ -113,7 +155,17 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
}
default:
}
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
// A user abort cancels the streaming context; the UI matches it
// with errors.Is(err, context.Canceled), so return the sentinel
// itself (it carries no key) instead of a flat redacted string.
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
// swap above can replace err with a plain transport error (e.g.
// "closed network connection") that doesn't itself unwrap to
// context.Canceled even though the cancellation is what caused it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
}
if typ != websocket.MessageText {
continue
@@ -125,6 +177,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
if onPartial != nil {
onPartial(compose(), false)
}
// onPartial may deliver the partial that makes the TUI cancel
// (Esc). Observe that before the next Read, which can already
// have a racing OpenAI error frame buffered.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
case realtimeCompleted:
// A completed item replaces the in-progress delta buffer with the
// server's authoritative transcript for that segment.
@@ -138,6 +196,9 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
if onPartial != nil {
onPartial(compose(), true)
}
if ctx.Err() != nil {
return compose(), ctx.Err()
}
// Once we've committed the buffer (user stopped) and the server has
// returned a completed transcription, the utterance is done.
if committed {
@@ -147,7 +208,15 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
case realtimeCommitted:
committed = true
case realtimeError:
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
// Esc can race an incoming error event: conn.Read may already have
// the OpenAI error in hand by the time cancellation lands. Key on
// ctx.Err() first, same as every other return point in this loop,
// so a cancel that wins the race still surfaces as context.Canceled
// instead of a spurious redacted failure alongside it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey))
}
// The writer signals commit completion out of band; observe it so a
// stop with no further audio still flips `committed`.
@@ -155,6 +224,10 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
case werr := <-writeErrCh:
if werr == nil {
committed = true
} else if ctx.Err() != nil {
return compose(), ctx.Err()
} else {
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey))
}
default:
}
@@ -201,6 +274,15 @@ func parseRealtimeEvent(data []byte) realtimeEvent {
return realtimeEvent{kind: realtimeOther}
}
func (o *openAIRealtimeTranscriber) writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
if o.cfg.writeErrInjector != nil {
if err := o.cfg.writeErrInjector(); err != nil {
return err
}
}
return writeJSON(ctx, conn, v)
}
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
data, err := json.Marshal(v)
if err != nil {
@@ -0,0 +1,437 @@
package dictation
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
)
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
// equal to the API key so the dial error embeds the key and redaction must strip it.
func TestOpenAIRealtimeDialFailureRedactsKey(t *testing.T) {
const key = "sk-test-dial-key-abcdef"
baseURL := dialFailServerWithUpgrade(t, key)
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: key, BaseURL: baseURL})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected dial failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from dial failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "connecting to OpenAI Realtime") {
t.Errorf("expected dial-path error prefix, got: %v", ferr)
}
}
// Session-configuration write fails after dial: inject a write error that embeds
// the API key (peer close cannot reach this path reliably — the session write
// always lands in the kernel buffer first) and assert redaction.
func TestOpenAIRealtimeSessionConfigFailureRedactsKey(t *testing.T) {
const key = "sk-test-session-key-abcdef"
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold open; the client fails on the injected session write before reading.
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected session-config failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("session-config failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from session-config failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "configuring OpenAI Realtime session") {
t.Errorf("expected session-config error prefix, got: %v", ferr)
}
}
// Asynchronous writer failure: session update succeeds, then the audio writer
// injects a key-bearing write error. The server keeps the connection open and
// emits a delta so the reader loop observes writeErrCh (not a peer close).
func TestOpenAIRealtimeWriterFailureRedactsKey(t *testing.T) {
const key = "sk-test-writer-key-abcdef"
sessionDone := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
close(sessionDone)
// Keep reading and emit a delta so the client processes an event and
// drains writeErrCh while the connection is still open.
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
))
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
var writes atomic.Int32
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
// First write is session.update; fail subsequent audio/commit writes.
if writes.Add(1) == 1 {
return nil
}
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
// Leave open after the first frame so the writer hits the injected error
// on that append rather than a clean commit.
done := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
done <- ferr
}()
var ferr error
select {
case ferr = <-done:
case <-time.After(5 * time.Second):
close(chunks)
t.Fatal("StreamTranscribe did not complete after writer failure")
}
close(chunks)
if ferr == nil {
t.Fatal("expected writer failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from writer failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
}
select {
case <-sessionDone:
default:
// Session may race the injected audio write; prefix assertion above is enough.
}
}
// Writer failure with a silent server: the peer never sends a frame after
// session setup, so the reader is parked in conn.Read. The writer must close
// the connection on failure or the writeErrCh error is never observed and
// StreamTranscribe blocks forever.
func TestOpenAIRealtimeWriterFailureUnblocksSilentServer(t *testing.T) {
const key = "sk-test-silent-writer-key-abcdef"
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open without ever writing a frame.
<-ctx.Done()
})
var writes atomic.Int32
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
// First write is session.update; fail the subsequent append write.
if writes.Add(1) == 1 {
return nil
}
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
// Leave the channel open: the writer must fail on the append, not the commit.
done := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {})
done <- ferr
}()
select {
case ferr := <-done:
if ferr == nil {
t.Fatal("expected writer failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from writer failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
}
case <-time.After(5 * time.Second):
close(chunks)
t.Fatal("StreamTranscribe did not unblock after writer failure with a silent server")
}
}
func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
defer c.Close(websocket.StatusNormalClosure, "")
for {
typ, _, err := c.Read(ctx)
if err != nil {
return
}
if typ != websocket.MessageText {
continue
}
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`))
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-test-key") {
t.Errorf("API key leaked: %v", ferr)
}
}
func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) {
firstFrame := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open, never answering, so the client blocks in
// Read until its context is cancelled (the Esc-abort path).
var once sync.Once
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
once.Do(func() { close(firstFrame) })
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 480)
// The channel stays open: the session is live when the user aborts.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-firstFrame:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can cancel while the WebSocket dial is still pending. The dial error is
// redacted with %s, so we must return ctx.Err() rather than a flat string.
func TestOpenAIRealtimeStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: "sk-test-key",
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
chunks := make(chan []byte)
defer close(chunks)
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
}
}
// Esc right after accept can hit the session-update write or the first Read;
// both redaction sites must still yield context.Canceled.
func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
accepted := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
close(accepted)
// Do not read: leave the client in session-update write or first Read.
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-accepted:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
}
}
// Cancel while the writer is blocked on more chunks. Whichever path observes
// the cancellation first (the blocked conn.Read or the writeErrCh drain) must
// return context.Canceled rather than a redacted flat string.
func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) {
sessionReceived := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
close(sessionReceived)
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
defer close(chunks)
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-sessionReceived:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can race an incoming OpenAI error event. conn.Read may already have
// the error frame in hand by the time the cancellation lands. The server
// fires a delta immediately followed by the error, back to back. Cancel from
// inside the delta callback (same path the TUI uses when a partial triggers
// Esc handling) so the cancel is observed before the next event is processed.
// The returned error must still be context.Canceled, not the redacted OpenAI
// error.
func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
))
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`,
))
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 480)
var once sync.Once
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {
// Cancel synchronously from the partial callback, before the
// stream loop continues to the already-buffered error frame.
once.Do(cancel)
})
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("StreamTranscribe error = %v, want context.Canceled (cancel must win over a racing OpenAI error event)", ferr)
}
}
+125 -6
View File
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"syscall"
"time"
)
@@ -38,12 +40,45 @@ func KillProcessTree(pid int) error {
// TerminateProcessTree requests graceful termination, then force-kills the
// process tree after grace. Callers retain their distinct persistence models;
// this function owns only the OS lifecycle primitive.
//
// The signal target is REDISCOVERED here via processSignalTarget(pid), which
// asks the OS for pid's current group. A caller that already knows pid was
// configured as its own group leader at launch (e.g. via ConfigureProcessGroup)
// should use TerminateProcessGroup instead — see its doc comment for why this
// rediscovery is fragile once the leader may have already exited.
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
return terminateTarget(pid, target, grace, poll)
}
// TerminateProcessGroup is like TerminateProcessTree, but for a caller that
// already knows pid is (or was, at launch) its own process-group leader — e.g.
// a command started after ConfigureProcessGroup(cmd), which sets Setpgid so
// cmd.Process.Pid always leads its own group at the moment it is signalled.
//
// TerminateProcessTree instead rediscovers the group via syscall.Getpgid(pid)
// at call time. On Darwin, that lookup can return ESRCH once an unreaped group
// leader has already exited, even though live descendants remain in the group
// it configured — TerminateProcessTree would then silently fall back to
// signalling only the (already dead) leader PID and leave those descendants
// running. Skipping rediscovery in favor of the launch-time invariant avoids
// that: the negative PID is used directly, regardless of whether the leader is
// still alive to be looked up.
func TerminateProcessGroup(pid int, grace, poll time.Duration) error {
if pid <= 1 {
return fmt.Errorf("refusing to signal invalid pid %d", pid)
}
return terminateTarget(pid, -pid, grace, poll)
}
// terminateTarget signals target (an individual PID or, negative, a process
// group) with SIGTERM then, after grace, SIGKILL. reportPid identifies the
// original process in error messages regardless of which form target takes.
func terminateTarget(reportPid, target int, grace, poll time.Duration) error {
alive := func() bool { return signalTargetRunning(target) }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if errors.Is(err, syscall.ESRCH) {
return nil
@@ -77,11 +112,91 @@ func TerminateProcessTree(pid int, grace, poll time.Duration) error {
time.Sleep(poll)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
return fmt.Errorf("process %d did not exit after SIGKILL", reportPid)
}
return nil
}
// signalTargetRunning reports whether the signal target still has a process that
// is actually RUNNING, as opposed to one waiting to be reaped.
//
// kill(2) with signal 0 succeeds for a zombie: the PID still exists until its
// parent collects it. That matters because the target here is usually a process
// GROUP, and a terminated leader's child is briefly a zombie before init/launchd
// reaps it. Treating that window as "still alive" made termination sit through
// both grace periods, SIGKILL an already-dead group, and then report
// "did not exit after SIGKILL" for a tree that had in fact stopped — turning a
// successful cleanup into a spurious failure (and a flaky one, since it depends on
// reap timing).
//
// Zombie detection goes through ps, which is available on every Unix we target and
// avoids a /proc dependency Darwin does not have. It is only consulted when the
// cheap kill check says something is still there, so the common path costs nothing
// extra. If ps cannot be run or its output cannot be parsed, the conservative
// pre-existing answer (the kill result) stands.
func signalTargetRunning(target int) bool {
if syscall.Kill(target, syscall.Signal(0)) != nil {
return false
}
// A negative target is a process GROUP: check whether any member is
// non-zombie. A positive target is an individual PID that is NOT its own
// group leader (processSignalTarget's fallback) — its actual PGID differs
// from its own PID, so matching rows by PGID here would look for a group
// that doesn't exist and always report "unknown" (conservatively alive).
// Check its own PID's state instead.
var running, ok bool
if target < 0 {
running, ok = processGroupHasRunningMember(-target)
} else {
running, ok = processIsRunning(target)
}
if !ok {
return true
}
return running
}
// processGroupHasRunningMember reports whether any member of pgid is in a state
// other than zombie. ok is false when the group's states could not be determined.
func processGroupHasRunningMember(pgid int) (running bool, ok bool) {
return processTableStateMatches(func(_, rowPgid int) bool { return rowPgid == pgid })
}
// processIsRunning reports whether pid itself is in a state other than zombie.
// ok is false when pid's state could not be determined.
func processIsRunning(pid int) (running bool, ok bool) {
return processTableStateMatches(func(rowPid, _ int) bool { return rowPid == pid })
}
// processTableStateMatches scans the process table once, via ps, and reports
// whether any row satisfying match is non-zombie, and whether at least one
// matching row was found at all (ok). No rows found means either the process
// (or group) genuinely doesn't exist (a race with exit) or ps's output was
// unparseable/restricted; either way, the caller should not claim knowledge.
func processTableStateMatches(match func(pid, pgid int) bool) (running bool, ok bool) {
output, err := exec.Command("ps", "-A", "-o", "pid=,pgid=,stat=").Output()
if err != nil {
return false, false
}
found := false
for line := range strings.SplitSeq(string(output), "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
rowPid, errPid := strconv.Atoi(fields[0])
rowPgid, errPgid := strconv.Atoi(fields[1])
if errPid != nil || errPgid != nil || !match(rowPid, rowPgid) {
continue
}
found = true
if !strings.HasPrefix(fields[2], "Z") {
return true, true
}
}
return false, found
}
func processSignalTarget(pid int) (int, error) {
if pid <= 1 {
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
@@ -91,10 +206,14 @@ func processSignalTarget(pid int) (int, error) {
if pgid == pid {
target = -pid
}
} else if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
} else {
if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
}
// Conservatively retain the individual PID after other lookup failures;
// guessing and signalling a process group could affect unrelated processes.
}
return target, nil
}
+133
View File
@@ -0,0 +1,133 @@
//go:build !windows
package execution
import (
"os/exec"
"strconv"
"syscall"
"testing"
"time"
)
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
// zombie, so a group whose only remaining member is waiting to be reaped used to
// sit through both grace periods, take a pointless SIGKILL, and then be reported
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
// depended on when the reaper ran.
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
// Deliberately do NOT Wait: the child stays a zombie in its own group, which
// is exactly the state a terminated tree passes through.
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
}
if signalTargetRunning(-pid) {
t.Fatal("a group holding only a zombie must not count as running")
}
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
}
}
// TestTerminateProcessTreeStopsRunningGroup is the positive control: a group with
// a live member must still be seen as running and then actually stopped, so the
// zombie tolerance above cannot degrade into ignoring real processes.
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
cmd := exec.Command("sh", "-c", "sleep 30")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
defer func() { _ = cmd.Wait() }()
if !signalTargetRunning(-pid) {
t.Fatal("a group with a live member must count as running")
}
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree: %v", err)
}
if running, ok := processGroupHasRunningMember(pid); ok && running {
t.Fatal("the group still has a running member after termination")
}
}
// TestSignalTargetRunningTreatsZombieIndividualPIDAsExited is the regression
// test for jatmn's second #774 finding: signalTargetRunning's zombie check
// matched the individual-PID fallback target (a process that is NOT its own
// group leader — processSignalTarget's positive-PID case) against process-table
// rows by PGID. That target's actual PGID differs from its own PID by
// definition (that's exactly why processSignalTarget fell back to the
// individual PID instead of a negative group target), so no row ever matched,
// "unknown" resulted every time, and signalTargetRunning conservatively (and
// incorrectly) treated a zombie individual-PID target as still running.
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0")
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
// test process's group, so it is not its own leader and processSignalTarget
// falls back to the individual (positive) PID.
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
target, err := processSignalTarget(pid)
if err != nil {
t.Fatalf("processSignalTarget: %v", err)
}
if target != pid {
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
}
if signalTargetRunning(target) {
t.Fatal("a zombie individual-PID target must not count as running")
}
}
// processIsZombie reports a process's zombie state via ps. ok is false when the
// state could not be read.
func processIsZombie(pid int) (zombie bool, ok bool) {
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false, false
}
state := string(output)
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
state = state[1:]
}
if state == "" {
return false, false
}
return state[0] == 'Z', true
}
+27 -1
View File
@@ -3,6 +3,8 @@
package execution
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -12,10 +14,34 @@ import (
func ConfigureProcessGroup(cmd *exec.Cmd) {}
// KillProcessTree asks taskkill /T to kill pid and descendants it can still
// discover from that live root. If taskkill fails, it falls back to killing pid
// alone but still returns the tree error because descendants may have survived.
// Neither path can recover descendants after the root has exited.
func KillProcessTree(pid int) error {
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return killProcessTree(pid, runTaskkill, killRootProcess)
}
func killProcessTree(pid int, killTree, killRoot func(int) error) error {
treeErr := killTree(pid)
if treeErr == nil {
return nil
}
rootErr := killRoot(pid)
if rootErr != nil {
return errors.Join(
fmt.Errorf("kill process tree rooted at %d: %w", pid, treeErr),
fmt.Errorf("kill root process %d: %w", pid, rootErr),
)
}
return fmt.Errorf("kill process tree rooted at %d (root process killed directly): %w", pid, treeErr)
}
func runTaskkill(pid int) error {
return exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
}
func killRootProcess(pid int) error {
process, err := os.FindProcess(pid)
if err != nil {
return err
@@ -0,0 +1,29 @@
//go:build windows
package execution
import (
"errors"
"strings"
"testing"
)
func TestKillProcessTreeRetainsTreeErrorWhenRootFallbackSucceeds(t *testing.T) {
treeErr := errors.New("taskkill failed")
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return nil })
if !errors.Is(err, treeErr) {
t.Fatalf("killProcessTree error = %v, want tree error", err)
}
if !strings.Contains(err.Error(), "root process killed directly") {
t.Fatalf("killProcessTree error = %q, want fallback context", err)
}
}
func TestKillProcessTreeJoinsTreeAndRootErrors(t *testing.T) {
treeErr := errors.New("taskkill failed")
rootErr := errors.New("root kill failed")
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return rootErr })
if !errors.Is(err, treeErr) || !errors.Is(err, rootErr) {
t.Fatalf("killProcessTree error = %v, want both failures", err)
}
}
+41
View File
@@ -3,12 +3,47 @@ package fsutil
import (
"errors"
"fmt"
"os"
"runtime"
"syscall"
"time"
)
// CommittedReplacementCleanupError reports that a replacement was committed,
// but the old destination retained at BackupPath could not be removed. Callers
// must treat the replacement itself as successful and surface the cleanup
// problem separately.
type CommittedReplacementCleanupError struct {
BackupPath string
Cause error
}
func (err *CommittedReplacementCleanupError) Error() string {
return fmt.Sprintf("replacement committed, but backup %s could not be removed: %v", err.BackupPath, err.Cause)
}
func (err *CommittedReplacementCleanupError) Unwrap() error {
return err.Cause
}
// ReplaceWithRetry publishes src over dst using the platform's replacement
// primitive, retrying on the same transient Windows lock errors RenameWithRetry
// handles. On Windows it uses ReplaceFileW so an existing destination keeps its
// DACL and selected metadata instead of receiving the temporary file's inherited
// DACL. ReplaceFileW is not observer-atomic and may briefly leave dst absent;
// callers that cannot tolerate that must synchronize their own readers. On Unix
// replacement uses os.Rename.
//
// replace overrides the platform primitive so tests can exercise the retry path;
// pass nil for the default.
func ReplaceWithRetry(src, dst string, replace func(src, dst string) error) error {
if replace == nil {
replace = replaceExisting
}
return RenameWithRetry(src, dst, replace)
}
// RenameWithRetry renames src to dst, retrying briefly on Windows when the
// destination is transiently locked (antivirus scanners, search indexers, or
// a concurrent reader holding the file open). rename overrides os.Rename so
@@ -23,6 +58,12 @@ func RenameWithRetry(src, dst string, rename func(src, dst string) error) error
if err == nil {
return nil
}
var committed *CommittedReplacementCleanupError
if errors.As(err, &committed) {
// The source has already been consumed. In particular, never retry if
// the cleanup cause itself is a transient Windows sharing violation.
break
}
if runtime.GOOS == "windows" {
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
time.Sleep(10 * time.Millisecond)
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package fsutil
import "os"
// replaceExisting publishes src over dst. rename(2) already replaces the
// destination atomically within one filesystem on Unix, and it neither creates
// nor consults an ACL, so there is nothing extra to preserve here.
func replaceExisting(src, dst string) error {
return os.Rename(src, dst)
}
+50
View File
@@ -0,0 +1,50 @@
//go:build !windows
package fsutil
import (
"os"
"path/filepath"
"testing"
)
// On Unix the replacement primitive is rename(2), which already publishes
// atomically within one filesystem and neither creates nor consults an ACL. These
// cover both shapes so the shared helper is exercised on every platform.
func TestReplaceWithRetryPublishesOverExistingAndMissingDestinations(t *testing.T) {
for _, tc := range []struct {
name string
existing bool
}{
{name: "existing destination", existing: true},
{name: "missing destination"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if tc.existing {
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
}
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile dst: %v", err)
}
if string(data) != "new" {
t.Fatalf("destination content = %q, want the replacement bytes", data)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("the replacement file should be consumed by the replace: %v", err)
}
})
}
}
+237
View File
@@ -0,0 +1,237 @@
//go:build windows
package fsutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"time"
"unsafe"
)
// replaceFileFlags is deliberately ZERO. Every REPLACEFILE_* flag ReplaceFileW
// accepts either defeats the reason this function exists or does nothing:
//
// - REPLACEFILE_IGNORE_MERGE_ERRORS (0x2) and REPLACEFILE_IGNORE_ACL_ERRORS
// (0x4). Microsoft documents BOTH with the same consequence: "if you specify
// this flag and do not have WRITE_DAC access, the function succeeds but the
// ACLs are not preserved." A silent success that publishes the temporary
// file's inherited directory DACL over an explicitly restricted specialist —
// exposing its system prompt — is precisely the failure this function exists
// to prevent, so a merge failure MUST surface as an error and leave the
// destination untouched. Passing 0x2 while omitting only 0x4 buys nothing:
// ACL merging is part of the metadata merge 0x2 covers.
// - REPLACEFILE_WRITE_THROUGH (0x1) is documented as "This value is not
// supported", so it cannot be relied on to flush anything.
const replaceFileFlags = 0
const replaceBackupPattern = ".zero-replace-*.backup"
const (
// Returned when the volume or redirector cannot provide ReplaceFileW's
// DACL-preserving semantics. Existing destinations must fail closed.
errorInvalidFunction = syscall.Errno(1)
errorNotSupported = syscall.Errno(50)
// Partial-failure codes: ReplaceFileW got far enough to move or delete
// something, so the on-disk state needs repair rather than a bare error. See
// recoverPartialReplace.
errorUnableToRemoveReplaced = syscall.Errno(1175)
errorUnableToMoveReplacement = syscall.Errno(1176)
errorUnableToMoveReplacement2 = syscall.Errno(1177)
)
var (
replaceKernel32 = syscall.NewLazyDLL("kernel32.dll")
replaceProcReplaceFil = replaceKernel32.NewProc("ReplaceFileW")
)
// replaceExisting publishes src over dst with ReplaceFileW rather than
// MoveFileEx (what os.Rename uses) to preserve destination metadata. The
// replacement is a freshly created temporary file, so it carries the directory's
// inherited DACL. Renaming it over the destination would therefore replace the
// destination's ACL - silently widening access to a file that had been restricted
// explicitly (os.File.Chmod cannot express that on Windows; Go only maps the
// owner-write bit). ReplaceFileW carries the replaced file's DACL and selected
// metadata over to the replacement instead.
//
// ReplaceFileW combines multiple filesystem steps and is not observer-atomic: an
// external reader can briefly see dst absent even when replacement succeeds.
// Callers that cannot tolerate that window must synchronize their own readers.
//
// No REPLACEFILE_* flag is passed at all — see replaceFileFlags for why each one
// would either silently lose the descriptor this function exists to preserve or
// do nothing. A merge failure therefore surfaces as an error, leaving the
// destination untouched and the caller free to clean up its temporary file,
// except in the partial-failure states recoverPartialReplace repairs.
func replaceExisting(src, dst string) error {
return replaceExistingWith(src, dst, callReplaceFile, nil)
}
func replaceExistingWith(src, dst string, replace func(string, string, string) error, restore func(string, string) error) error {
return replaceExistingWithCleanup(src, dst, replace, restore, removeReplaceBackup)
}
func replaceExistingWithCleanup(src, dst string, replace func(string, string, string) error, restore func(string, string) error, removeBackup func(string) error) error {
info, err := os.Lstat(dst)
if err != nil {
if os.IsNotExist(err) {
// Nothing to replace and no descriptor to preserve.
return os.Rename(src, dst)
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("refusing to replace symlink destination: %s", dst)
}
backup, err := prepareReplaceBackup(dst)
if err != nil {
return fmt.Errorf("prepare replacement backup: %w", err)
}
callErr := replace(dst, src, backup)
if callErr == nil {
if err := removeBackup(backup); err != nil {
return &CommittedReplacementCleanupError{BackupPath: backup, Cause: err}
}
return nil
}
if errors.Is(callErr, errorInvalidFunction) || errors.Is(callErr, errorNotSupported) {
callErr = fmt.Errorf("DACL-preserving replacement is not supported for %s: %w", dst, callErr)
}
return recoverPartialReplace(callErr, dst, backup, restore)
}
func callReplaceFile(replacedPath, replacementPath, backupPath string) error {
replaced, err := syscall.UTF16PtrFromString(replacedPath)
if err != nil {
return err
}
replacement, err := syscall.UTF16PtrFromString(replacementPath)
if err != nil {
return err
}
backup, err := syscall.UTF16PtrFromString(backupPath)
if err != nil {
return err
}
result, _, callErr := replaceProcReplaceFil.Call(
uintptr(unsafe.Pointer(replaced)),
uintptr(unsafe.Pointer(replacement)),
uintptr(unsafe.Pointer(backup)),
uintptr(replaceFileFlags),
0,
0,
)
if result != 0 {
return nil
}
if callErr == nil || errors.Is(callErr, syscall.Errno(0)) {
return fmt.Errorf("replace %s: ReplaceFileW failed", replacedPath)
}
return callErr
}
func prepareReplaceBackup(dst string) (string, error) {
file, err := os.CreateTemp(filepath.Dir(dst), replaceBackupPattern)
if err != nil {
return "", err
}
path := file.Name()
if err := file.Close(); err != nil {
cleanupErr := removeReplaceBackup(path)
return "", fmt.Errorf("close backup placeholder %s: %w (cleanup error: %v)", path, err, cleanupErr)
}
if err := removeReplaceBackup(path); err != nil {
return "", fmt.Errorf("release backup path %s: %w", path, err)
}
return path, nil
}
func removeReplaceBackup(path string) error {
var err error
for i := 0; i < 10; i++ {
err = os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
if os.IsPermission(err) {
// ReplaceFileW can leave the original's read-only attribute on the
// backup. Chmod clears that bit on Windows without changing its DACL.
_ = os.Chmod(path, 0o600)
}
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
time.Sleep(10 * time.Millisecond)
continue
}
break
}
return err
}
func cleanupReplaceBackup(callErr error, backup string) error {
if err := removeReplaceBackup(backup); err != nil {
return fmt.Errorf("%w (backup %s could not be removed: %v)", callErr, backup, err)
}
return callErr
}
// recoverPartialReplace restores the original after ReplaceFileW reports a
// partial failure, then returns the original error. Supplying a managed backup
// changes the dangerous failure states documented by Microsoft:
//
// - ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176) leaves both files under their
// original names, so the failed write can discard src normally.
// - ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177) leaves src in place and moves the
// original destination to backup. Moving backup back to dst rolls the failed
// overwrite back without losing the original content or its DACL.
//
// The replacement is left alone either way — nothing here deletes what it was
// handed — but the errors below deliberately say nothing about where it ended up,
// which is also why its path is not a parameter. Whether it still exists when the
// error surfaces is the caller's business: the specialist writer, the only caller
// today, removes its temporary file in a deferred cleanup, so an error promising
// a replacement "remains at" that path would send an operator looking for a file
// that was already gone. What these errors do describe is what this function
// itself left on disk — the state of dst, and of the managed backup holding the
// original.
func recoverPartialReplace(callErr error, dst, backup string, restore func(string, string) error) error {
if !errors.Is(callErr, errorUnableToMoveReplacement2) {
// For 1175, 1176, unsupported volumes, and ordinary failures, Windows
// documents that dst remains at its original name. Remove any redundant
// backup only after confirming that the destination still exists.
if _, err := os.Lstat(dst); err == nil {
return cleanupReplaceBackup(callErr, backup)
} else if !os.IsNotExist(err) {
return fmt.Errorf("replace %s: %w (inspect destination during recovery: %v)", dst, callErr, err)
}
}
if _, err := os.Lstat(backup); err != nil {
return fmt.Errorf("replace %s: %w (original backup expected at %s is unavailable: %v)", dst, callErr, backup, err)
}
if _, err := os.Lstat(dst); err == nil {
return fmt.Errorf("replace %s: %w (destination unexpectedly exists; the original also remains at backup %s)", dst, callErr, backup)
} else if !os.IsNotExist(err) {
return fmt.Errorf("replace %s: %w (inspect destination before restoring backup %s: %v)", dst, callErr, backup, err)
}
if err := RenameWithRetry(backup, dst, restore); err != nil {
// Only callErr is wrapped. Exposing a sharing violation from the exhausted
// rollback would make the outer ReplaceWithRetry call retry after the
// filesystem was already mutated.
//
// This is the one terminal state that needs a human: nothing is at dst, and
// the original's only copy is under a name no tool looks for (the specialist
// loader reads *.md and skips everything else), so it is invisible until
// somebody moves it back. Say that outright rather than leaving an operator
// to infer it from a bare error code.
return fmt.Errorf(
"replace %s: %w (rolling the original back failed: %v; %s no longer exists and the original survives only as %s, which must be moved back by hand to restore it)",
dst, callErr, err, dst, backup,
)
}
return fmt.Errorf("replace %s: %w (the original was restored)", dst, callErr)
}
+559
View File
@@ -0,0 +1,559 @@
//go:build windows
package fsutil
import (
"errors"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"golang.org/x/sys/windows"
)
// TestReplaceWithRetryPreservesDestinationCreationTime proves the publish goes
// through ReplaceFileW and not os.Rename: ReplaceFileW carries selected metadata,
// including creation time, from the destination to the replacement. A rename
// would carry the temporary file's creation time over instead.
func TestReplaceWithRetryPreservesDestinationCreationTime(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
created := creationTime(t, dst)
src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
// Push the replacement's own creation time clearly past the destination's so a
// rename would be visible in the comparison below.
future := windows.NsecToFiletime(created.Nanoseconds() + int64(10*1e9))
setCreationTime(t, src, future)
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile dst: %v", err)
}
if string(data) != "new" {
t.Fatalf("destination content = %q, want the replacement bytes", data)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("the replacement file should be consumed by the replace: %v", err)
}
assertNoReplaceBackups(t, dir)
if got := creationTime(t, dst); got != created {
t.Fatalf("creation time = %v, want the replaced file's %v (a rename would not preserve it)", got, created)
}
}
// TestReplaceWithRetryPreservesDestinationDACL is the regression test for the
// second half of the finding: the replacement is a freshly created temporary file
// carrying the directory's inherited DACL, so publishing it with a rename would
// REPLACE the restrictive descriptor an explicitly locked-down file had.
func TestReplaceWithRetryPreservesDestinationDACL(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
// A protected DACL granting only the owner: distinct from whatever the temp
// file inherits from the directory.
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
if err != nil {
t.Skipf("cannot build a test security descriptor: %v", err)
}
dacl, _, err := restricted.DACL()
if err != nil {
t.Skipf("cannot read the test DACL: %v", err)
}
if err := windows.SetNamedSecurityInfo(
dst,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, nil, dacl, nil,
); err != nil {
t.Skipf("cannot apply a restrictive DACL on this filesystem: %v", err)
}
want := describeDACL(t, dst)
src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if inherited := describeDACL(t, src); inherited == want {
t.Skip("the temporary file already carries the same DACL; this filesystem cannot show the difference")
}
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if got := describeDACL(t, dst); got != want {
t.Fatalf("DACL after replace = %q, want the destination's own %q", got, want)
}
assertNoReplaceBackups(t, dir)
}
// TestReplaceWithRetryPublishesWhenDestinationIsMissing covers the no-destination
// case: ReplaceFileW requires an existing file to replace, so there is a rename
// fallback (and nothing to preserve).
func TestReplaceWithRetryPublishesWhenDestinationIsMissing(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := ReplaceWithRetry(src, dst, nil); err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if data, err := os.ReadFile(dst); err != nil || string(data) != "new" {
t.Fatalf("destination = %q err=%v, want the replacement bytes", data, err)
}
assertNoReplaceBackups(t, dir)
}
// TestReplaceWithRetryRetriesTransientLockViolation keeps the retry behavior that
// RenameWithRetry provides for antivirus/indexer holds.
func TestReplaceWithRetryRetriesTransientLockViolation(t *testing.T) {
attempts := 0
err := ReplaceWithRetry("src", "dst", func(src, dst string) error {
attempts++
if attempts < 3 {
return &os.PathError{Op: "replace", Path: dst, Err: syscall.Errno(32)} // ERROR_SHARING_VIOLATION
}
return nil
})
if err != nil {
t.Fatalf("ReplaceWithRetry: %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want the transient violations retried", attempts)
}
}
// TestReplaceFileFlagsDoNotIgnoreMergeErrors is the regression test for jatmn's
// #757 P1 finding on flags: the call used to pass REPLACEFILE_IGNORE_MERGE_ERRORS
// (0x2) while the comment claimed ACL failures were fail-closed. Microsoft
// documents 0x2 and REPLACEFILE_IGNORE_ACL_ERRORS (0x4) identically — with either
// one set, a call lacking WRITE_DAC "succeeds but the ACLs are not preserved" —
// so passing 0x2 let a --force overwrite silently publish the temporary file's
// inherited directory DACL over a restricted specialist and expose its system
// prompt.
//
// This asserts the flag word directly rather than a live denied merge because a
// denied merge is not constructible for a file this process owns: Windows grants
// an object's owner READ_CONTROL and WRITE_DAC implicitly, so no DACL a test can
// apply to its own temp file can withhold WRITE_DAC from it. Pinning the flags is
// what actually prevents the regression — re-adding either bit fails here.
func TestReplaceFileFlagsDoNotIgnoreMergeErrors(t *testing.T) {
const (
ignoreMergeErrors = 0x00000002
ignoreACLErrors = 0x00000004
)
if replaceFileFlags&ignoreMergeErrors != 0 {
t.Error("REPLACEFILE_IGNORE_MERGE_ERRORS must not be set: it makes ReplaceFileW succeed WITHOUT preserving ACLs when it cannot obtain WRITE_DAC")
}
if replaceFileFlags&ignoreACLErrors != 0 {
t.Error("REPLACEFILE_IGNORE_ACL_ERRORS must not be set: it makes ReplaceFileW succeed WITHOUT preserving ACLs when it cannot obtain WRITE_DAC")
}
}
func TestReplaceExistingRejectsUnsupportedDACLReplacement(t *testing.T) {
for _, tc := range []struct {
name string
code syscall.Errno
}{
{name: "ERROR_INVALID_FUNCTION", code: errorInvalidFunction},
{name: "ERROR_NOT_SUPPORTED", code: errorNotSupported},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
err := replaceExistingWith(src, dst, func(replaced, replacement, backup string) error {
if replaced != dst || replacement != src {
t.Fatalf("ReplaceFileW paths = (%q, %q), want (%q, %q)", replaced, replacement, dst, src)
}
if filepath.Dir(backup) != dir {
t.Fatalf("backup directory = %q, want sibling directory %q", filepath.Dir(backup), dir)
}
return tc.code
}, nil)
if !errors.Is(err, tc.code) {
t.Fatalf("error = %v, want unsupported error %v", err, tc.code)
}
if !strings.Contains(err.Error(), "DACL-preserving replacement is not supported") {
t.Fatalf("error = %v, want a DACL-preserving-replacement explanation", err)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
assertNoReplaceBackups(t, dir)
})
}
}
func TestReplaceExistingCleansManagedBackup(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
err := replaceExistingWith(src, dst, func(replaced, replacement, backup string) error {
backupPath = backup
if matched, matchErr := filepath.Match(replaceBackupPattern, filepath.Base(backup)); matchErr != nil || !matched {
t.Fatalf("backup path = %q, want pattern %q (match error: %v)", backup, replaceBackupPattern, matchErr)
}
if _, statErr := os.Lstat(backup); !os.IsNotExist(statErr) {
t.Fatalf("backup path must be vacant before ReplaceFileW: %v", statErr)
}
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage backup: %v", renameErr)
}
// Windows refuses to remove a read-only file. The cleanup path must clear
// that attribute without changing the backup's DACL, then remove it.
if chmodErr := os.Chmod(backup, 0o400); chmodErr != nil {
t.Fatalf("make backup read-only: %v", chmodErr)
}
return os.Rename(replacement, replaced)
}, nil)
if err != nil {
t.Fatalf("replaceExistingWith: %v", err)
}
assertFileContent(t, dst, "new")
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("replacement source should be consumed: %v", err)
}
if _, err := os.Lstat(backupPath); !os.IsNotExist(err) {
t.Fatalf("managed backup should be removed: %v", err)
}
assertNoReplaceBackups(t, dir)
}
func TestReplaceWithRetryDoesNotRetryCommittedReplacementWhenBackupCleanupFails(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
attempts := 0
cleanupErr := &os.PathError{Op: "remove", Path: "managed backup", Err: syscall.Errno(32)}
var backupPath string
err := ReplaceWithRetry(src, dst, func(src, dst string) error {
attempts++
return replaceExistingWithCleanup(src, dst, func(replaced, replacement, backup string) error {
backupPath = backup
if err := os.Rename(replaced, backup); err != nil {
return err
}
return os.Rename(replacement, replaced)
}, nil, func(got string) error {
if got != backupPath {
t.Fatalf("cleanup backup path = %q, want %q", got, backupPath)
}
return cleanupErr
})
})
var outcome *CommittedReplacementCleanupError
if !errors.As(err, &outcome) {
t.Fatalf("error = %v, want committed cleanup outcome", err)
}
if outcome.BackupPath != backupPath || !errors.Is(outcome, syscall.Errno(32)) {
t.Fatalf("outcome = %#v, want backup %q and sharing violation", outcome, backupPath)
}
if attempts != 1 {
t.Fatalf("replacement attempts = %d, want 1", attempts)
}
assertFileContent(t, dst, "new")
assertFileContent(t, backupPath, "old")
}
func TestReplaceExistingKeeps1176FilesAtOriginalNames(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
err := replaceExistingWith(src, dst, func(_, _, _ string) error {
// With a backup name, Microsoft documents that 1176 leaves the replaced
// and replacement files under their original names.
return errorUnableToMoveReplacement
}, nil)
if !errors.Is(err, errorUnableToMoveReplacement) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
assertNoReplaceBackups(t, dir)
}
func TestReplaceExistingRollsBack1177AndRetriesTransientRestore(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
restoreAttempts := 0
err := replaceExistingWith(src, dst, func(replaced, _, backup string) error {
backupPath = backup
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage documented 1177 backup: %v", renameErr)
}
return errorUnableToMoveReplacement2
}, func(backup, destination string) error {
restoreAttempts++
if restoreAttempts < 3 {
return &os.PathError{Op: "rename", Path: backup, Err: syscall.Errno(32)}
}
return os.Rename(backup, destination)
})
if !errors.Is(err, errorUnableToMoveReplacement2) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement2)
}
if restoreAttempts != 3 {
t.Fatalf("restore attempts = %d, want transient lock failures retried", restoreAttempts)
}
assertErrorDoesNotAdvertiseReplacement(t, err, src)
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
if _, err := os.Lstat(backupPath); !os.IsNotExist(err) {
t.Fatalf("backup should be consumed by rollback: %v", err)
}
assertNoReplaceBackups(t, dir)
}
func TestReplaceExistingPreserves1177BackupWhenRollbackFails(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
var backupPath string
replaceAttempts := 0
err := ReplaceWithRetry(src, dst, func(src, dst string) error {
replaceAttempts++
return replaceExistingWith(src, dst, func(replaced, _, backup string) error {
backupPath = backup
if renameErr := os.Rename(replaced, backup); renameErr != nil {
t.Fatalf("stage documented 1177 backup: %v", renameErr)
}
return errorUnableToMoveReplacement2
}, func(_, _ string) error {
return &os.PathError{Op: "rename", Path: backupPath, Err: syscall.Errno(32)}
})
})
if !errors.Is(err, errorUnableToMoveReplacement2) {
t.Fatalf("error = %v, want %v", err, errorUnableToMoveReplacement2)
}
if replaceAttempts != 1 {
t.Fatalf("replace attempts = %d, want no retry after a partial failure", replaceAttempts)
}
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
t.Fatalf("partial-failure error exposes the rollback lock error to the outer retry loop: %v", err)
}
if !strings.Contains(err.Error(), backupPath) {
t.Fatalf("error = %v, want retained backup path %q", err, backupPath)
}
// The terminal state an operator has to fix by hand: the original exists only
// under the backup name and nothing is at dst, so the error has to name both
// and say what to do, not just report the Windows code.
if !strings.Contains(err.Error(), dst) {
t.Fatalf("error = %v, want the destination %q an operator must restore to", err, dst)
}
if !strings.Contains(err.Error(), "moved back by hand") {
t.Fatalf("error = %v, want an explicit instruction to move the backup back", err)
}
assertErrorDoesNotAdvertiseReplacement(t, err, src)
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("destination should remain absent after failed rollback: %v", err)
}
assertFileContent(t, backupPath, "old")
assertFileContent(t, src, "new")
}
func TestReplaceExistingRejectsSymlinkDestination(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.md")
dst := filepath.Join(dir, "manifest.md")
src := filepath.Join(dir, ".manifest.tmp")
if err := os.WriteFile(target, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.Symlink(target, dst); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
called := false
err := replaceExistingWith(src, dst, func(_, _, _ string) error {
called = true
return nil
}, nil)
if err == nil || !strings.Contains(err.Error(), "refusing to replace symlink") {
t.Fatalf("symlink replacement error = %v", err)
}
if called {
t.Fatal("ReplaceFileW callback was called for a symlink destination")
}
assertFileContent(t, target, "old")
assertFileContent(t, src, "new")
if info, err := os.Lstat(dst); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("destination symlink was changed: info=%v err=%v", info, err)
}
assertNoReplaceBackups(t, dir)
}
func TestRecoverPartialReplaceLeavesIntactStatesAlone(t *testing.T) {
for _, tc := range []struct {
name string
code syscall.Errno
}{
{name: "ERROR_UNABLE_TO_REMOVE_REPLACED", code: errorUnableToRemoveReplaced},
{name: "ERROR_ACCESS_DENIED", code: syscall.Errno(5)},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, ".manifest.tmp")
dst := filepath.Join(dir, "manifest.md")
backup := filepath.Join(dir, ".zero-replace-test.backup")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile dst: %v", err)
}
if err := recoverPartialReplace(tc.code, dst, backup, nil); !errors.Is(err, tc.code) {
t.Fatalf("error = %v, want the original %v unchanged", err, tc.code)
}
assertFileContent(t, dst, "old")
assertFileContent(t, src, "new")
if _, err := os.Lstat(backup); !os.IsNotExist(err) {
t.Fatalf("unexpected backup residue: %v", err)
}
})
}
}
// assertErrorDoesNotAdvertiseReplacement guards the messaging fixed for jatmn's
// #757 P3 finding: recovery errors used to say the replacement "remains at" the
// caller's temporary path, but the only caller removes that file in a deferred
// cleanup before the error ever surfaces. Recovery is always about the original,
// so an operator must never be sent looking for the replacement.
func assertErrorDoesNotAdvertiseReplacement(t *testing.T, err error, replacement string) {
t.Helper()
if err == nil {
t.Fatal("expected a partial-replacement error")
}
if strings.Contains(err.Error(), replacement) {
t.Fatalf("error = %v, must not point an operator at the replacement %q, which its caller deletes", err, replacement)
}
}
func assertFileContent(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if string(data) != want {
t.Fatalf("content of %s = %q, want %q", path, data, want)
}
}
func assertNoReplaceBackups(t *testing.T, dir string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(dir, replaceBackupPattern))
if err != nil {
t.Fatalf("Glob replacement backups: %v", err)
}
if len(matches) != 0 {
t.Fatalf("replacement backups remain: %v", matches)
}
}
func creationTime(t *testing.T, path string) syscall.Filetime {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat %s: %v", path, err)
}
data, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok {
t.Skipf("no Windows file attributes for %s", path)
}
return data.CreationTime
}
func setCreationTime(t *testing.T, path string, created windows.Filetime) {
t.Helper()
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
t.Fatalf("UTF16PtrFromString: %v", err)
}
handle, err := windows.CreateFile(pathPtr, windows.FILE_WRITE_ATTRIBUTES, 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
t.Fatalf("CreateFile %s: %v", path, err)
}
defer func() {
_ = windows.CloseHandle(handle)
}()
if err := windows.SetFileTime(handle, &created, nil, nil); err != nil {
t.Fatalf("SetFileTime %s: %v", path, err)
}
}
func describeDACL(t *testing.T, path string) string {
t.Helper()
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Skipf("cannot read the security descriptor of %s: %v", path, err)
}
text := sd.String()
if index := strings.Index(text, "D:"); index >= 0 {
return text[index:]
}
return text
}
+263 -11
View File
@@ -13,8 +13,14 @@ import (
)
// NotificationHandler receives server->client notifications (e.g.
// textDocument/publishDiagnostics). params is the raw JSON payload.
type NotificationHandler func(method string, params json.RawMessage)
// textDocument/publishDiagnostics). params is the raw JSON payload. seq is the
// notification's receipt sequence (see Client.ReceiptSeq): the position of the
// frame it arrived in, stamped the moment that frame came off the wire — NOT
// when this handler happens to run, which can lag receipt when the queue is
// backed up. A caller that needs to know whether something newer than a given
// point has arrived must compare against seq, not against when its own handling
// code runs.
type NotificationHandler func(method string, params json.RawMessage, seq int64)
// Client speaks JSON-RPC 2.0 with LSP framing (Content-Length headers) over a
// reader/writer pair. It is transport-agnostic: server.go wires it to a process's
@@ -32,8 +38,60 @@ type Client struct {
closeOnce sync.Once
closed chan struct{}
readErr error
notifyMu sync.Mutex
notifyQueue []notification
notifyBytes int
notifyReady chan struct{}
notifyClosed bool
// notificationDrained is closed by notificationLoop, and only after
// closeNotifications has stopped admission and every accepted notification
// (including an in-flight handler) has finished.
notificationDrained chan struct{}
receiptSeq int64 // frames read off the wire so far (see ReceiptSeq)
// acceptedSeq is the receipt seq of the last notification queued for the
// worker, handledSeq the seq of the last one whose handler has returned, and
// handledWait a channel closed (and replaced) each time handledSeq advances.
// Together they let a waiter tell whether the worker has caught up with what
// the reader accepted. Both track notifications only; receiptSeq counts every
// frame, so the two are compared against each other, never against it.
acceptedSeq int64
handledSeq int64
handledWait chan struct{}
}
type notification struct {
method string
params json.RawMessage
seq int64
}
// The notification backlog is bounded by both message count and retained
// payload bytes. A well-behaved handler drains far faster than any single burst
// fills either limit; sustained overload — a language server emitting faster
// than the single handler can consume, or a handler stuck waiting on a
// re-entrant Call — is a fatal condition for this client, not something to
// paper over by growing the queue without bound. Hitting either limit fails the
// client (see enqueueNotification): IsClosed becomes true, and the manager
// evicts and restarts the session on next use, exactly as it does for any other
// dead client.
const (
notifyQueueLimit = 4096
notifyQueueByteLimit = 16 << 20 // 16 MiB
)
// maxFrameBytes is the largest single frame this transport will read. Without
// it the byte budget above would not be authoritative: readMessage allocates the
// whole body before anything can classify it, so one notification with a huge
// Content-Length would be materialized in full and only then rejected by
// enqueueNotification. Capping at the same value keeps that impossible — a
// notification can never allocate past the budget it is measured against — and
// bounds what a malformed or hostile header can make this client allocate. A
// legitimate LSP frame is orders of magnitude smaller; one that is not is a
// protocol error, and failing the read closes the client so the manager restarts
// the session.
const maxFrameBytes = notifyQueueByteLimit
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -81,10 +139,14 @@ type incomingMessage struct {
// server process exits); call Close to stop using the client.
func NewClient(r io.Reader, w io.Writer) *Client {
client := &Client{
writer: w,
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
writer: w,
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
notifyReady: make(chan struct{}, 1),
notificationDrained: make(chan struct{}),
handledWait: make(chan struct{}),
}
go client.notificationLoop()
go client.readLoop(bufio.NewReader(r))
return client
}
@@ -155,6 +217,15 @@ func (c *Client) readLoop(reader *bufio.Reader) {
c.failPending(err)
return
}
// Stamp the receipt boundary here — while the frame is nothing but bytes
// that have left the wire — rather than at enqueue. Everything below
// (json.Unmarshal of a peer-sized payload, then enqueue) runs on this
// goroutine while another can be capturing a baseline from ReceiptSeq, so
// stamping later would let a publish that was already read be numbered
// above a baseline taken after it arrived, and a superseded publish would
// then look fresh. Frames that turn out not to be notifications consume a
// number too: the sequence is a receipt clock, and gaps in it are fine.
seq := c.stampReceipt()
var msg incomingMessage
if err := json.Unmarshal(body, &msg); err != nil {
continue // skip a malformed frame rather than tearing down the session
@@ -166,12 +237,7 @@ func (c *Client) readLoop(reader *bufio.Reader) {
// required or the server can block waiting on it (e.g. registerCapability).
_ = c.write(outgoingReply{JSONRPC: "2.0", ID: msg.ID, Result: nil})
case msg.Method != "":
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler != nil {
handler(msg.Method, msg.Params)
}
c.enqueueNotification(notification{method: msg.Method, params: msg.Params, seq: seq})
case hasID:
var id int64
if err := json.Unmarshal(msg.ID, &id); err == nil {
@@ -181,6 +247,171 @@ func (c *Client) readLoop(reader *bufio.Reader) {
}
}
func (c *Client) notificationLoop() {
defer close(c.notificationDrained)
for {
<-c.notifyReady
for {
notification, ok, closed := c.dequeueNotification()
if !ok {
if closed {
return
}
break
}
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler != nil {
handler(notification.method, notification.params, notification.seq)
}
c.markHandled(notification.seq)
}
}
}
// markHandled records that everything received through seq has now been handled
// and wakes anyone waiting for the worker to catch up.
func (c *Client) markHandled(seq int64) {
c.notifyMu.Lock()
if seq > c.handledSeq {
c.handledSeq = seq
}
wait := c.handledWait
c.handledWait = make(chan struct{})
c.notifyMu.Unlock()
if wait != nil {
close(wait)
}
}
// handledThrough returns the receipt seq the worker has finished handling, plus
// a channel closed the next time that advances. The channel is nil for a Client
// built without NewClient (some unit tests), which has no worker to advance it.
func (c *Client) handledThrough() (int64, <-chan struct{}) {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
return c.handledSeq, c.handledWait
}
// acceptedNotificationSeq returns the receipt seq of the most recently queued
// notification (0 if none). It is the point the worker must reach to have
// handled everything the reader had accepted at the time of the call.
func (c *Client) acceptedNotificationSeq() int64 {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
return c.acceptedSeq
}
// notificationsDone returns the worker completion signal. A nil result means
// this Client was constructed without NewClient (as some unit-test clients are)
// and has no notification worker to wait for.
func (c *Client) notificationsDone() <-chan struct{} {
return c.notificationDrained
}
// enqueueNotification hands a server notification to the worker loop. item.seq
// must already carry the receipt stamp from stampReceipt: the receipt boundary
// belongs to the moment the frame left the wire, not to this call, which happens
// a full json.Unmarshal later. It never blocks and never silently discards a
// message the handler could still act on: the queue grows instead, up to its
// message and byte limits.
//
// The alternatives to growing are worse. Blocking the read loop when a buffer
// fills is the deadlock this dispatch exists to avoid — a handler that calls
// Client.Call waits for a response frame the blocked reader can no longer
// deliver. Dropping the oldest queued item instead loses protocol state
// permanently: a textDocument/publishDiagnostics for one URI is the server's
// only report for that URI, so discarding it makes session.waitForDiagnostics
// time out and Manager.Check return nothing even though the server published
// findings.
//
// Growth is bounded in practice by how much the server emits while a handler
// runs, and the queue is released as soon as it drains. But "in practice" is
// not a limit: a handler that never returns, or a server that sustains a
// higher rate than the single handler can drain, would otherwise grow this
// queue's full json.RawMessage payloads without bound until the heap gives
// out. The count limit alone is insufficient because notification payloads are
// peer-controlled and can be large; the byte limit prevents a few large
// diagnostics publishes from exhausting the heap before the count limit is
// reached. Either limit turns overload into an explicit, observable failure —
// the client is failed and closed — rather than unbounded protocol retention.
func (c *Client) enqueueNotification(item notification) {
c.notifyMu.Lock()
if c.notifyClosed {
// The worker loop has already stopped, so anything queued now would never
// be handled. Retaining it would grow the queue for as long as the
// transport stays readable after Close — Server.Shutdown closes the client
// before closing stdin, so a server emitting notifications while it handles
// shutdown/exit keeps the read loop feeding a queue nobody drains.
c.notifyMu.Unlock()
return
}
itemBytes := len(item.method) + len(item.params)
if len(c.notifyQueue) >= notifyQueueLimit || itemBytes > notifyQueueByteLimit-c.notifyBytes {
c.notifyMu.Unlock()
c.failPending(fmt.Errorf(
"lsp client: notification backlog exceeded %d messages or %d bytes",
notifyQueueLimit,
notifyQueueByteLimit,
))
return
}
c.notifyQueue = append(c.notifyQueue, item)
c.notifyBytes += itemBytes
c.acceptedSeq = item.seq
c.notifyMu.Unlock()
select {
case c.notifyReady <- struct{}{}:
default:
// A wake-up is already pending; the worker drains the whole queue per wake,
// so this item is covered by it.
}
}
// stampReceipt claims the next receipt number for a frame that has just been
// read off the wire. The read loop calls it for every frame before parsing one,
// so a notification's seq reflects when it arrived rather than when this client
// got around to decoding and queueing it.
func (c *Client) stampReceipt() int64 {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
c.receiptSeq++
return c.receiptSeq
}
// ReceiptSeq returns how many frames have been read off the wire so far,
// including any notification still waiting to be dispatched to the handler. A
// caller that wants to know whether a notification newer than "now" has arrived
// should snapshot this before triggering whatever produces it, then require a
// subsequently-observed seq to be strictly greater: a notification whose frame
// had already been read at snapshot time has seq <= the snapshot, even if it is
// still queued and its handler doesn't run until afterward.
func (c *Client) ReceiptSeq() int64 {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
return c.receiptSeq
}
func (c *Client) dequeueNotification() (notification, bool, bool) {
c.notifyMu.Lock()
defer c.notifyMu.Unlock()
if len(c.notifyQueue) == 0 {
return notification{}, false, c.notifyClosed
}
item := c.notifyQueue[0]
c.notifyBytes -= len(item.method) + len(item.params)
c.notifyQueue[0] = notification{}
c.notifyQueue = c.notifyQueue[1:]
if len(c.notifyQueue) == 0 {
// Release the backing array once drained; re-slicing alone would keep a
// burst's worth of capacity (and its already-consumed items) alive.
c.notifyQueue = nil
}
return item, true, false
}
func (c *Client) deliver(id int64, resp rpcResponse) {
c.mu.Lock()
ch, ok := c.pending[id]
@@ -204,9 +435,27 @@ func (c *Client) failPending(err error) {
ch <- rpcResponse{Err: &rpcError{Code: -1, Message: err.Error()}}
}
close(c.closed)
c.closeNotifications()
})
}
// closeNotifications atomically stops accepting notifications, then wakes the
// worker so everything accepted before that boundary is delivered in FIFO order.
// It deliberately does not wait: a user handler may be blocked, and transport
// failure, overload, and concurrent Close must never deadlock teardown. The
// worker exits itself after the accepted queue (including any in-flight handler)
// has drained. An enqueue racing with shutdown is therefore either accepted and
// delivered before worker exit, or observes notifyClosed and is rejected.
func (c *Client) closeNotifications() {
c.notifyMu.Lock()
c.notifyClosed = true
c.notifyMu.Unlock()
select {
case c.notifyReady <- struct{}{}:
default:
}
}
func (c *Client) readError() error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -283,6 +532,9 @@ func readMessage(reader *bufio.Reader) ([]byte, error) {
if contentLength < 0 {
return nil, errors.New("message missing Content-Length header")
}
if contentLength > maxFrameBytes {
return nil, fmt.Errorf("lsp frame of %d bytes exceeds the %d byte limit", contentLength, maxFrameBytes)
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(reader, body); err != nil {
return nil, err
+511 -1
View File
@@ -7,7 +7,9 @@ import (
"encoding/json"
"fmt"
"io"
"runtime"
"strings"
"sync"
"testing"
"time"
)
@@ -208,7 +210,7 @@ func TestClientNotificationHandler(t *testing.T) {
_ = serverReader
received := make(chan string, 1)
client.SetNotificationHandler(func(method string, _ json.RawMessage) {
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
received <- method
})
_ = writeMessage(serverWriter, map[string]any{
@@ -227,6 +229,438 @@ func TestClientNotificationHandler(t *testing.T) {
}
}
func TestClientNotificationHandlerCanCallClient(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
client := NewClient(clientReader, clientWriter)
defer client.Close()
defer serverWriter.Close()
defer clientWriter.Close()
serverDone := make(chan error, 1)
go func() {
body, err := readMessage(bufio.NewReader(serverReader))
if err != nil {
serverDone <- err
return
}
var request incomingMessage
if err := json.Unmarshal(body, &request); err != nil {
serverDone <- err
return
}
serverDone <- writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]bool{"applied": true},
})
}()
handlerDone := make(chan error, 1)
client.SetNotificationHandler(func(_ string, _ json.RawMessage, _ int64) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := client.Call(ctx, "workspace/applyEdit", nil)
handlerDone <- err
})
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": "workspace/requestEdit",
}); err != nil {
t.Fatal(err)
}
select {
case err := <-handlerDone:
if err != nil {
t.Fatalf("notification handler call failed: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("notification handler deadlocked waiting for its response")
}
if err := <-serverDone; err != nil {
t.Fatalf("server failed: %v", err)
}
}
func TestClientNotificationHandlersPreserveOrder(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
defer client.Close()
defer serverWriter.Close()
received := make(chan string, 2)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
received <- method
})
for _, method := range []string{"first", "second"} {
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": method,
}); err != nil {
t.Fatal(err)
}
}
for _, want := range []string{"first", "second"} {
select {
case got := <-received:
if got != want {
t.Fatalf("notification = %q, want %q", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for notification %q", want)
}
}
}
func TestClientNotificationBurstDoesNotBlockResponse(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
client := NewClient(clientReader, clientWriter)
defer client.Close()
defer serverWriter.Close()
defer clientWriter.Close()
handlerStarted := make(chan struct{})
handlerDone := make(chan error, 1)
var mu sync.Mutex
var queued []string
allQueued := make(chan struct{})
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
if method != "blocking" {
mu.Lock()
queued = append(queued, method)
complete := len(queued) == notificationBurstSize
mu.Unlock()
if complete {
close(allQueued)
}
return
}
close(handlerStarted)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := client.Call(ctx, "workspace/applyEdit", nil)
handlerDone <- err
})
serverDone := make(chan error, 1)
go func() {
reader := bufio.NewReader(serverReader)
body, err := readMessage(reader)
if err != nil {
serverDone <- err
return
}
var request incomingMessage
if err := json.Unmarshal(body, &request); err != nil {
serverDone <- err
return
}
for i := 0; i < notificationBurstSize; i++ {
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": fmt.Sprintf("queued-%03d", i),
}); err != nil {
serverDone <- err
return
}
}
serverDone <- writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": nil,
})
}()
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": "blocking",
}); err != nil {
t.Fatal(err)
}
select {
case <-handlerStarted:
case <-time.After(time.Second):
t.Fatal("blocking notification handler did not start")
}
select {
case err := <-handlerDone:
if err != nil {
t.Fatalf("notification handler call failed under burst: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("notification burst blocked the response read loop")
}
if err := <-serverDone; err != nil {
t.Fatalf("server failed: %v", err)
}
// Not blocking the reader is only half the requirement: every notification the
// server sent during the burst must still reach the handler, in order. A
// bounded queue that drops the oldest would silently lose the head of this
// sequence — and with it a publishDiagnostics the checker is waiting for.
select {
case <-allQueued:
case <-time.After(5 * time.Second):
mu.Lock()
received := len(queued)
mu.Unlock()
t.Fatalf("received %d of %d burst notifications; the queue lost messages", received, notificationBurstSize)
}
mu.Lock()
defer mu.Unlock()
for i, method := range queued {
if want := fmt.Sprintf("queued-%03d", i); method != want {
t.Fatalf("burst notification %d = %q, want %q (order must survive the burst)", i, method, want)
}
}
}
// notificationBurstSize is far past any buffer the dispatch used to have, so a
// bounded queue would be visible as either a blocked read loop or a lost message.
const notificationBurstSize = 512
// TestClientNotificationQueueIsLossless is the regression test for the overflow
// policy: a queued notification must never be discarded. A dropped
// textDocument/publishDiagnostics is the server's only report for that URI, so
// losing it makes session.waitForDiagnostics time out and Manager.Check return
// nothing even though the server published findings.
func TestClientNotificationQueueIsLossless(t *testing.T) {
client := &Client{notifyReady: make(chan struct{}, 1)}
for i := 0; i < notificationBurstSize; i++ {
client.enqueueNotification(notification{method: fmt.Sprintf("notification-%03d", i)})
}
for i := 0; i < notificationBurstSize; i++ {
item, ok, _ := client.dequeueNotification()
if !ok {
t.Fatalf("notification %d was discarded by the queue", i)
}
want := fmt.Sprintf("notification-%03d", i)
if item.method != want {
t.Fatalf("notification = %q, want %q (queue must stay FIFO)", item.method, want)
}
}
if _, ok, _ := client.dequeueNotification(); ok {
t.Fatal("queue returned more notifications than were enqueued")
}
if client.notifyQueue != nil {
t.Fatalf("drained queue retained %d slots of capacity", cap(client.notifyQueue))
}
if client.notifyBytes != 0 {
t.Fatalf("drained queue retained byte accounting: %d", client.notifyBytes)
}
}
func TestClientDrainsAcceptedNotificationsAfterTransportEOF(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
blockStarted := make(chan struct{})
releaseBlock := make(chan struct{})
handled := make(chan string, 2)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
if method == "block" {
close(blockStarted)
<-releaseBlock
}
handled <- method
})
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "block"}); err != nil {
t.Fatal(err)
}
<-blockStarted
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
}); err != nil {
t.Fatal(err)
}
// Wait until the reader has accepted the publish, then fail it while the
// sole worker is still in the preceding handler and cannot dequeue it.
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() != 2 {
if time.Now().After(deadline) {
t.Fatal("publish was not accepted by the read loop")
}
runtime.Gosched()
}
if err := serverWriter.Close(); err != nil {
t.Fatal(err)
}
// Fresh budget: the wait above may have consumed most of the previous one,
// which would make this loop fail on nothing worse than slow scheduling.
deadline = time.Now().Add(time.Second)
for !client.IsClosed() {
if time.Now().After(deadline) {
t.Fatal("transport EOF did not close client")
}
runtime.Gosched()
}
close(releaseBlock)
for _, want := range []string{"block", "textDocument/publishDiagnostics"} {
select {
case got := <-handled:
if got != want {
t.Fatalf("handled %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("accepted notification %q was not delivered after EOF", want)
}
}
select {
case <-client.notificationDrained:
case <-time.After(time.Second):
t.Fatal("notification worker did not report the accepted queue drained")
}
}
// TestReceiptStampedAtReadNotEnqueue is the regression test for the review gap
// on #759 (pullrequestreview-4834747507): TestPublishBaselineRejectsFrameReadBeforeBaseline
// calls stampReceipt itself and never drives readLoop, so it pins the seq
// comparison but not WHERE the stamp is taken — a refactor that moved stamping
// back to the enqueue call site (reverting the read-time fix) left that test
// green.
//
// This test distinguishes the two call sites directly: a response frame is
// read off the wire but never reaches enqueueNotification (see the `hasID`
// branch in readLoop). Under read-time stamping every frame consumes a receipt
// number — "gaps in it are fine", per stampReceipt's doc comment — so
// ReceiptSeq must advance even though nothing was queued. Under enqueue-time
// stamping it would not, since stampReceipt would never run for this frame.
func TestReceiptStampedAtReadNotEnqueue(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
defer serverWriter.Close()
// A plain response frame: has an id, no method, so it is delivered via
// c.deliver and never passed to enqueueNotification.
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "id": 1, "result": nil,
}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() == 0 {
if time.Now().After(deadline) {
t.Fatal("ReceiptSeq did not advance for a frame that was read but never enqueued as a notification")
}
runtime.Gosched()
}
}
// TestReadMessageRejectsOversizedFrame covers jatmn's P3 finding on #759: the
// notification byte budget was enforced only at enqueue, but readMessage
// allocates the whole body before anything can tell a notification from a
// response. A single frame declaring a huge Content-Length was therefore
// materialized in full and only then measured against a budget it had already
// blown past. The header is now rejected before the allocation.
func TestReadMessageRejectsOversizedFrame(t *testing.T) {
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", maxFrameBytes+1)
_, err := readMessage(bufio.NewReader(strings.NewReader(header)))
if err == nil {
t.Fatal("an oversized frame must be rejected, not allocated and read")
}
if !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("error = %v, want the frame-size limit to reject it before reading the body", err)
}
// A frame at the limit is still legal: the cap rejects what is over it, not
// what merely approaches it.
body := strings.Repeat("x", 16)
atLimit := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body)
if _, err := readMessage(bufio.NewReader(strings.NewReader(atLimit))); err != nil {
t.Fatalf("readMessage of an ordinary frame: %v", err)
}
}
// TestClientFailsOnNotificationBacklogOverload is the regression test for
// jatmn's #759 P2 finding: the lossless queue above had no failure policy — a
// language server sustaining a higher notification rate than the single
// handler can drain (no permanently-stuck handler required, just a sustained
// producer faster than the consumer) would retain every full json.RawMessage
// payload on Zero's heap without bound. Hitting notifyQueueLimit must fail
// (and close) the client observably instead of continuing to grow.
func TestClientFailsOnNotificationBacklogOverload(t *testing.T) {
client := &Client{
notifyReady: make(chan struct{}, 1),
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
for i := 0; i < notifyQueueLimit; i++ {
client.enqueueNotification(notification{method: "spam"})
}
if client.IsClosed() {
t.Fatal("client closed before the backlog limit was reached")
}
client.notifyMu.Lock()
queued := len(client.notifyQueue)
client.notifyMu.Unlock()
if queued != notifyQueueLimit {
t.Fatalf("queued = %d, want %d before the limit is exceeded", queued, notifyQueueLimit)
}
// One push past the limit must fail the client rather than growing the
// queue further.
client.enqueueNotification(notification{method: "spam"})
if !client.IsClosed() {
t.Fatal("client must be closed once the notification backlog exceeds notifyQueueLimit")
}
client.notifyMu.Lock()
queuedAfter := len(client.notifyQueue)
bytesAfter := client.notifyBytes
client.notifyMu.Unlock()
if queuedAfter != notifyQueueLimit {
t.Fatalf("closed client retained %d accepted notifications, want %d pending worker drain", queuedAfter, notifyQueueLimit)
}
if bytesAfter == 0 {
t.Fatal("accepted notifications were discarded instead of left for worker drain")
}
}
func TestClientFailsOnNotificationBacklogByteOverload(t *testing.T) {
client := &Client{
notifyReady: make(chan struct{}, 1),
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
const messageCount = 16
const method = "spam"
for i := 0; i < messageCount; i++ {
client.enqueueNotification(notification{
method: method,
params: make(json.RawMessage, notifyQueueByteLimit/messageCount-len(method)),
})
}
if client.IsClosed() {
t.Fatal("client closed before the notification byte limit was exceeded")
}
client.notifyMu.Lock()
queued := len(client.notifyQueue)
queuedBytes := client.notifyBytes
client.notifyMu.Unlock()
if queued != messageCount {
t.Fatalf("queued = %d, want %d at the byte limit", queued, messageCount)
}
if queuedBytes != notifyQueueByteLimit {
t.Fatalf("queued bytes = %d, want limit %d", queuedBytes, notifyQueueByteLimit)
}
client.enqueueNotification(notification{method: "x"})
if !client.IsClosed() {
t.Fatal("client must close before retaining notification bytes past the limit")
}
client.notifyMu.Lock()
queuedAfter := len(client.notifyQueue)
bytesAfter := client.notifyBytes
client.notifyMu.Unlock()
if queuedAfter != messageCount || bytesAfter != notifyQueueByteLimit {
t.Fatalf("accepted backlog changed during close: %d notifications and %d accounted bytes", queuedAfter, bytesAfter)
}
}
func TestClientRejectsCallsAfterClose(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
@@ -243,3 +677,79 @@ func TestClientRejectsCallsAfterClose(t *testing.T) {
t.Fatal("Notify after Close must return an error")
}
}
// TestClientDropsNotificationsAfterClose covers the shutdown path: Close stops
// the worker loop, but the read loop keeps reading until the transport ends —
// Server.Shutdown closes the client BEFORE closing stdin, so a server emitting
// notifications while it handles shutdown/exit would otherwise pile them into a
// queue nobody drains. Nothing may be handled or retained after Close.
func TestClientDropsNotificationsAfterClose(t *testing.T) {
clientReader, serverWriter := io.Pipe()
serverReader, clientWriter := io.Pipe()
client := NewClient(clientReader, clientWriter)
defer serverWriter.Close()
defer clientWriter.Close()
go func() {
// Drain anything the client writes so no write can block the test.
_, _ = io.Copy(io.Discard, serverReader)
}()
handled := make(chan string, 4)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
handled <- method
})
// A notification before Close still reaches the handler.
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "before-close"}); err != nil {
t.Fatal(err)
}
select {
case method := <-handled:
if method != "before-close" {
t.Fatalf("handled %q, want before-close", method)
}
case <-time.After(2 * time.Second):
t.Fatal("notification before Close was not handled")
}
if err := client.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// The transport stays writable, exactly as it does between Client.Close and
// stdin.Close in Server.Shutdown.
for i := 0; i < 64; i++ {
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0",
"method": fmt.Sprintf("after-close-%03d", i),
}); err != nil {
t.Fatalf("write notification after Close: %v", err)
}
}
select {
case method := <-handled:
t.Fatalf("handler ran for %q after Close", method)
case <-time.After(200 * time.Millisecond):
}
// Give the read loop time to consume every frame it was handed, then require
// the queue to hold nothing: dropping on enqueue is what keeps a closed client
// from growing for as long as its reader stays open.
deadline := time.Now().Add(2 * time.Second)
for {
client.notifyMu.Lock()
queued := len(client.notifyQueue)
closed := client.notifyClosed
client.notifyMu.Unlock()
if !closed {
t.Fatal("Close did not mark the notification queue closed")
}
if queued != 0 {
t.Fatalf("closed client retained %d queued notifications", queued)
}
if time.Now().After(deadline) {
return
}
time.Sleep(10 * time.Millisecond)
}
}
+135 -34
View File
@@ -20,34 +20,39 @@ type lspServer interface {
type session struct {
server lspServer
client *Client
// catchUpGrace bounds catchUpNotifications on a live client. Zero means "do
// not wait", which is what the hand-built sessions in tests get by default.
catchUpGrace time.Duration
mu sync.Mutex
open map[string]bool // uri -> didOpen sent
versions map[string]int // uri -> current (committed) version
diagnostics map[string][]Diagnostic // uri -> latest published diagnostics
lastPublish map[string]time.Time // uri -> time of last publish
publishCount map[string]int // uri -> monotonic publish count
waiters map[string][]chan struct{} // uri -> goroutines awaiting the next publish
fileLocks map[string]*sync.Mutex // uri -> per-document sync serializer
mu sync.Mutex
open map[string]bool // uri -> didOpen sent
versions map[string]int // uri -> current (committed) version
diagnostics map[string][]Diagnostic // uri -> latest published diagnostics
lastPublish map[string]time.Time // uri -> time of last publish
publishSeq map[string]int64 // uri -> receipt seq of latest publish (see Client.ReceiptSeq)
waiters map[string][]chan struct{} // uri -> goroutines awaiting the next publish
fileLocks map[string]*sync.Mutex // uri -> per-document sync serializer
}
func newSession(server lspServer) *session {
s := &session{
server: server,
client: server.Client(),
open: map[string]bool{},
versions: map[string]int{},
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishCount: map[string]int{},
waiters: map[string][]chan struct{}{},
fileLocks: map[string]*sync.Mutex{},
catchUpGrace: notificationCatchUpGrace,
open: map[string]bool{},
versions: map[string]int{},
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
fileLocks: map[string]*sync.Mutex{},
}
s.client.SetNotificationHandler(s.handleNotification)
return s
}
func (s *session) handleNotification(method string, params json.RawMessage) {
func (s *session) handleNotification(method string, params json.RawMessage, seq int64) {
if method != "textDocument/publishDiagnostics" {
return
}
@@ -66,7 +71,7 @@ func (s *session) handleNotification(method string, params json.RawMessage) {
}
s.diagnostics[payload.URI] = payload.Diagnostics
s.lastPublish[payload.URI] = time.Now()
s.publishCount[payload.URI]++
s.publishSeq[payload.URI] = seq
waiters := s.waiters[payload.URI]
delete(s.waiters, payload.URI)
s.mu.Unlock()
@@ -133,36 +138,65 @@ func (s *session) diagnosticsFor(uri string) []Diagnostic {
return append([]Diagnostic(nil), s.diagnostics[uri]...)
}
// publishBaseline snapshots the current publish count for a URI, captured before
// a sync so waitForDiagnostics can wait specifically for the publish that sync
// triggers (not be satisfied by a stale earlier publish).
func (s *session) publishBaseline(uri string) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.publishCount[uri]
// publishBaseline snapshots the client's current notification receipt
// sequence, captured before a sync so waitForDiagnostics can require a publish
// that was RECEIVED after this point. This must baseline against receipt, not
// against session.publishSeq (which only advances once handleNotification
// actually runs): dispatch happens off a queue, so a publish for a since-
// superseded version can already be sitting in that queue, still unprocessed,
// at the moment a later Check captures its baseline. If baseline were instead
// "how many publishes has this session recorded so far", that stale publish
// would land afterward, still satisfy "more than baseline", and hand the new
// check diagnostics for the wrong text — silently, since many servers omit the
// version field the staleness check in handleNotification relies on.
// Baselining against receipt sequence closes that: a notification whose frame
// had already come off the wire before this call has seq <= baseline no matter
// when it is decoded, queued, or handled.
func (s *session) publishBaseline() int64 {
return s.client.ReceiptSeq()
}
// notificationCatchUpGrace is the live-client catch-up budget a real session
// runs with. It exists for the case where the caller's own deadline has already
// passed: dropping a publish that is sitting accepted-but-unhandled in the queue
// would report "no diagnostics" for text the server had in fact already answered
// for, so a brief overrun is preferable to discarding it. It stays short because
// the caller is by then out of budget, and it costs nothing in the ordinary case
// — the session's own handler only parses and records, so the worker is
// virtually always caught up already and no wait happens at all. A closed client
// is deliberately not bounded by this; see catchUpNotifications.
const notificationCatchUpGrace = 50 * time.Millisecond
// waitForDiagnostics blocks until a publish newer than baseline arrives for the
// URI and the server then goes quiet for the debounce window, or until ctx is
// done. It returns true when a fresh publish was observed and false when ctx
// expired before any did — so a caller can avoid returning a stale prior result
// for newer text. Servers don't signal "analysis complete", so the debounce
// approximates it: once a fresh publish lands, wait debounce for a follow-up,
// resetting on each new publish.
func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce time.Duration, baseline int) bool {
// done or the client closes. It returns true when a fresh publish was observed
// and false when the wait ended before any did — so a caller can avoid returning
// a stale prior result for newer text. Servers don't signal "analysis complete",
// so the debounce approximates it: once a fresh publish lands, wait debounce for
// a follow-up, resetting on each new publish.
func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce time.Duration, baseline int64) bool {
for {
s.mu.Lock()
ch := make(chan struct{})
s.waiters[uri] = append(s.waiters[uri], ch)
count := s.publishCount[uri]
seq := s.publishSeq[uri]
last := s.lastPublish[uri]
s.mu.Unlock()
if count <= baseline {
if seq <= baseline {
select {
case <-ctx.Done():
s.cancelWaiter(uri, ch)
return false // no fresh publish for this sync arrived in time
// Cancellation, like closure below, ends the wait on a signal that
// says nothing about what the server sent, and either can win a race
// against a publish this session has already received. Deciding
// "nothing arrived" straight out of the select would discard it, so
// both settle identically: let the worker finish what it accepted,
// then re-read what got recorded.
return s.freshAfterCatchUp(uri, baseline)
case <-s.client.closed:
s.cancelWaiter(uri, ch)
return s.freshAfterCatchUp(uri, baseline)
case <-ch:
continue // a fresh publish arrived; loop into the debounce check
}
@@ -171,6 +205,7 @@ func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce t
remaining := debounce - time.Since(last)
if remaining <= 0 {
s.cancelWaiter(uri, ch)
s.catchUpNotifications()
return true
}
timer := time.NewTimer(remaining)
@@ -178,17 +213,83 @@ func (s *session) waitForDiagnostics(ctx context.Context, uri string, debounce t
case <-ctx.Done():
timer.Stop()
s.cancelWaiter(uri, ch)
return true // a fresh publish did arrive; ctx merely cut the debounce short
// A fresh publish did arrive; ctx merely cut the debounce short. Catch
// the worker up regardless: the caller reads diagnosticsFor the moment
// this returns, and a newer publish for this URI may be sitting accepted
// but unhandled — it should get the newest received, not the older one
// that happened to wake this wait.
s.catchUpNotifications()
return true
case <-s.client.closed:
timer.Stop()
s.cancelWaiter(uri, ch)
// Same, for whatever the server managed to publish before it died.
s.catchUpNotifications()
return true // preserve the fresh publish already received before failure
case <-ch:
timer.Stop()
continue // a newer publish arrived; re-arm the debounce
case <-timer.C:
s.cancelWaiter(uri, ch)
s.catchUpNotifications()
return true // quiet for the full window
}
}
}
// freshAfterCatchUp reports whether a publish received after baseline has been
// recorded for the URI, having first let the notification worker finish frames
// it already accepted. Receipt is what the answer turns on: a publish that was
// read off the wire but is still queued would otherwise read as "never arrived".
func (s *session) freshAfterCatchUp(uri string, baseline int64) bool {
s.catchUpNotifications()
s.mu.Lock()
defer s.mu.Unlock()
return s.publishSeq[uri] > baseline
}
// catchUpNotifications waits for the client's notification worker to handle
// every notification it had already accepted, so a decision made right after
// cannot miss a publish that was on the wire before it. It returns immediately
// in the ordinary case, where the worker is already caught up.
func (s *session) catchUpNotifications() {
drained := s.client.notificationsDone()
if drained == nil {
return // hand-built client (some unit tests): no worker to catch up on
}
if s.client.IsClosed() {
// Wait the worker out instead of time-boxing it. Admission is already
// closed so the backlog cannot grow, and a handler's re-entrant Call now
// fails immediately rather than blocking on a response that will never
// come — the drain is bounded, and it is the last chance these frames get.
<-drained
return
}
timer := time.NewTimer(s.catchUpGrace)
defer timer.Stop()
for {
// Re-read the target every pass rather than snapshotting it once. The
// reader keeps running while this waits, so a snapshot goes stale: the
// worker finishing the item that was last when we started satisfies
// `handled >= target` while a newer publish for the same URI sits queued,
// and the caller then answers from diagnosticsFor with the older one —
// the exact failure the closed-client drain exists to prevent, on the
// live path. Growth is not unbounded: the grace below caps the wait no
// matter how fast the server publishes.
handled, advanced := s.client.handledThrough()
if handled >= s.client.acceptedNotificationSeq() {
return
}
select {
case <-advanced:
case <-drained:
return
case <-timer.C:
return
}
}
}
// cancelWaiter removes a still-open waiter (one a publish has not already closed
// and cleared) so it can't leak or be closed twice.
func (s *session) cancelWaiter(uri string, target chan struct{}) {
+1 -1
View File
@@ -73,7 +73,7 @@ func (m *Manager) Check(ctx context.Context, path, text string) ([]Diagnostic, e
}
return nil, err
}
baseline := sess.publishBaseline(uri)
baseline := sess.publishBaseline()
if err := sess.sync(ctx, abs, languageID, text); err != nil {
return nil, err
}
+733 -2
View File
@@ -7,6 +7,7 @@ import (
"errors"
"io"
"os/exec"
"runtime"
"strings"
"sync"
"testing"
@@ -228,18 +229,748 @@ func TestSessionDropsStaleVersionPublish(t *testing.T) {
sess.mu.Unlock()
stale, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Version: 2, Diagnostics: []Diagnostic{{Message: "stale"}}})
sess.handleNotification("textDocument/publishDiagnostics", stale)
sess.handleNotification("textDocument/publishDiagnostics", stale, 1)
if len(sess.diagnosticsFor(uri)) != 0 {
t.Fatal("a publish for an older version must be ignored")
}
fresh, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Version: 3, Diagnostics: []Diagnostic{{Message: "fresh"}}})
sess.handleNotification("textDocument/publishDiagnostics", fresh)
sess.handleNotification("textDocument/publishDiagnostics", fresh, 2)
if d := sess.diagnosticsFor(uri); len(d) != 1 || d[0].Message != "fresh" {
t.Fatalf("a current-version publish must apply, got %#v", d)
}
}
// TestPublishBaselineRejectsAlreadyQueuedPublish is the regression test for
// jatmn's #759 P1 finding: publishBaseline used to snapshot how many publishes
// session.handleNotification had already RUN for a URI. Dispatch happens off a
// queue, though, so a publish for a version Check is about to supersede can
// already be sitting RECEIVED but undispatched at the moment a later Check
// captures its baseline — then run only afterward, incrementing the old
// count-based baseline and wrongly looking "new". Since many servers omit the
// version field, handleNotification's own staleness check (which only fires
// for a positive version) can't catch this either, so the stale result would
// reach the caller for the new text, and the debounce could finish before the
// real response even arrives.
//
// This drives the exact interleaving through the real production path: the
// stale publish is enqueued first (fixing its receipt seq), THEN a baseline is
// captured, THEN the stale item is dispatched — reproducing "received before
// baseline, handled after" without depending on goroutine scheduling luck. A
// bare Client (no background loops, like TestClientNotificationQueueIsLossless
// uses) makes the enqueue/dequeue ordering fully explicit.
func TestPublishBaselineRejectsAlreadyQueuedPublish(t *testing.T) {
client := &Client{notifyReady: make(chan struct{}, 1)}
sess := &session{
client: client,
versions: map[string]int{},
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
uri := PathToURI("/repo/main.go")
// The stale (version-less — the common case) publish is RECEIVED first: the
// read loop stamps a frame's receipt before it decodes or queues it, so the
// stamp here comes first too.
stale, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "stale"}}})
client.enqueueNotification(notification{
method: "textDocument/publishDiagnostics", params: stale, seq: client.stampReceipt(),
})
// A later Check captures its baseline only now — after the stale publish's
// receipt, exactly as publishBaseline does between two Checks whose
// notification queue has backed up.
baseline := sess.publishBaseline()
// Dispatch the stale item exactly as notificationLoop would: dequeue and
// call the handler with the receipt seq it was actually stamped with.
item, ok, _ := client.dequeueNotification()
if !ok {
t.Fatal("stale notification was not queued")
}
sess.handleNotification(item.method, item.params, item.seq)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if sess.waitForDiagnostics(ctx, uri, 10*time.Millisecond, baseline) {
t.Fatal("a publish received before baseline must not satisfy waitForDiagnostics merely because it was handled afterward")
}
// The real response — received (and handled) after baseline — must satisfy it.
fresh, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "fresh"}}})
client.enqueueNotification(notification{
method: "textDocument/publishDiagnostics", params: fresh, seq: client.stampReceipt(),
})
item2, ok, _ := client.dequeueNotification()
if !ok {
t.Fatal("fresh notification was not queued")
}
sess.handleNotification(item2.method, item2.params, item2.seq)
ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second)
defer cancel2()
if !sess.waitForDiagnostics(ctx2, uri, 10*time.Millisecond, baseline) {
t.Fatal("a publish received after baseline must satisfy waitForDiagnostics")
}
if d := sess.diagnosticsFor(uri); len(d) != 1 || d[0].Message != "fresh" {
t.Fatalf("diagnostics = %#v, want the fresh publish", d)
}
}
// TestPublishBaselineRejectsFrameReadBeforeBaseline is the regression test for
// jatmn's follow-up P1 finding on #759: stamping receipt at ENQUEUE left a
// window async dispatch had opened. The read loop consumes a frame, then decodes
// it (a full json.Unmarshal of a peer-sized payload) and only then enqueues it,
// all while another goroutine can capture a baseline. A publishDiagnostics for
// text the new Check is about to supersede could therefore be numbered above a
// baseline taken after it had already come off the wire, and — since most
// servers omit the version field that handleNotification's staleness check needs
// — reach the caller as if it answered the new text.
//
// The interleaving is driven at the seam itself: stamp (the frame leaves the
// wire), baseline (a concurrent Check), then enqueue and dispatch (the read loop
// finishes decoding). Stamping at enqueue put this publish above the baseline;
// stamping at read puts it at or below.
func TestPublishBaselineRejectsFrameReadBeforeBaseline(t *testing.T) {
client := &Client{notifyReady: make(chan struct{}, 1)}
sess := &session{
client: client,
versions: map[string]int{},
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
uri := PathToURI("/repo/main.go")
// The read loop pulls the stale frame off the wire and stamps it...
seq := client.stampReceipt()
// ...a concurrent Check baselines while that frame is still being decoded...
baseline := sess.publishBaseline()
// ...and only now does the frame reach the queue and the handler.
stale, _ := json.Marshal(PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "stale"}}})
client.enqueueNotification(notification{
method: "textDocument/publishDiagnostics", params: stale, seq: seq,
})
item, ok, _ := client.dequeueNotification()
if !ok {
t.Fatal("stale notification was not queued")
}
sess.handleNotification(item.method, item.params, item.seq)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if sess.waitForDiagnostics(ctx, uri, 10*time.Millisecond, baseline) {
t.Fatal("a publish whose frame was read before baseline must not satisfy waitForDiagnostics merely because it was decoded and queued afterward")
}
if d := sess.diagnosticsFor(uri); len(d) != 1 || d[0].Message != "stale" {
t.Fatalf("diagnostics = %#v, want the stale publish still recorded (rejected as an answer, not dropped)", d)
}
}
func TestWaitForDiagnosticsReturnsWhenClientCloses(t *testing.T) {
client := &Client{
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
waitDone := make(chan bool, 1)
go func() {
waitDone <- sess.waitForDiagnostics(context.Background(), uri, time.Second, 0)
}()
deadline := time.Now().Add(time.Second)
for {
sess.mu.Lock()
waiting := len(sess.waiters[uri]) == 1
sess.mu.Unlock()
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("diagnostic wait was not registered")
}
time.Sleep(time.Millisecond)
}
client.failPending(errors.New("notification backlog exceeded"))
select {
case fresh := <-waitDone:
if fresh {
t.Fatal("client failure without a fresh publish must not report fresh diagnostics")
}
case <-time.After(time.Second):
t.Fatal("diagnostic wait did not wake when the client failed")
}
sess.mu.Lock()
waiters := len(sess.waiters[uri])
sess.mu.Unlock()
if waiters != 0 {
t.Fatalf("client failure left %d diagnostic waiters registered", waiters)
}
}
func TestWaitForDiagnosticsPreservesPublishHandledBeforeClientCloses(t *testing.T) {
// Keep the test goroutine running while it closes both signals so the waiter
// observes the publish and client closure as simultaneously ready. Without
// re-checking publishSeq, select could randomly choose closure and discard the
// fresh diagnostics that handleNotification had already recorded.
previousProcs := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(previousProcs)
params, err := json.Marshal(PublishDiagnosticsParams{
URI: PathToURI("/repo/main.go"),
Diagnostics: []Diagnostic{{Message: "fresh"}},
})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
client := &Client{
pending: make(map[int64]chan rpcResponse),
closed: make(chan struct{}),
}
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
waitDone := make(chan bool, 1)
go func() {
waitDone <- sess.waitForDiagnostics(context.Background(), uri, time.Second, 0)
}()
deadline := time.Now().Add(time.Second)
for {
sess.mu.Lock()
waiting := len(sess.waiters[uri]) == 1
sess.mu.Unlock()
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("diagnostic wait was not registered")
}
time.Sleep(time.Millisecond)
}
sess.handleNotification("textDocument/publishDiagnostics", params, 1)
client.failPending(errors.New("server exited after publishing"))
select {
case fresh := <-waitDone:
if !fresh {
t.Fatalf("iteration %d discarded diagnostics handled before client closure", i)
}
case <-time.After(time.Second):
t.Fatal("diagnostic wait did not wake when the client failed")
}
}
}
func TestWaitForDiagnosticsDrainsAcceptedPublishAfterTransportEOF(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
blocked := make(chan struct{})
release := make(chan struct{})
client.SetNotificationHandler(func(method string, params json.RawMessage, seq int64) {
if method == "test/block" {
close(blocked)
<-release
return
}
sess.handleNotification(method, params, seq)
})
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
params := PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "fresh"}}}
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params,
}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() != 2 {
if time.Now().After(deadline) {
t.Fatal("publish was not accepted before EOF")
}
runtime.Gosched()
}
waitDone := make(chan bool, 1)
go func() { waitDone <- sess.waitForDiagnostics(context.Background(), uri, 0, 0) }()
if err := serverWriter.Close(); err != nil {
t.Fatal(err)
}
// Fresh budget: the wait above may have consumed most of the previous one,
// which would make this loop fail on nothing worse than slow scheduling.
deadline = time.Now().Add(time.Second)
for !client.IsClosed() {
if time.Now().After(deadline) {
t.Fatal("transport EOF did not close client")
}
runtime.Gosched()
}
select {
case fresh := <-waitDone:
t.Fatalf("wait returned %v before accepted notifications drained", fresh)
case <-time.After(25 * time.Millisecond):
}
close(release)
select {
case fresh := <-waitDone:
if !fresh {
t.Fatal("wait missed publish accepted before transport EOF")
}
case <-time.After(time.Second):
t.Fatal("wait did not finish after notification drain")
}
if got := sess.diagnosticsFor(uri); len(got) != 1 || got[0].Message != "fresh" {
t.Fatalf("diagnostics = %#v, want fresh publish", got)
}
}
// TestWaitForDiagnosticsCatchesUpOnContextCancellation covers jatmn's P2 finding
// (1): the cancellation arm used to re-check only what the handler had already
// RUN. A publishDiagnostics accepted off the wire but still queued behind a busy
// worker therefore read as "never arrived", and Manager.Check returned (nil,
// nil) for text the server had in fact answered. Cancellation now settles like
// closure does — let the worker finish what it accepted, then re-read.
// TestCatchUpNotificationsFollowsPublishesAcceptedWhileWaiting covers jatmn's
// #759 finding: catch-up snapshotted the accepted sequence once, so a publish
// the reader accepted DURING the wait was not waited for. The worker finishing
// the stale target satisfied the check, and the caller then read diagnosticsFor
// while a newer publish for the same URI was still queued — the same
// "newest received, not the one that woke the wait" failure the closed-client
// drain regression was written for, on the live path.
func TestCatchUpNotificationsFollowsPublishesAcceptedWhileWaiting(t *testing.T) {
clientReader, serverWriter := io.Pipe()
defer serverWriter.Close()
client := NewClient(clientReader, io.Discard)
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
catchUpGrace: 5 * time.Second,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
publish := func(message string) {
t.Helper()
params := PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: message}}}
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params,
}); err != nil {
t.Error(err)
}
}
blocked := make(chan struct{})
release := make(chan struct{})
secondEntered := make(chan struct{})
releaseSecond := make(chan struct{})
client.SetNotificationHandler(func(method string, params json.RawMessage, seq int64) {
switch {
case method == "test/block":
close(blocked)
<-release
return
case strings.Contains(string(params), `"first"`):
// Handling "first" is what makes "second" arrive mid-catch-up: it goes
// on the wire and is confirmed accepted before this handler returns, so
// the worker's handled count only reaches "first" once a newer item is
// already queued behind it.
publish("second")
deadline := time.Now().Add(time.Second)
for client.acceptedNotificationSeq() < 3 {
if time.Now().After(deadline) {
t.Error("follow-up publish was not accepted while the worker was busy")
break
}
runtime.Gosched()
}
case strings.Contains(string(params), `"second"`):
// Hold the worker inside this handler so the assertion below observes
// the state the bug produced: caught up by the stale target, with the
// newer publish still unrecorded.
close(secondEntered)
<-releaseSecond
}
sess.handleNotification(method, params, seq)
})
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
publish("first")
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() != 2 {
if time.Now().After(deadline) {
t.Fatal("first publish was not accepted")
}
runtime.Gosched()
}
done := make(chan struct{})
go func() {
sess.catchUpNotifications()
close(done)
}()
// Catch-up must be parked in its wait before the worker is released, so the
// sequence it started from is the one that goes stale.
select {
case <-done:
t.Fatal("catch-up returned while the worker was still behind")
case <-time.After(25 * time.Millisecond):
}
close(release)
// The worker is now inside the handler for "second", which means "first" is
// recorded and its sequence — the one catch-up started from — is satisfied.
// A snapshot-based catch-up returns here, with the newer publish unrecorded.
select {
case <-secondEntered:
case <-time.After(3 * time.Second):
t.Fatal("worker never reached the follow-up publish")
}
select {
case <-done:
t.Fatalf("catch-up returned on a stale target; diagnostics = %#v", sess.diagnosticsFor(uri))
case <-time.After(25 * time.Millisecond):
}
close(releaseSecond)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("catch-up did not finish")
}
if got := sess.diagnosticsFor(uri); len(got) != 1 || got[0].Message != "second" {
t.Fatalf("diagnostics = %#v, want the publish accepted during catch-up", got)
}
}
// TestCatchUpNotificationsGivesUpAfterTheLiveGrace spells out the tradeoff the
// live-client timeout represents: an unbounded wait would hang Check past a
// deadline the caller has already blown, so a worker that never catches up
// costs the grace and no more. Production sessions use a 50ms grace; this uses
// the same order of magnitude rather than the generous one the other tests pin.
func TestCatchUpNotificationsGivesUpAfterTheLiveGrace(t *testing.T) {
clientReader, serverWriter := io.Pipe()
defer serverWriter.Close()
client := NewClient(clientReader, io.Discard)
sess := &session{
client: client,
catchUpGrace: 25 * time.Millisecond,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
blocked := make(chan struct{})
release := make(chan struct{})
defer close(release)
client.SetNotificationHandler(func(method string, _ json.RawMessage, _ int64) {
if method == "test/block" {
close(blocked)
<-release
}
})
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
done := make(chan struct{})
start := time.Now()
go func() {
sess.catchUpNotifications()
close(done)
}()
select {
case <-done:
if elapsed := time.Since(start); elapsed < sess.catchUpGrace {
t.Fatalf("catch-up returned after %v, before its %v grace", elapsed, sess.catchUpGrace)
}
case <-time.After(3 * time.Second):
t.Fatal("catch-up on a permanently-behind worker must give up after the grace, not hang")
}
}
func TestWaitForDiagnosticsCatchesUpOnContextCancellation(t *testing.T) {
clientReader, serverWriter := io.Pipe()
defer serverWriter.Close()
client := NewClient(clientReader, io.Discard)
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
// Generous, so the catch-up is decided by the worker rather than by how
// fast this test's goroutines happen to be scheduled.
catchUpGrace: 5 * time.Second,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
blocked := make(chan struct{})
release := make(chan struct{})
client.SetNotificationHandler(func(method string, params json.RawMessage, seq int64) {
if method == "test/block" {
close(blocked)
<-release
return
}
sess.handleNotification(method, params, seq)
})
// Occupy the sole worker, then let a publish queue up behind it.
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
params := PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: "fresh"}}}
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params,
}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for client.ReceiptSeq() != 2 {
if time.Now().After(deadline) {
t.Fatal("publish was not accepted before cancellation")
}
runtime.Gosched()
}
ctx, cancel := context.WithCancel(context.Background())
waitDone := make(chan bool, 1)
go func() { waitDone <- sess.waitForDiagnostics(ctx, uri, 0, 0) }()
deadline = time.Now().Add(time.Second)
for {
sess.mu.Lock()
waiting := len(sess.waiters[uri]) == 1
sess.mu.Unlock()
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("diagnostic wait was not registered")
}
runtime.Gosched()
}
cancel()
select {
case fresh := <-waitDone:
t.Fatalf("wait returned %v while an accepted publish was still queued", fresh)
case <-time.After(25 * time.Millisecond):
}
close(release)
select {
case fresh := <-waitDone:
if !fresh {
t.Fatal("cancellation discarded a publish that was already off the wire")
}
case <-time.After(5 * time.Second):
t.Fatal("wait did not finish after the worker caught up")
}
if got := sess.diagnosticsFor(uri); len(got) != 1 || got[0].Message != "fresh" {
t.Fatalf("diagnostics = %#v, want the publish accepted before cancellation", got)
}
}
// TestWaitForDiagnosticsDrainsFollowUpPublishOnCloseDuringDebounce covers
// jatmn's P2 finding (2): the debounce-phase closure arm returned true without
// draining, so Manager.Check read diagnosticsFor while a NEWER publish was still
// queued and handed back the older one. It also pins the drain as
// ctx-independent — cancelling mid-drain (finding (1)'s second clause) must not
// cut it short, since after closure the backlog can no longer grow.
func TestWaitForDiagnosticsDrainsFollowUpPublishOnCloseDuringDebounce(t *testing.T) {
clientReader, serverWriter := io.Pipe()
client := NewClient(clientReader, io.Discard)
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
// Zero on purpose: the post-closure drain must not be bounded by the
// live-client grace.
catchUpGrace: 0,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
blocked := make(chan struct{})
release := make(chan struct{})
client.SetNotificationHandler(func(method string, params json.RawMessage, seq int64) {
if method == "test/block" {
close(blocked)
<-release
return
}
sess.handleNotification(method, params, seq)
})
publish := func(message string) {
t.Helper()
params := PublishDiagnosticsParams{URI: uri, Diagnostics: []Diagnostic{{Message: message}}}
if err := writeMessage(serverWriter, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params,
}); err != nil {
t.Fatal(err)
}
}
// A first publish lands and is handled, so the wait below starts out in its
// debounce phase rather than waiting for a first result.
publish("first")
deadline := time.Now().Add(time.Second)
for {
if got := sess.diagnosticsFor(uri); len(got) == 1 && got[0].Message == "first" {
break
}
if time.Now().After(deadline) {
t.Fatal("first publish was never handled")
}
runtime.Gosched()
}
// Now occupy the worker and queue a newer publish behind it.
if err := writeMessage(serverWriter, map[string]any{"jsonrpc": "2.0", "method": "test/block"}); err != nil {
t.Fatal(err)
}
<-blocked
publish("second")
deadline = time.Now().Add(time.Second)
for client.ReceiptSeq() != 3 {
if time.Now().After(deadline) {
t.Fatal("follow-up publish was not accepted before closure")
}
runtime.Gosched()
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
waitDone := make(chan bool, 1)
go func() { waitDone <- sess.waitForDiagnostics(ctx, uri, 5*time.Second, 0) }()
select {
case fresh := <-waitDone:
t.Fatalf("wait returned %v instead of debouncing", fresh)
case <-time.After(25 * time.Millisecond):
}
if err := serverWriter.Close(); err != nil {
t.Fatal(err)
}
deadline = time.Now().Add(time.Second)
for !client.IsClosed() {
if time.Now().After(deadline) {
t.Fatal("transport EOF did not close client")
}
runtime.Gosched()
}
// Cancelling now must not cut the drain short.
cancel()
select {
case fresh := <-waitDone:
t.Fatalf("wait returned %v before the accepted follow-up publish drained", fresh)
case <-time.After(25 * time.Millisecond):
}
close(release)
select {
case fresh := <-waitDone:
if !fresh {
t.Fatal("wait dropped the publish it had already observed")
}
case <-time.After(time.Second):
t.Fatal("wait did not finish after the notification drain")
}
if got := sess.diagnosticsFor(uri); len(got) != 1 || got[0].Message != "second" {
t.Fatalf("diagnostics = %#v, want the follow-up publish accepted before closure", got)
}
}
func TestWaitForDiagnosticsPreservesPublishHandledAtDeadline(t *testing.T) {
previousProcs := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(previousProcs)
params, err := json.Marshal(PublishDiagnosticsParams{
URI: PathToURI("/repo/main.go"), Diagnostics: []Diagnostic{{Message: "fresh"}},
})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
client := &Client{closed: make(chan struct{})}
uri := PathToURI("/repo/main.go")
sess := &session{
client: client,
diagnostics: map[string][]Diagnostic{},
lastPublish: map[string]time.Time{},
publishSeq: map[string]int64{},
waiters: map[string][]chan struct{}{},
}
ctx, cancel := context.WithCancel(context.Background())
waitDone := make(chan bool, 1)
go func() {
waitDone <- sess.waitForDiagnostics(ctx, uri, time.Second, 0)
}()
deadline := time.Now().Add(time.Second)
for {
sess.mu.Lock()
waiting := len(sess.waiters[uri]) == 1
sess.mu.Unlock()
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("diagnostic wait was not registered")
}
runtime.Gosched()
}
// handleNotification records the publish and closes the waiter. Cancel
// before yielding under GOMAXPROCS(1), making both cases ready together.
sess.handleNotification("textDocument/publishDiagnostics", params, 1)
cancel()
select {
case fresh := <-waitDone:
if !fresh {
t.Fatalf("iteration %d: deadline discarded a publish handled before cancellation", i)
}
case <-time.After(time.Second):
t.Fatalf("iteration %d: wait did not return", i)
}
}
}
func TestManagerCheckDegradesWhenServerBinaryMissing(t *testing.T) {
// A configured extension whose binary isn't on PATH (exec.ErrNotFound) must
// degrade to no diagnostics, exactly like an unsupported extension.
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff. Show more