Compare commits

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

These five functions are unreachable from any cmd/* main even when tests are included (deadcode -test ./...), so they are genuinely dead on every platform:

- TraceEventKeys: one-line alias of ParseTraceEventKeys, no callers
- ValidateToolCapabilities: one-line alias of ValidateCapabilities, no callers
- GetLocalDiffStats and WatchPRState: test seams in internal/tui/export_test.go that no test calls
- ScanSSEData: non-context SSE scanner in internal/providers/providerio, fully superseded by ScanSSEDataWithContext

The five remaining -test findings are //go:build !linux stubs (landlock/seccomp/linux_helper) that are live on Linux, so they are left in place.

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

* Bump Go to 1.26.6 so govulncheck and Windows smoke pass.

The 1.26.5 standard library still reports GO-2026-6218 and the Punycode
label check that fail required CI; 1.26.6 matches main and clears them.

---------

Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-18 21:44:13 +08:00
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
216 changed files with 25713 additions and 2741 deletions

No files matched your search

+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
+40
View File
@@ -70,3 +70,43 @@ Endpoint resolution order:
Installer scripts download the matching release asset for the local platform and
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.
+3 -2
View File
@@ -1,6 +1,6 @@
module github.com/Gitlawb/zero
go 1.26.5
go 1.26.6
require (
charm.land/bubbles/v2 v2.1.1
@@ -11,16 +11,17 @@ require (
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
+4
View File
@@ -64,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=
+1 -1
View File
@@ -429,7 +429,7 @@ func newCompactionState(options Options, task *taskState) *compactionState {
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
}
return state
+2
View File
@@ -9,6 +9,7 @@ import (
type contextPlannerConfig struct {
contextWindow int
promptCacheKey string
serviceTier string
promptParts systemPromptParts
}
@@ -62,6 +63,7 @@ func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []z
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)
+2
View File
@@ -25,6 +25,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{
contextWindow: 128_000,
promptCacheKey: "session-1",
serviceTier: "priority",
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
})
@@ -33,6 +34,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
Messages: copyMessages(messages),
Tools: toolDefs,
ReasoningEffort: "medium",
ServiceTier: "priority",
PromptCacheKey: "session-1",
}
if !reflect.DeepEqual(plan.Request, want) {
+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)
}
+13 -3
View File
@@ -186,6 +186,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
planner := newContextPlanner(contextPlannerConfig{
contextWindow: options.ContextWindow,
promptCacheKey: options.SessionID,
serviceTier: options.ServiceTier,
promptParts: promptParts,
})
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
@@ -829,6 +830,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
@@ -3081,13 +3087,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
// Deferral may activate only when tool_search is actually runnable; otherwise
// the loop would hide deferred tools behind a loader the dispatch gate rejects
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
// registered, not in DisabledTools, and advertised in the current permission
// mode (e.g. not spec-draft, where tool_search is not advertised). The
// registered, model-visible, not in DisabledTools, and advertised in the
// current permission mode (e.g. not spec-draft, where tool_search is not
// advertised). The
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
// exempt from the allowlist at dispatch, so an allowlist that omits it must
// not disable deferral.
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
loaderUsable := loaderFound &&
tools.ModelVisible(loader) &&
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
ToolAdvertised(loader, permissionMode)
@@ -3202,7 +3210,9 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
return tools.ModelVisible(tool) &&
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
ToolAdvertised(tool, permissionMode)
}
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
+18
View File
@@ -67,3 +67,21 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
}
}
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
names := make(map[string]bool, len(exposed))
for _, definition := range exposed {
names[definition.Name] = true
}
if !names["apply_patch"] {
t.Fatal("agent must retain apply_patch for existing-file changes")
}
if names["edit_file"] {
t.Fatal("agent must not receive the ambiguous string-replacement tool")
}
}
+7 -6
View File
@@ -52,7 +52,7 @@ work.
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, edit_file, apply_patch - over shelling out to
write_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
@@ -61,10 +61,11 @@ work.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
and enough unchanged context to identify the intended location. Match the
existing indentation, imports, and idioms. Match the file's comment density:
do not add explanatory comments unless the user asks or the code is already
comment-dense.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
@@ -104,7 +105,7 @@ work.
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/edit_file/apply_patch for file changes.
with exec_command, or use write_file/apply_patch for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
+2 -2
View File
@@ -60,14 +60,14 @@ const openAIPromptAddendum = `<model_guidance>
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
write_file, apply_patch) over shelling out to cat/sed/awk/python for
file work. Make one tool call per file; do not batch file writes into a script.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
const geminiPromptAddendum = `<model_guidance>
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
+1 -1
View File
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"inspect the target file",
"plan then act",
"choose the narrowest tool",
"prefer edit_file or apply_patch",
"for edits to existing files, prefer apply_patch",
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
+1
View File
@@ -325,6 +325,7 @@ type Options struct {
ProviderName string
Model string
ReasoningEffort string
ServiceTier string
Cwd string
SystemPrompt string
// TransientSystemPrompt adds trusted runtime guidance for this run only.
-4
View File
@@ -29,10 +29,6 @@ func ParseTraceEventKeys(stdout string) []string {
return keys
}
func TraceEventKeys(stdout string) []string {
return ParseTraceEventKeys(stdout)
}
func MissingTraceEvents(required []string, stdout string) []string {
seen := map[string]bool{}
for _, key := range ParseTraceEventKeys(stdout) {
+32 -1
View File
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
}
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
if err != nil {
return Task{}, false, err
var migrated bool
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
if !migrated {
return Task{}, false, err
}
changed = true
}
if outputFile != task.OutputFile {
changed = true
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
return path, nil
}
// migrateLegacyOutputFile recovers task metadata written under a previous home
// or XDG data root. It accepts only the canonical per-task output
// name in the historic zero/background layout, and only when the corresponding
// regular file already exists inside the active manager root.
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
requested = strings.TrimSpace(requested)
if !filepath.IsAbs(requested) {
return "", false
}
requested = filepath.Clean(requested)
expectedName := taskID + ".ndjson"
if filepath.Base(requested) != expectedName {
return "", false
}
legacyRoot := filepath.Dir(requested)
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
return "", false
}
canonical := filepath.Join(manager.rootDir, expectedName)
info, err := os.Lstat(canonical)
if err != nil || !info.Mode().IsRegular() {
return "", false
}
return canonical, true
}
func validTaskID(taskID string) bool {
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
}
+83
View File
@@ -1,6 +1,7 @@
package background
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -191,6 +192,88 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
}
}
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
canonicalOutput := filepath.Join(root, taskID+".ndjson")
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: legacyOutput,
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal legacy metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write legacy metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
task, ok := manager.Get(taskID)
if !ok {
t.Fatal("manager did not load migrated task")
}
if task.OutputFile != canonicalOutput {
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
}
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
t.Fatalf("migration produced load warnings: %#v", warnings)
}
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
if err != nil {
t.Fatalf("read migrated metadata: %v", err)
}
var saved Task
if err := json.Unmarshal(persisted, &saved); err != nil {
t.Fatalf("decode migrated metadata: %v", err)
}
if saved.OutputFile != canonicalOutput {
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
}
}
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
if _, ok := manager.Get(taskID); ok {
t.Fatal("manager loaded task with an unrelated absolute output path")
}
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
t.Fatalf("load warnings = %q, want containment rejection", warnings)
}
}
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
root := t.TempDir()
manager, err := NewManager(root)
+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)
}
}
+133 -37
View File
@@ -64,38 +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)
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)
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 {
@@ -198,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,
}
}
@@ -562,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
}
@@ -842,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,
+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)
}
}
+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{
File diff suppressed because it is too large. Load diff
+538 -8
View File
@@ -2,9 +2,12 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -602,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`"}
}
@@ -934,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
`)
@@ -958,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 {
@@ -1010,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 {
@@ -1100,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
}
+15 -5
View File
@@ -105,7 +105,17 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
//
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
// it, since both tests return about one WaitDelay after the fixture exits.
const providerCommandTestBudget = 60 * time.Second
const (
providerCommandTestBudget = 90 * time.Second
providerCommandReadyWaitAttempts = 45
)
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
if providerCommandTestBudget < 2*readyWaitCeiling {
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
}
}
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
// dying of old age is unobservable: any run where runProviderCommand has not
@@ -341,15 +351,15 @@ func writeCommand(t *testing.T, script commandScript) string {
// assertion fail is far more diagnosable than blocking until the
// timeout fires and reporting an unrelated error. Each iteration is
// about a second, since `ping -n 2` sleeps between its two echoes,
// so the ceiling is roughly 15s: far longer than any cold PowerShell
// start seen on a runner, and far shorter than
// providerCommandTestBudget, so giving up surfaces a legible
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
// runner while leaving the other half of providerCommandTestBudget for
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
// PID-file failure rather than a timeout that hides the real cause.
lines = append(lines,
":zeroWaitReady",
"if exist \""+readyFile+"\" goto zeroReady",
"set /a ZERO_READY_TRIES+=1",
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
"ping -n 2 127.0.0.1 >nul",
"goto zeroWaitReady",
":zeroReady",
+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)
}
})
}
}
+3
View File
@@ -273,6 +273,9 @@ 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)
+16
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.
+5 -1
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).
@@ -148,7 +151,8 @@ type KeyBindingsConfig struct {
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
// TogglePlan is retained for configuration compatibility. Plan updates render
// in the transcript and no longer have a persistent panel to toggle.
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
+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)
}
}
+4 -1
View File
@@ -29,6 +29,9 @@ const (
// newer Anthropic models). Defined so a capability catalog that lists "max"
// has a constant to map to; no curated model lists it yet.
ReasoningEffortMax ReasoningEffort = "max"
// ReasoningEffortUltra is advertised by models whose provider exposes an
// additional tier above max.
ReasoningEffortUltra ReasoningEffort = "ultra"
)
type ModelCapability string
@@ -410,7 +413,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
func ValidReasoningEffort(effort ReasoningEffort) bool {
switch effort {
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax, ReasoningEffortUltra:
return true
default:
return false
+177
View File
@@ -0,0 +1,177 @@
// Package pathjail confines file operations to a directory tree.
//
// It exists because the obvious guard does not work. Checking a path with
// Lstat and then handing the same string to MkdirAll, CreateTemp, Rename or
// Remove is two independent resolutions with a gap between them, and every
// operation after the check re-resolves the ANCESTORS as well. A store that
// verified only its own directory and file was therefore writable outside its
// tree whenever any component above them was a link: the check passed, and the
// syscall that followed went somewhere else.
//
// On Windows the same guard failed for a second, independent reason. A junction
// is a reparse point but NOT a symlink, so os.Lstat reports it as ModeIrregular
// with ModeSymlink clear, and a guard testing only ModeSymlink returns "not a
// link" when pointed straight at one. Junctions also need no privilege to
// create, unlike Windows symlinks, so this is reachable by exactly the
// unprivileged user a workspace guard is meant to contain.
//
// The fix for both is to stop naming things by path. Open a handle on the root
// once and address everything below it relative to that handle, so the kernel
// resolves each component against an object that cannot be swapped underneath.
// os.Root is that handle: it refuses any component that would escape, including
// a Windows reparse point, and it is the same idea the sandbox uses for ACL
// materialization.
package pathjail
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// ErrEscapes reports a target that is not inside the root it was confined to.
// Returned before anything is opened, so a caller cannot act on the path first.
var ErrEscapes = errors.New("path escapes its root")
// ErrReparse reports a link or reparse point where a real file or directory was
// required.
var ErrReparse = errors.New("refusing to write through a link")
// Open confines dir to root and returns a handle on root plus dir expressed
// relative to it. The caller closes the handle.
//
// root is resolved normally, because it is the boundary the caller chose and
// trusts (a workspace the operator opened, say, which may legitimately sit under
// a link). Everything BELOW it is resolved against the returned handle and
// cannot escape.
func Open(root, dir string) (*os.Root, string, error) {
// Only a genuinely EMPTY value is treated as missing; every other spelling is
// used verbatim. Testing TrimSpace(...) == "" instead would retarget the very
// paths this guards: " " is a legal directory name on unix, so trimming would
// reject it as "no root" or silently swap dir for root, which is the same
// retargeting bug as trimming "root " down to "root".
if root == "" {
return nil, "", fmt.Errorf("%w: no root to confine %q to", ErrEscapes, dir)
}
if dir == "" {
dir = root
}
// Rel before Open, so an escaping target is refused without opening
// anything. filepath.Rel is lexical, which is the right test here: it says
// what the caller ASKED for, and the handle enforces what actually happens.
relative, err := filepath.Rel(root, dir)
if err != nil {
return nil, "", fmt.Errorf("%w: %q against %q: %v", ErrEscapes, dir, root, err)
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, "", fmt.Errorf("%w: %q is outside %q", ErrEscapes, dir, root)
}
// The root is created if it is missing, by ordinary pathname, because it is
// the boundary the caller declared rather than anything being confined. The
// stores this replaced called MkdirAll on the whole tree, so this creates
// strictly less by pathname than before, and everything BELOW root is opened
// relative to the handle.
if err := os.MkdirAll(root, 0o700); err != nil {
return nil, "", fmt.Errorf("create %s: %w", root, err)
}
handle, err := os.OpenRoot(root)
if err != nil {
return nil, "", fmt.Errorf("open %s: %w", root, err)
}
return handle, relative, nil
}
// RefuseReparse fails when relative names a link or reparse point.
//
// Absent is fine: a name that does not exist yet is what a create is for.
//
// This is belt to os.Root's braces. The handle already refuses to traverse a
// reparse point, so this exists to give the FINAL component a clear error
// instead of an "escapes from parent" one, and to keep the refusal explicit at
// the point a reader looks for it.
//
// ModeIrregular is tested alongside ModeSymlink deliberately: that is what a
// Windows junction reports, and a guard checking only ModeSymlink lets one
// straight through.
func RefuseReparse(handle *os.Root, relative string) error {
// A trailing separator makes os.Root.Lstat resolve the terminal component on
// Windows (doInRootAlwaysResolveTerminalSlash), so a junction named with a
// trailing slash would be stat'd as its target and wave through as "not a
// link". Strip it so the final component itself is the thing inspected.
//
// os.IsPathSeparator rather than a literal `/\` set, because backslash is a
// separator only on Windows: on unix it is an ordinary filename character, and
// trimming it would inspect "linked" when asked about a symlink genuinely
// named `linked\` — reporting "not a link" for one that is.
if trimmed := trimTrailingSeparators(relative); trimmed != "" {
relative = trimmed
}
info, err := handle.Lstat(relative)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return fmt.Errorf("inspect %s: %w", relative, err)
}
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return fmt.Errorf("%w: %s", ErrReparse, relative)
}
return nil
}
// trimTrailingSeparators drops the path separators at the end of a name, using
// the ACTIVE platform's definition of one: `/` and `\` on Windows, `/` only on
// unix, where a backslash is a legal character in a filename.
func trimTrailingSeparators(path string) string {
end := len(path)
for end > 0 && os.IsPathSeparator(path[end-1]) {
end--
}
return path[:end]
}
// randomBytes fills b with cryptographic randomness. A variable rather than a
// direct crypto/rand call so a test can force the name collision that exercises
// the O_EXCL retry below — the branch that matters, and one a real random source
// will not reach on demand.
var randomBytes = rand.Read
// CreateTemp makes an O_EXCL temp file named prefix.<random>suffix inside dir,
// relative to handle, and returns it with its relative path.
//
// O_EXCL rather than a fixed name: a link planted at a predictable temp path
// between the check and the create is the same race this package exists to
// close, and O_EXCL makes the kernel refuse it rather than us.
func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string, error) {
// prefix and suffix are NAME fragments, not path fragments. A separator or a
// "."/".." in either would relocate the file out of dir — a prefix of
// "../escaped", or a suffix of ".tmp/../evil" that also erases the random
// component and with it the O_EXCL unpredictability the file relies on.
// Reject them up front so the file lands where dir promises.
for _, fragment := range []string{prefix, suffix} {
if strings.ContainsAny(fragment, `/\`) || fragment == "." || fragment == ".." {
return nil, "", fmt.Errorf("%w: temporary name fragment %q must be a single path component", ErrEscapes, fragment)
}
}
for attempt := 0; attempt < 10; attempt++ {
noise := make([]byte, 8)
if _, err := randomBytes(noise); err != nil {
return nil, "", fmt.Errorf("generate a temporary name: %w", err)
}
relative := filepath.Join(dir, prefix+"."+hex.EncodeToString(noise)+suffix)
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
return file, relative, nil
}
if errors.Is(err, fs.ErrExist) {
continue
}
return nil, "", err
}
return nil, "", errors.New("could not create a temporary file with an unused name")
}
+360
View File
@@ -0,0 +1,360 @@
package pathjail
import (
"encoding/hex"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// linkDir points link at target, using whatever kind of link this platform lets
// an unprivileged process create.
//
// On Windows that is a JUNCTION, not a symlink, and the difference is the whole
// reason this package exists twice over. A symlink needs
// SeCreateSymbolicLinkPrivilege, which an ordinary account does not hold, so a
// test written with os.Symlink silently skips on Windows and proves nothing
// there. A junction needs no privilege at all, so it is both the reachable
// attack and the only one that can be tested.
func linkDir(t *testing.T, target, link string) {
t.Helper()
if runtime.GOOS == "windows" {
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
t.Skipf("cannot create a junction: %v %s", err, out)
}
return
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create a symlink: %v", err)
}
}
// The reported defect: a link in an ANCESTOR of the target, not at the target
// itself. Every guard that checked only the final directory and file passed
// this, because the components it checked were genuinely not links; the ones
// the syscall then traversed were.
func TestAnAncestorLinkCannotRedirectAWrite(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(base, "outside")
workspace := filepath.Join(base, "workspace")
for _, dir := range []string{outside, workspace} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, outside, filepath.Join(workspace, ".zero"))
handle, relative, err := Open(workspace, filepath.Join(workspace, ".zero", "store"))
if err != nil {
t.Fatalf("Open should confine, not refuse outright: %v", err)
}
defer handle.Close()
if err := handle.MkdirAll(relative, 0o700); err == nil {
t.Fatal("created a directory through a linked ancestor, so a write can still leave the workspace")
}
if _, err := os.Stat(filepath.Join(outside, "store")); !os.IsNotExist(err) {
t.Errorf("something was created outside the workspace, stat error = %v", err)
}
}
// RefuseReparse has to catch a junction, which is the case os.ModeSymlink alone
// misses. Asserted through the exported helper rather than by reading the mode,
// so the test fails if the predicate is ever narrowed back.
func TestRefuseReparseCatchesAJunctionNotJustASymlink(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, "linked"))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
err = RefuseReparse(handle, filepath.Join(relative, "linked"))
if !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(link) = %v, want ErrReparse; on Windows a junction reports ModeIrregular, so a ModeSymlink-only check returns nil here", err)
}
}
// A name that does not exist yet is what a create is for, so it must not be
// refused. Without this the guard above would be satisfied by refusing
// everything.
func TestRefuseReparseAllowsAnAbsentName(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
if err := RefuseReparse(handle, filepath.Join(relative, "not-there.md")); err != nil {
t.Errorf("an absent name was refused: %v", err)
}
if err := RefuseReparse(handle, relative); err != nil {
t.Errorf("an ordinary directory was refused: %v", err)
}
}
// Open refuses a target outside its root before opening anything, so a caller
// cannot act on the path first.
func TestOpenRefusesATargetOutsideTheRoot(t *testing.T) {
base := t.TempDir()
inside := filepath.Join(base, "inside")
if err := os.MkdirAll(inside, 0o700); err != nil {
t.Fatal(err)
}
for _, target := range []string{
filepath.Join(base, "sibling"),
filepath.Join(inside, "..", "..", "escape"),
} {
if _, _, err := Open(inside, target); !errors.Is(err, ErrEscapes) {
t.Errorf("Open(%q, %q) = %v, want ErrEscapes", inside, target, err)
}
}
if _, _, err := Open("", filepath.Join(base, "anything")); !errors.Is(err, ErrEscapes) {
t.Error("an empty root was accepted; a boundary is not optional")
}
}
// CreateTemp must use O_EXCL and an unpredictable name, so a link planted at a
// guessable temp path is refused by the kernel rather than followed.
func TestCreateTempIsExclusiveAndUnpredictable(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
seen := map[string]bool{}
for i := 0; i < 8; i++ {
file, name, err := CreateTemp(handle, relative, "note", ".tmp")
if err != nil {
t.Fatal(err)
}
file.Close()
if seen[name] {
t.Fatalf("CreateTemp reused the name %q, so two concurrent writes would stomp each other", name)
}
seen[name] = true
if filepath.Ext(name) != ".tmp" {
t.Errorf("temp name %q lost its suffix, so a listing may pick it up as real content", name)
}
}
}
// A legitimate directory whose name carries a space must be confined as spelled.
// Trimming it would open a different filesystem object.
//
// A LEADING space, deliberately: Win32 normalizes a component's trailing spaces
// and dots away, so "root " and "root" are one object there and a trailing-space
// case cannot express the bug on Windows. A leading space survives on every
// platform, so this runs everywhere rather than being gated to unix.
// The path is RELATIVE, under t.Chdir, so the leading space sits at the start of
// the whole string. An absolute filepath.Join(base, " root") would put the space
// in the middle, where TrimSpace cannot reach it — the test would then pass
// against the very guard it exists to catch, on every platform.
func TestOpenPreservesSpaceInPaths(t *testing.T) {
base := t.TempDir()
t.Chdir(base)
const spaced = " root" // the leading space is part of the name
handle, relative, err := Open(spaced, spaced)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
// Write through the handle, then confirm the bytes landed under the spelling
// the caller asked for — the property that actually matters, and one
// handle.Name() alone would not prove.
file, _, err := CreateTemp(handle, relative, "probe", ".tmp")
if err != nil {
t.Fatal(err)
}
name := file.Name()
if err := file.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(base, spaced, filepath.Base(name))); err != nil {
t.Errorf("a file created through the handle is not under %q: %v — the leading space was trimmed away", spaced, err)
}
if _, err := os.Stat(filepath.Join(base, "root")); err == nil {
t.Error(`a trimmed "root" was created instead of the requested " root"`)
}
}
// A whitespace-only name is a legal directory on unix, so it must be confined as
// given rather than treated as "no root" or silently swapped for the root.
func TestOpenAcceptsAWhitespaceOnlyComponent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Win32 normalizes a whitespace-only component away; the name is not expressible there")
}
// The path must be whitespace-only IN FULL for this to bite, so it has to be
// relative: an absolute path ending in " " still has a non-empty TrimSpace and
// would sail past the old guard, making the test prove nothing.
t.Chdir(t.TempDir())
handle, _, err := Open(" ", " ")
if err != nil {
t.Fatalf("a whitespace-only path was rejected as missing: %v", err)
}
defer handle.Close()
if _, err := os.Stat(" "); err != nil {
t.Errorf("the whitespace-only directory was not created as spelled: %v", err)
}
}
// prefix and suffix are name fragments. A separator or traversal in either must
// be refused, or the file lands outside dir and (for a suffix erasing the random
// component) loses the O_EXCL unpredictability it depends on.
func TestCreateTempRejectsPathFragmentsThatEscapeTheDir(t *testing.T) {
base := t.TempDir()
inside := filepath.Join(base, "inside")
if err := os.MkdirAll(inside, 0o700); err != nil {
t.Fatal(err)
}
// root is base, dir is the subdirectory: a "../escaped" fragment lands in
// base — still inside the os.Root, so os.Root does NOT catch it — but outside
// the dir CreateTemp promised. This is the case the reviewers reproduced.
handle, relative, err := Open(base, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
cases := []struct{ prefix, suffix string }{
{"../escaped", ".tmp"},
{`..\sibling`, ".tmp"},
{"note", ".tmp/../evil"},
{"note", ".."},
{"..", ".tmp"},
}
for _, c := range cases {
if _, _, err := CreateTemp(handle, relative, c.prefix, c.suffix); !errors.Is(err, ErrEscapes) {
t.Errorf("CreateTemp(prefix=%q, suffix=%q) = %v, want ErrEscapes", c.prefix, c.suffix, err)
}
}
// Nothing may have leaked into base above the promised dir.
entries, _ := os.ReadDir(base)
for _, entry := range entries {
if entry.Name() != "inside" {
t.Errorf("a temp fragment escaped into the parent: %q", entry.Name())
}
}
}
// A junction named with a trailing separator must still be refused. On Windows
// os.Root.Lstat resolves the terminal component when the name ends in a
// separator, so without trimming it the link is stat'd as its target and waved
// through.
func TestRefuseReparseCatchesALinkNamedWithATrailingSeparator(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, "linked"))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
name := filepath.Join(relative, "linked") + string(filepath.Separator)
if err := RefuseReparse(handle, name); !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(%q) = %v, want ErrReparse", name, err)
}
}
// The O_EXCL retry is the branch that protects an existing file, and a real
// random source will not collide on demand — so force it. The first two draws
// return the same bytes: the name is pre-created, CreateTemp must refuse to
// reuse it, leave its contents alone, and come back with a different name.
func TestCreateTempRetriesWithoutClobberingAnExistingName(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
draws := [][]byte{
{1, 1, 1, 1, 1, 1, 1, 1}, // collides with the pre-created file
{1, 1, 1, 1, 1, 1, 1, 1}, // and again, so the retry must keep trying
{2, 2, 2, 2, 2, 2, 2, 2}, // finally a free name
}
previous := randomBytes
t.Cleanup(func() { randomBytes = previous })
call := 0
randomBytes = func(b []byte) (int, error) {
copy(b, draws[min(call, len(draws)-1)])
call++
return len(b), nil
}
taken := filepath.Join(base, "note."+hex.EncodeToString(draws[0])+".tmp")
if err := os.WriteFile(taken, []byte("do not clobber me"), 0o600); err != nil {
t.Fatal(err)
}
file, name, err := CreateTemp(handle, relative, "note", ".tmp")
if err != nil {
t.Fatal(err)
}
defer file.Close()
if filepath.Base(name) == filepath.Base(taken) {
t.Fatalf("CreateTemp returned the name that was already taken (%q), so O_EXCL did not protect it", name)
}
kept, err := os.ReadFile(taken)
if err != nil {
t.Fatal(err)
}
if string(kept) != "do not clobber me" {
t.Errorf("the pre-existing file was overwritten: %q", kept)
}
if call < 2 {
t.Errorf("the random source was drawn %d time(s); the collision path never ran", call)
}
}
// On unix a backslash is an ordinary filename character, not a separator, so a
// link genuinely named `linked\` must still be refused. Trimming backslashes
// unconditionally would inspect "linked" instead and report "not a link" for one
// that is.
func TestRefuseReparseKeepsABackslashInAUnixName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip(`backslash is a separator on Windows, so this name is not expressible there`)
}
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, `linked\`))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
name := filepath.Join(relative, `linked\`)
if err := RefuseReparse(handle, name); !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(%q) = %v, want ErrReparse — the backslash is part of the name here, not a separator", name, err)
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
}
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("Windows access token has no default owner")
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
+17 -11
View File
@@ -8,17 +8,21 @@ import (
)
type Model struct {
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
ReasoningEfforts []string
DefaultReasoningEffort string
ServiceTiers []string
DefaultServiceTier string
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
}
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
@@ -270,6 +274,8 @@ func dedupeModels(defaultModel string, models []Model) []Model {
}
model.InputModalities = append([]string{}, model.InputModalities...)
model.OutputModalities = append([]string{}, model.OutputModalities...)
model.ReasoningEfforts = append([]string{}, model.ReasoningEfforts...)
model.ServiceTiers = append([]string{}, model.ServiceTiers...)
model.Tags = append([]string{}, model.Tags...)
seen[model.ID] = true
result = append(result, model)
@@ -122,6 +122,25 @@ func TestModelsDoNotAliasMutableCatalogState(t *testing.T) {
}
}
func TestDedupeModelsDoesNotAliasCapabilitySlices(t *testing.T) {
models := []Model{{
ID: "reasoner",
ReasoningEfforts: []string{"low", "high"},
ServiceTiers: []string{"priority"},
}}
first := dedupeModels("reasoner", models)
first[0].ReasoningEfforts[0] = "mutated"
first[0].ServiceTiers[0] = "mutated"
second := dedupeModels("reasoner", models)
if got, want := second[0].ReasoningEfforts[0], "low"; got != want {
t.Fatalf("reasoning efforts alias source state: got %q, want %q", got, want)
}
if got, want := second[0].ServiceTiers[0], "priority"; got != want {
t.Fatalf("service tiers alias source state: got %q, want %q", got, want)
}
}
func modelIDs(models []Model) []string {
ids := make([]string, 0, len(models))
for _, model := range models {
+58 -11
View File
@@ -184,6 +184,10 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
switch strings.TrimSpace(provider.ID) {
case "chatgpt":
// ChatGPT authenticates against the Codex backend, while its public
// model metadata is published under the OpenAI provider on models.dev.
return "openai"
case "dashscope":
return "alibaba"
case "github":
@@ -230,6 +234,11 @@ type remoteModel struct {
Tools bool `json:"tools"`
// ReasoningRaw accepts both a boolean (models.dev) and OpenRouter's object form.
ReasoningRaw json.RawMessage `json:"reasoning"`
ReasoningEfforts []string `json:"reasoning_efforts"`
DefaultReasoning string `json:"default_reasoning_effort"`
ServiceTiers []string `json:"service_tiers"`
AdditionalSpeed []string `json:"additional_speed_tiers"`
DefaultServiceTier string `json:"default_service_tier"`
Free bool `json:"free"`
IsFree bool `json:"is_free"`
InputCost float64 `json:"input_cost"`
@@ -309,6 +318,8 @@ func (model remoteModel) toModel(key string, source string) Model {
containsFold(model.SupportedParameters, "reasoning") ||
containsFold(model.SupportedParameters, "reasoning_effort") ||
containsFold(model.SupportedParameters, "include_reasoning")
reasoningEfforts := model.supportedReasoningEfforts()
serviceTiers := normalizedServiceTiers(model.ServiceTiers, model.AdditionalSpeed)
// Prefer the short display name for aggregator catalogs (OpenRouter's
// description field is multi-sentence marketing copy). models.dev and
@@ -319,20 +330,56 @@ func (model remoteModel) toModel(key string, source string) Model {
}
return Model{
ID: strings.TrimSpace(id),
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning,
InputModalities: inputModalities,
OutputModalities: outputModalities,
InputCost: inputCost,
OutputCost: outputCost,
Tags: cleanStrings(model.Tags),
Source: source,
ID: strings.TrimSpace(id),
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning,
ReasoningEfforts: reasoningEfforts,
DefaultReasoningEffort: strings.TrimSpace(model.DefaultReasoning),
ServiceTiers: serviceTiers,
DefaultServiceTier: normalizeServiceTier(model.DefaultServiceTier),
InputModalities: inputModalities,
OutputModalities: outputModalities,
InputCost: inputCost,
OutputCost: outputCost,
Tags: cleanStrings(model.Tags),
Source: source,
}
}
func normalizedServiceTiers(tiers []string, legacy []string) []string {
values := append(append([]string{}, tiers...), legacy...)
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = normalizeServiceTier(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func normalizeServiceTier(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "fast" {
return "priority"
}
return value
}
func (model remoteModel) supportedReasoningEfforts() []string {
efforts := append([]string{}, model.ReasoningEfforts...)
var info remoteReasoningInfo
if err := json.Unmarshal(bytes.TrimSpace(model.ReasoningRaw), &info); err == nil {
efforts = append(efforts, info.Supported...)
}
return cleanStrings(efforts)
}
func (model remoteModel) supportsReasoning() bool {
raw := bytes.TrimSpace(model.ReasoningRaw)
if len(raw) == 0 || string(raw) == "null" {
@@ -241,6 +241,7 @@ func TestPublicLiveCatalog(t *testing.T) {
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
tests := map[string]string{
"chatgpt": "openai",
"github": "github-models",
"moonshot": "moonshotai",
"nvidia-nim": "nvidia",
+180 -61
View File
@@ -19,20 +19,27 @@ import (
"github.com/Gitlawb/zero/internal/redaction"
)
const anthropicVersion = "2023-06-01"
const (
anthropicVersion = "2023-06-01"
chatGPTModelsProtocolVersion = "0.146.0"
)
type Model struct {
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
ReasoningEfforts []string
DefaultReasoningEffort string
ServiceTiers []string
DefaultServiceTier string
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
}
type Options struct {
@@ -43,6 +50,7 @@ type Options struct {
OAuthResolver providerio.TokenResolver
CodexAccountResolver openai.CodexAccountResolver
UserAgent string
ClientVersion string
}
func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) {
@@ -51,6 +59,7 @@ func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, p
// without credentials so the picker stays current before a key is entered.
canProbeProvider := modelDiscoveryAllowed(profile) && (!provider.RequiresAuth ||
discoveryHasCredential(profile) ||
options.OAuthResolver != nil ||
publicLiveCatalogProvider(provider, profile))
if canProbeProvider {
liveModels, liveErr := Discover(ctx, profile, options)
@@ -204,6 +213,18 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
}
var configure func(*http.Request)
if providercatalog.NormalizeID(profile.CatalogID) == "chatgpt" {
parsed, parseErr := url.Parse(endpoint)
if parseErr != nil {
return nil, fmt.Errorf("parse ChatGPT models endpoint: %w", parseErr)
}
clientVersion := strings.TrimSpace(options.ClientVersion)
if clientVersion == "" {
clientVersion = chatGPTModelsProtocolVersion
}
query := parsed.Query()
query.Set("client_version", clientVersion)
parsed.RawQuery = query.Encode()
endpoint = parsed.String()
configure = func(request *http.Request) {
account := ""
if options.CodexAccountResolver != nil {
@@ -326,24 +347,39 @@ func anthropicModelsEndpoint(baseURL string) (string, error) {
return parsed.String(), nil
}
type modelsResponseItem struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
ContextWindow int `json:"context_window"`
ContextWindowAlt int `json:"contextWindow"`
ContextLength int `json:"context_length"`
MaxContextLength int `json:"max_context_length"`
Free bool `json:"free"`
IsFree bool `json:"is_free"`
ToolCall bool `json:"tool_call"`
ToolCallCamel bool `json:"toolCall"`
Tools bool `json:"tools"`
Reasoning bool `json:"reasoning"`
ReasoningEfforts []string `json:"reasoning_efforts"`
DefaultReasoning string `json:"default_reasoning_level"`
SupportedReasoning []struct {
Effort string `json:"effort"`
} `json:"supported_reasoning_levels"`
ServiceTiers []struct {
ID string `json:"id"`
} `json:"service_tiers"`
AdditionalSpeedTiers []string `json:"additional_speed_tiers"`
DefaultServiceTier string `json:"default_service_tier"`
SupportedParameters []string `json:"supported_parameters"`
}
type modelsResponse struct {
Data []struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Description string `json:"description"`
ContextWindow int `json:"context_window"`
ContextWindowAlt int `json:"contextWindow"`
ContextLength int `json:"context_length"`
MaxContextLength int `json:"max_context_length"`
Free bool `json:"free"`
IsFree bool `json:"is_free"`
ToolCall bool `json:"tool_call"`
ToolCallCamel bool `json:"toolCall"`
Tools bool `json:"tools"`
Reasoning bool `json:"reasoning"`
SupportedParameters []string `json:"supported_parameters"`
} `json:"data"`
Data []modelsResponseItem `json:"data"`
Models []modelsResponseItem `json:"models"`
}
func parseModelsResponse(body []byte) ([]Model, error) {
@@ -352,12 +388,21 @@ func parseModelsResponse(body []byte) ([]Model, error) {
return nil, fmt.Errorf("decode models response: %w", err)
}
seen := map[string]bool{}
models := make([]Model, 0, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
items := payload.Data
if len(items) == 0 {
items = payload.Models
}
models := make([]Model, 0, len(items))
for _, item := range items {
id := firstNonEmptyDiscovery(item.ID, item.Slug)
if id == "" || seen[id] {
continue
}
// The ChatGPT catalog includes hidden/internal entries alongside picker
// models. Generic OpenAI-compatible responses omit visibility entirely.
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
continue
}
seen[id] = true
description := firstNonEmptyDiscovery(
item.DisplayName,
@@ -381,13 +426,19 @@ func parseModelsResponse(body []byte) ([]Model, error) {
discoveryContainsFold(item.SupportedParameters, "reasoning") ||
discoveryContainsFold(item.SupportedParameters, "reasoning_effort") ||
discoveryContainsFold(item.SupportedParameters, "include_reasoning")
efforts := reasoningEfforts(item)
serviceTiers := modelServiceTiers(item)
models = append(models, Model{
ID: id,
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning,
Source: "live",
ID: id,
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning || len(efforts) > 0,
ReasoningEfforts: efforts,
DefaultReasoningEffort: strings.TrimSpace(item.DefaultReasoning),
ServiceTiers: serviceTiers,
DefaultServiceTier: normalizeServiceTier(item.DefaultServiceTier),
Source: "live",
})
}
sort.SliceStable(models, func(i, j int) bool {
@@ -399,6 +450,50 @@ func parseModelsResponse(body []byte) ([]Model, error) {
return models, nil
}
func modelServiceTiers(item modelsResponseItem) []string {
values := append([]string{}, item.AdditionalSpeedTiers...)
for _, tier := range item.ServiceTiers {
values = append(values, tier.ID)
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = normalizeServiceTier(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func normalizeServiceTier(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "fast" {
return "priority"
}
return value
}
func reasoningEfforts(item modelsResponseItem) []string {
efforts := append([]string{}, item.ReasoningEfforts...)
for _, level := range item.SupportedReasoning {
efforts = append(efforts, level.Effort)
}
seen := map[string]bool{}
result := make([]string, 0, len(efforts))
for _, effort := range efforts {
effort = strings.ToLower(strings.TrimSpace(effort))
if effort == "" || seen[effort] {
continue
}
seen[effort] = true
result = append(result, effort)
}
return result
}
func fetchCatalogModels(ctx context.Context, provider providercatalog.Descriptor, options Options) ([]Model, error) {
models, err := providermodelcatalog.FetchRemote(ctx, provider, providermodelcatalog.FetchOptions{
HTTPClient: options.HTTPClient,
@@ -416,17 +511,21 @@ func modelsFromCatalog(models []providermodelcatalog.Model) []Model {
result := make([]Model, 0, len(models))
for _, model := range models {
result = append(result, Model{
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
DefaultReasoningEffort: model.DefaultReasoningEffort,
ServiceTiers: append([]string{}, model.ServiceTiers...),
DefaultServiceTier: model.DefaultServiceTier,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
})
}
return result
@@ -440,7 +539,8 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
hasCatalog := len(byID) > 0
// Aggregators publish the live list as the source of truth. Keep live-only
// ids even when a remote catalog also loaded, instead of intersecting.
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID)
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
result := make([]Model, 0, len(liveModels))
for _, live := range liveModels {
if catalog, ok := byID[live.ID]; ok {
@@ -458,6 +558,21 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
if strings.TrimSpace(catalog.Description) == "" && strings.TrimSpace(live.Description) != "" {
catalog.Description = live.Description
}
if len(live.ReasoningEfforts) > 0 {
catalog.ReasoningEfforts = append([]string{}, live.ReasoningEfforts...)
}
if live.DefaultReasoningEffort != "" {
catalog.DefaultReasoningEffort = live.DefaultReasoningEffort
}
if live.Reasoning || len(live.ReasoningEfforts) > 0 || live.DefaultReasoningEffort != "" {
catalog.Reasoning = true
}
if len(live.ServiceTiers) > 0 {
catalog.ServiceTiers = append([]string{}, live.ServiceTiers...)
}
if live.DefaultServiceTier != "" {
catalog.DefaultServiceTier = live.DefaultServiceTier
}
catalog.Source = firstDiscoverySource(catalog.Source, "live")
result = append(result, catalog)
continue
@@ -526,17 +641,21 @@ func liveModelAllowedWithoutCatalog(provider providercatalog.Descriptor, id stri
func catalogModelFromDiscovery(model Model) providermodelcatalog.Model {
return providermodelcatalog.Model{
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
DefaultReasoningEffort: model.DefaultReasoningEffort,
ServiceTiers: append([]string{}, model.ServiceTiers...),
DefaultServiceTier: model.DefaultServiceTier,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
}
}
@@ -80,6 +80,152 @@ func TestParseModelsResponseCapturesContextAndFree(t *testing.T) {
}
}
func TestParseModelsResponseSupportsChatGPTCatalog(t *testing.T) {
models, err := parseModelsResponse([]byte(`{
"models": [
{"slug":"gpt-5.6-sol","display_name":"GPT-5.6 Sol","description":"Frontier coding model","visibility":"list","context_window":400000,"default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"}],"service_tiers":[{"id":"priority"}],"default_service_tier":"standard"},
{"slug":"gpt-5.6-terra","display_name":"GPT-5.6 Terra","visibility":"list"},
{"slug":"internal-router","display_name":"Internal","visibility":"hide"},
{"slug":"server-only","display_name":"Server only","visibility":"none"}
]
}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got, want := modelIDs(models), []string{"gpt-5.6-sol", "gpt-5.6-terra"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("models = %#v, want visible ChatGPT models %#v", got, want)
}
if models[0].Description != "GPT-5.6 Sol" || models[0].ContextWindow != 400000 {
t.Fatalf("ChatGPT metadata = %#v", models[0])
}
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,medium,high,xhigh" {
t.Fatalf("reasoning efforts = %q", got)
}
if models[0].DefaultReasoningEffort != "high" || !models[0].Reasoning {
t.Fatalf("reasoning metadata = %#v", models[0])
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" || models[0].DefaultServiceTier != "standard" {
t.Fatalf("service tier metadata = %#v", models[0])
}
}
func TestParseModelsResponseNormalizesLegacyFastTier(t *testing.T) {
models, err := parseModelsResponse([]byte(`{"data":[{"id":"gpt-test","additional_speed_tiers":["fast","priority"]}]}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
t.Fatalf("service tiers = %q, want priority", got)
}
}
func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
[]Model{
{ID: "gpt-5.5", ReasoningEfforts: []string{"low", "high"}, ServiceTiers: []string{"priority"}},
{ID: "gpt-5.6-sol", ReasoningEfforts: []string{"low", "ultra"}},
},
[]Model{{ID: "gpt-5.5", Description: "fallback"}},
)
if got, want := strings.Join(modelIDs(models), ","), "gpt-5.5,gpt-5.6-sol"; got != want {
t.Fatalf("models = %q, want %q", got, want)
}
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,high" {
t.Fatalf("merged reasoning efforts = %q", got)
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
t.Fatalf("merged service tiers = %q", got)
}
}
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api.json":
_, _ = w.Write([]byte(`{
"openai": {"models": {
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"description": "Frontier coding model",
"tool_call": true,
"reasoning": true,
"reasoning_efforts": ["low", "high", "max"],
"limit": {"context": 1050000},
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
}
}}
}`))
case "/backend-api/codex/models":
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
default:
t.Errorf("unexpected request path %q", r.URL.Path)
http.NotFound(w, r)
return
}
}))
defer server.Close()
provider, ok := providercatalog.Get("chatgpt")
if !ok {
t.Fatal("ChatGPT provider missing from catalog")
}
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
ModelsDevURL: server.URL + "/api.json",
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer test", true, nil
},
})
if err != nil {
t.Fatalf("DiscoverCatalog returned error: %v", err)
}
if len(models) != 1 {
t.Fatalf("models = %#v, want one ChatGPT live model", models)
}
model := models[0]
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
}
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
t.Fatalf("input modalities = %q, want text,image,pdf", got)
}
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
}
}
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
[]Model{{
ID: "gpt-live",
Reasoning: true,
DefaultReasoningEffort: "high",
DefaultServiceTier: "priority",
}},
[]Model{{
ID: "gpt-live",
Reasoning: false,
DefaultReasoningEffort: "low",
DefaultServiceTier: "standard",
}},
)
if len(models) != 1 {
t.Fatalf("merged models = %#v, want one model", models)
}
got := models[0]
if !got.Reasoning || got.DefaultReasoningEffort != "high" || got.DefaultServiceTier != "priority" {
t.Fatalf("merged live metadata = %#v", got)
}
}
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
// Catalog and live endpoints return distinct payloads so the merge must keep
// live-only ids that are absent from the remote catalog response.
@@ -206,6 +352,9 @@ func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
if got := r.URL.Path; got != "/backend-api/codex/models" {
t.Errorf("path = %q, want Codex models endpoint", got)
}
if got := r.URL.Query().Get("client_version"); got != chatGPTModelsProtocolVersion {
t.Errorf("client_version = %q, want %q", got, chatGPTModelsProtocolVersion)
}
wantToken := "Bearer old-token"
wantAccount := "old-account"
if requests == 2 {
+56
View File
@@ -0,0 +1,56 @@
package providers
import (
"context"
"errors"
"net/http"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// OAuthLoginForProfile resolves the OAuth login used by profile once and
// returns both its bearer resolver and credential-store key. Keeping the key
// alongside the resolver lets auxiliary requests derive account-scoped headers
// from the same login that supplies the bearer.
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 {
return nil, ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AllowPresets: true,
})
if err != nil {
return nil, ""
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var resolveErr error
if forceRefresh {
token, resolveErr = manager.Handle401(ctx, key)
} else {
token, resolveErr = manager.GetFresh(ctx, key)
}
if errors.Is(resolveErr, oauth.ErrNoToken) {
return "", "", false, nil
}
if resolveErr != nil {
return "", "", false, resolveErr
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
}
+68
View File
@@ -0,0 +1,68 @@
package providers
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestOAuthLoginForProfileBindsBearerAndAccountToSameLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
key := oauth.ProviderKey("chatgpt")
if err := store.Save(key, oauth.Token{
AccessToken: "subscription-token",
Account: "account-42",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("save OAuth token: %v", err)
}
resolver, loginKey := OAuthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"})
if resolver == nil || loginKey != key {
t.Fatalf("OAuth login = (%v, %q), want resolver bound to %q", resolver != nil, loginKey, key)
}
header, value, ok, err := resolver(context.Background(), false)
if err != nil || !ok || header != "Authorization" || value != "Bearer subscription-token" {
t.Fatalf("bearer resolution = (%q, %q, %v, %v)", header, value, ok, err)
}
account, ok, err := CodexAccountResolverForLogin(loginKey)(context.Background())
if err != nil || !ok || account != "account-42" {
t.Fatalf("account resolution = (%q, %v, %v)", account, ok, err)
}
}
func TestOAuthLoginForProfileReturnsNoResolverWithoutUsableLogin(t *testing.T) {
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(t.TempDir(), "oauth-tokens.json"))
for _, test := range []struct {
name string
profile config.ProviderProfile
}{
{
name: "profile with its own credential has no OAuth candidates",
profile: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKey: "sk-configured"},
},
{
name: "candidate has no stored login",
profile: config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
},
} {
t.Run(test.name, func(t *testing.T) {
resolver, key := OAuthLoginForProfile(test.profile)
if resolver != nil || key != "" {
t.Fatalf("OAuthLoginForProfile() = (%v, %q), want (nil, empty)", resolver != nil, key)
}
})
}
}
@@ -80,6 +80,7 @@ type responsesRequest struct {
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
}
// responsesReasoning carries the reasoning controls for the Responses API. The
@@ -265,6 +266,9 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
// long thinking phase shows live progress instead of looking hung.
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
}
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
req.ServiceTier = tier
}
return req, nil
}
+43
View File
@@ -492,6 +492,49 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
}
}
func TestCodexProviderNormalizesServiceTier(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{name: "priority", input: "priority", want: "priority"},
{name: "flex", input: "flex", want: "flex"},
{name: "unsupported omitted", input: "standard", want: ""},
} {
t.Run(test.name, func(t *testing.T) {
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ServiceTier: test.input,
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drainCodexEvents(t, stream)
if test.want == "" {
if _, ok := rec.body["service_tier"]; ok {
t.Fatalf("body.service_tier = %#v, want omitted", rec.body["service_tier"])
}
return
}
if got := rec.body["service_tier"]; got != test.want {
t.Fatalf("body.service_tier = %#v, want %q", got, test.want)
}
})
}
}
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
// "thinking"), in order, alongside the normal text output. Without this a long
+13 -1
View File
@@ -467,6 +467,9 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
mapped.ReasoningEffort = effort
}
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
mapped.ServiceTier = tier
}
// prompt_cache_key is a documented OpenAI parameter for server-side prefix
// cache routing. Official OpenAI accepts it; many openai-compatible
// gateways (NVIDIA NIM, strict local proxies) reject unknown fields with a
@@ -505,7 +508,16 @@ func promptCacheKeyDisabled() bool {
// is dropped rather than risking a 400 on an unrecognized enum.
func openAIReasoningEffort(requested string) string {
switch strings.ToLower(strings.TrimSpace(requested)) {
case "minimal", "low", "medium", "high":
case "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
return strings.ToLower(strings.TrimSpace(requested))
default:
return ""
}
}
func openAIServiceTier(requested string) string {
switch strings.ToLower(strings.TrimSpace(requested)) {
case "priority", "flex":
return strings.ToLower(strings.TrimSpace(requested))
default:
return ""
@@ -860,6 +860,51 @@ func TestStreamCompletionSendsReasoningEffort(t *testing.T) {
}
}
func TestStreamCompletionNormalizesServiceTier(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{name: "priority", input: "priority", want: "priority"},
{name: "flex with surrounding whitespace", input: " FlEx ", want: "flex"},
{name: "unsupported omitted", input: "standard", want: ""},
} {
t.Run(test.name, func(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "gpt-test"})
if err != nil {
t.Fatalf("New: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ServiceTier: test.input,
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drain(stream)
if test.want == "" {
if _, ok := gotBody["service_tier"]; ok {
t.Fatalf("service_tier = %#v, want omitted", gotBody["service_tier"])
}
return
}
if got := gotBody["service_tier"]; got != test.want {
t.Fatalf("service_tier = %#v, want %q", got, test.want)
}
})
}
}
func TestStreamCompletionOmitsReasoningEffortWhenUnsetOrInvalid(t *testing.T) {
for _, effort := range []string{"", "none", "bogus"} {
var gotBody map[string]any
@@ -0,0 +1,34 @@
package openai
import "testing"
func TestOpenAIReasoningEffortPreservesKnownProviderTiers(t *testing.T) {
for _, effort := range []string{"minimal", "low", "medium", "high", "xhigh", "max", "ultra"} {
if got := openAIReasoningEffort(effort); got != effort {
t.Fatalf("openAIReasoningEffort(%q) = %q", effort, got)
}
}
if got := openAIReasoningEffort("unknown"); got != "" {
t.Fatalf("unknown effort = %q, want omitted", got)
}
}
func TestOpenAIServiceTierNormalizesKnownValues(t *testing.T) {
for _, test := range []struct {
input string
want string
}{
{input: "priority", want: "priority"},
{input: "flex", want: "flex"},
{input: " PRIORITY ", want: "priority"},
{input: "FlEx", want: "flex"},
{input: "", want: ""},
{input: "standard", want: ""},
} {
t.Run(test.input, func(t *testing.T) {
if got := openAIServiceTier(test.input); got != test.want {
t.Fatalf("openAIServiceTier(%q) = %q, want %q", test.input, got, test.want)
}
})
}
}
+4 -2
View File
@@ -153,8 +153,8 @@ func (s *turnSession) observePrefix(ctx context.Context, request zeroruntime.Com
// computeFingerprint digests the wire-affecting request parameters: the
// session's model and max-tokens (fixed at construction — CompletionRequest
// carries no model field), the normalized reasoning effort as it would appear
// on the wire, the prompt-cache key (constant within a session, and part of
// the request the provider serializes), and an order-preserving digest of the
// on the wire, the normalized service tier, the prompt-cache key (constant
// within a session, and part of the request the provider serializes), and an order-preserving digest of the
// advertised tools (request serialization preserves tool order, so a reorder
// changes the wire bytes and counts as drift). Messages are excluded — they
// grow every turn; this fingerprints request parameters, not conversation
@@ -175,6 +175,8 @@ func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest)
builder.WriteByte('|')
builder.WriteString(openAIReasoningEffort(request.ReasoningEffort))
builder.WriteByte('|')
builder.WriteString(openAIServiceTier(request.ServiceTier))
builder.WriteByte('|')
builder.WriteString(request.PromptCacheKey)
builder.WriteByte('|')
builder.WriteString(wireToolsDigest(request.Tools))
+8 -1
View File
@@ -231,11 +231,18 @@ func TestTurnSessionFingerprintDriftOnToolsAndEffort(t *testing.T) {
// Unrecognized efforts normalize to omitted — same as empty.
droppedEffort := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
ReasoningEffort: "xhigh",
ReasoningEffort: "bogus",
})
if base != droppedEffort {
t.Fatal("an effort the wire would omit must fingerprint like no effort")
}
changedTier := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
ServiceTier: "priority",
})
if base == changedTier {
t.Fatal("fingerprint did not drift on a changed service tier")
}
}
func TestTurnSessionFingerprintDriftOnToolDescription(t *testing.T) {
+1
View File
@@ -6,6 +6,7 @@ type chatCompletionRequest struct {
Tools []toolDefinition `json:"tools,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
// PromptCacheKey asks the backend to route the request to a replica that
@@ -1,14 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package providerio
import (
"bufio"
"io"
)
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
return scanSSEPayloads(scanner, handle, nil)
}
+22
View File
@@ -242,6 +242,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
{name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}},
} {
t.Run(tc.name, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), Request{
@@ -315,6 +316,27 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
}
}
func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
for _, patch := range []string{
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n",
"*** Begin Patch\n*** Add File: ..\\escape.txt\n+x\n*** End Patch\n",
} {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "apply_patch",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"patch": patch},
})
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace {
t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision)
}
}
}
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
+30 -2
View File
@@ -261,7 +261,7 @@ func applyPatchRequestPaths(args map[string]any) []string {
}
cwd := firstArgString(args, "cwd")
var paths []string
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -281,7 +281,7 @@ func applyPatchPathBlock(request Request) *pathBlock {
if patch == "" {
return nil
}
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -296,6 +296,34 @@ func applyPatchPathBlock(request Request) *pathBlock {
return nil
}
func applyPatchPaths(patch string) []string {
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
return structuredPatchHeaderPaths(patch)
}
return patchHeaderPaths(patch)
}
// structuredPatchHeaderPaths is intentionally a small, conservative scanner for
// the sandbox boundary. The executor validates the complete hunk grammar before
// writing; the sandbox only needs every possible target path so it can reject an
// unsafe request before the tool runs.
func structuredPatchHeaderPaths(patch string) []string {
var paths []string
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(line)
for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} {
if path, ok := strings.CutPrefix(trimmed, prefix); ok {
path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/")
if path != "" {
paths = append(paths, path)
}
break
}
}
}
return paths
}
func patchHeaderPaths(patch string) []string {
var paths []string
oldRemaining, newRemaining := 0, 0
+1 -1
View File
@@ -105,7 +105,7 @@ Review.`)
_, err = ParseMarkdown(`---
name: reviewer
description: Reviews code
reasoningEffort: ULTRA
reasoningEffort: IMPOSSIBLE
---
Review.`)
if err == nil || !strings.Contains(err.Error(), "unknown reasoning effort") {
+828
View File
@@ -0,0 +1,828 @@
package terminalpet
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
_ "golang.org/x/image/webp"
"github.com/Gitlawb/zero/internal/installtxn"
)
const (
manifestCacheAge = 10 * time.Minute
rankingCacheAge = 10 * time.Minute
maxManifestBytes = 2 << 20
maxRankingBytes = 2 << 20
maxPreviewBytes = 2 << 20
maxSpriteBytes = 16 << 20
maxPetJSONBytes = 1 << 20
maxImageSide = 16384
maxImagePixels = 50_000_000
)
type Client struct {
RootDir string
ManifestURL string
RankingURL string
HTTPClient *http.Client
TrustedHosts map[string]bool
// TrustedAssetHosts is narrower than TrustedHosts: catalog redirects may use
// petdex.dev, while image and metadata downloads must stay on the asset host.
TrustedAssetHosts map[string]bool
Now func() time.Time
}
type compactManifest struct {
Version int `json:"v"`
AssetBase string `json:"assetBase"`
Fields []string `json:"fields"`
Pets []json.RawMessage `json:"pets"`
}
type installedMetadata struct {
Entry
InstalledAt time.Time `json:"installedAt"`
}
type petMetadata struct {
SpriteVersion int `json:"spriteVersionNumber"`
Animations map[string]petAnimationMetadata `json:"animations"`
Interactions struct {
Click struct {
Animations []string `json:"animations"`
} `json:"click"`
} `json:"interactions"`
}
type petAnimationMetadata struct {
SourceRow json.RawMessage `json:"sourceRow"`
SourceRowIndex *int `json:"sourceRowIndex"`
FrameCount int `json:"frameCount"`
TimingMS []int `json:"timingMs"`
Playback string `json:"playback"`
Loop bool `json:"loop"`
}
func NewClient(rootDir string) *Client {
client := &Client{
RootDir: rootDir,
ManifestURL: DefaultManifestURL,
RankingURL: DefaultRankingURL,
TrustedHosts: map[string]bool{
"petdex.dev": true,
TrustedAssetHost: true,
},
TrustedAssetHosts: map[string]bool{TrustedAssetHost: true},
Now: time.Now,
}
client.HTTPClient = &http.Client{Timeout: 20 * time.Second}
return client
}
func (c *Client) Catalog(ctx context.Context) ([]Entry, error) {
remote, remoteErr := c.remoteCatalog(ctx)
local, localErr := c.localCatalog()
entries := mergeEntries(local, remote)
if len(entries) > 0 {
rankingCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
ranking, _ := c.installedRanking(rankingCtx)
cancel()
sortEntriesByRanking(entries, ranking)
return entries, nil
}
return nil, errors.Join(remoteErr, localErr)
}
func (c *Client) InstalledEntries() ([]Entry, error) {
return c.localCatalog()
}
type rankingPayload struct {
Pets []struct {
Slug string `json:"slug"`
} `json:"pets"`
}
func (c *Client) installedRanking(ctx context.Context) ([]string, error) {
cachePath := filepath.Join(c.cacheDir(), "ranking-installed.json")
if info, err := os.Stat(cachePath); err == nil && c.now().Sub(info.ModTime()) < rankingCacheAge {
if data, readErr := os.ReadFile(cachePath); readErr == nil {
if ranking, decodeErr := decodeRanking(data); decodeErr == nil {
return ranking, nil
}
}
}
data, err := c.fetch(ctx, c.RankingURL, maxRankingBytes)
if err == nil {
ranking, decodeErr := decodeRanking(data)
if decodeErr == nil {
_ = c.cacheFile(cachePath, data)
return ranking, nil
}
err = decodeErr
}
if cached, readErr := os.ReadFile(cachePath); readErr == nil {
if ranking, decodeErr := decodeRanking(cached); decodeErr == nil {
return ranking, nil
}
}
return nil, fmt.Errorf("load pet ranking: %w", err)
}
func decodeRanking(data []byte) ([]string, error) {
var payload rankingPayload
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("decode pet ranking: %w", err)
}
ranking := make([]string, 0, len(payload.Pets))
seen := make(map[string]bool, len(payload.Pets))
for _, pet := range payload.Pets {
slug := strings.TrimSpace(pet.Slug)
if validateSlug(slug) != nil || seen[slug] {
continue
}
seen[slug] = true
ranking = append(ranking, slug)
}
if len(ranking) == 0 {
return nil, fmt.Errorf("pet ranking is empty")
}
return ranking, nil
}
func (c *Client) Preview(ctx context.Context, entry Entry) (*Animation, error) {
if entry.Local {
if animation, err := c.LoadInstalled(entry.Slug); err == nil {
return animation, nil
}
}
if err := validateSlug(entry.Slug); err != nil {
return nil, err
}
cachePath := filepath.Join(c.previewDir(), previewCacheName(entry))
if data, err := os.ReadFile(cachePath); err == nil {
if animation, err := decodePreview(data); err == nil {
return animation, nil
}
}
assetBase, err := c.trustedAssetBase(entry.AssetBase)
if err != nil {
return nil, err
}
previewURL := assetBase + "/pets/" + url.PathEscape(entry.Slug) + "/preview.webp"
data, err := c.fetchAsset(ctx, previewURL, maxPreviewBytes)
if err == nil {
animation, decodeErr := decodePreview(data)
if decodeErr == nil {
_ = c.cacheFile(cachePath, data)
return animation, nil
}
err = decodeErr
}
thumbnailURL := assetBase + "/pets/" + url.PathEscape(entry.Slug) + "/thumb.webp"
thumbnail, thumbnailErr := c.fetchAsset(ctx, thumbnailURL, maxPreviewBytes)
if thumbnailErr != nil {
return nil, errors.Join(err, thumbnailErr)
}
imageValue, decodeErr := decodeImage(thumbnail)
if decodeErr != nil {
return nil, decodeErr
}
return ThumbnailAnimation(imageValue)
}
func (c *Client) Install(ctx context.Context, entry Entry) (*Animation, error) {
if err := validateSlug(entry.Slug); err != nil {
return nil, err
}
if entry.Local {
return c.LoadInstalled(entry.Slug)
}
spriteURL, err := c.trustedAssetURL(entry.SpritesheetURL)
if err != nil {
return nil, fmt.Errorf("invalid spritesheet URL: %w", err)
}
spriteData, err := c.fetchAsset(ctx, spriteURL, maxSpriteBytes)
if err != nil {
return nil, fmt.Errorf("download spritesheet: %w", err)
}
sheet, err := decodeImage(spriteData)
if err != nil {
return nil, fmt.Errorf("decode spritesheet: %w", err)
}
var petJSON []byte
var animationTracks map[State]atlasTrack
var document petMetadata
if strings.TrimSpace(entry.PetJSONURL) != "" {
petURL, urlErr := c.trustedAssetURL(entry.PetJSONURL)
if urlErr != nil {
return nil, fmt.Errorf("invalid pet metadata URL: %w", urlErr)
}
petJSON, err = c.fetchAsset(ctx, petURL, maxPetJSONBytes)
if err != nil {
return nil, fmt.Errorf("download pet metadata: %w", err)
}
if err := json.Unmarshal(petJSON, &document); err != nil {
return nil, fmt.Errorf("invalid pet metadata: %w", err)
}
if document.SpriteVersion != 0 {
if document.SpriteVersion != 1 && document.SpriteVersion != 2 {
return nil, fmt.Errorf("invalid pet metadata: unsupported sprite version %d", document.SpriteVersion)
}
entry.SpriteVersion = document.SpriteVersion
}
animationTracks, err = document.atlasTracks()
if err != nil {
return nil, fmt.Errorf("invalid pet metadata: %w", err)
}
}
animation, err := atlasAnimation(sheet, entry.SpriteVersion, animationTracks)
if err != nil {
return nil, err
}
animation.setClickAnimations(document.Interactions.Click.Animations)
root := c.installedDir()
stage, cleanup, err := installtxn.StageDir(root)
if err != nil {
return nil, err
}
defer cleanup()
if err := os.MkdirAll(stage, 0o700); err != nil {
return nil, fmt.Errorf("create pet staging directory: %w", err)
}
if err := os.WriteFile(filepath.Join(stage, "spritesheet.webp"), spriteData, 0o600); err != nil {
return nil, fmt.Errorf("stage spritesheet: %w", err)
}
if len(petJSON) > 0 {
if err := os.WriteFile(filepath.Join(stage, "pet.json"), petJSON, 0o600); err != nil {
return nil, fmt.Errorf("stage pet metadata: %w", err)
}
}
metadata, err := json.MarshalIndent(installedMetadata{Entry: entry, InstalledAt: c.now()}, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(stage, "source.json"), append(metadata, '\n'), 0o600); err != nil {
return nil, fmt.Errorf("stage pet source: %w", err)
}
unlock, err := installtxn.Lock(root)
if err != nil {
return nil, err
}
defer unlock()
target := filepath.Join(root, entry.Slug)
if err := installtxn.CommitDir(target, stage, func() error { return nil }); err != nil {
return nil, fmt.Errorf("install pet: %w", err)
}
return animation, nil
}
func (c *Client) LoadInstalled(slug string) (*Animation, error) {
if err := validateSlug(slug); err != nil {
return nil, err
}
dir := filepath.Join(c.installedDir(), slug)
entry, err := c.InstalledEntry(slug)
if err != nil {
return nil, err
}
spriteData, err := os.ReadFile(filepath.Join(dir, "spritesheet.webp"))
if err != nil {
return nil, fmt.Errorf("read installed spritesheet: %w", err)
}
sheet, err := decodeImage(spriteData)
if err != nil {
return nil, err
}
var animationTracks map[State]atlasTrack
var document petMetadata
if petJSON, readErr := os.ReadFile(filepath.Join(dir, "pet.json")); readErr == nil {
if decodeErr := json.Unmarshal(petJSON, &document); decodeErr != nil {
return nil, fmt.Errorf("read installed pet metadata: %w", decodeErr)
}
if document.SpriteVersion != 0 {
entry.SpriteVersion = document.SpriteVersion
}
animationTracks, err = document.atlasTracks()
if err != nil {
return nil, fmt.Errorf("read installed pet metadata: %w", err)
}
} else if !errors.Is(readErr, os.ErrNotExist) {
return nil, fmt.Errorf("read installed pet metadata: %w", readErr)
}
animation, err := atlasAnimation(sheet, entry.SpriteVersion, animationTracks)
if err != nil {
return nil, err
}
animation.setClickAnimations(document.Interactions.Click.Animations)
return animation, nil
}
func (m petMetadata) atlasTracks() (map[State]atlasTrack, error) {
names := make([]string, 0, len(m.Animations))
for name := range m.Animations {
names = append(names, name)
}
sort.Strings(names)
tracks := make(map[State]atlasTrack)
for _, name := range names {
spec := m.Animations[name]
state := State(strings.ToLower(strings.TrimSpace(name)))
if state == "" {
return nil, fmt.Errorf("animation name is empty")
}
if spec.FrameCount < 1 || spec.FrameCount > atlasColumns {
return nil, fmt.Errorf("animation %q has invalid frame count %d", name, spec.FrameCount)
}
if len(spec.TimingMS) != 0 && len(spec.TimingMS) != spec.FrameCount {
return nil, fmt.Errorf("animation %q has %d timings for %d frames", name, len(spec.TimingMS), spec.FrameCount)
}
row, err := spec.rowIndex()
if err != nil {
return nil, fmt.Errorf("animation %q: %w", name, err)
}
// Idle must always cycle. Treating an authored idle track as one-shot
// leaves the companion frozen on its final frame indefinitely.
loop := state == Idle || spec.Loop || strings.EqualFold(strings.TrimSpace(spec.Playback), "loop")
track := atlasTrack{row: row, count: spec.FrameCount, loop: loop, fallbackIdle: !loop}
if len(spec.TimingMS) > 0 {
track.durations = make([]time.Duration, len(spec.TimingMS))
for index, milliseconds := range spec.TimingMS {
if milliseconds < 16 || milliseconds > 10_000 {
return nil, fmt.Errorf("animation %q has invalid frame timing %dms", name, milliseconds)
}
track.durations[index] = time.Duration(milliseconds) * time.Millisecond
}
}
tracks[state] = track
}
return tracks, nil
}
func (m petAnimationMetadata) rowIndex() (int, error) {
if m.SourceRowIndex != nil {
return *m.SourceRowIndex, nil
}
if len(m.SourceRow) == 0 || string(m.SourceRow) == "null" {
return 0, fmt.Errorf("source row is missing")
}
var numeric int
if err := json.Unmarshal(m.SourceRow, &numeric); err == nil {
return numeric, nil
}
var name string
if err := json.Unmarshal(m.SourceRow, &name); err != nil {
return 0, fmt.Errorf("source row is invalid")
}
rows := map[string]int{
"idle": 0, "running-right": 1, "running-left": 2, "waving": 3,
"jumping": 4, "failed": 5, "waiting": 6, "running": 7, "review": 8,
}
normalized := strings.ToLower(strings.TrimSpace(name))
if row, ok := rows[normalized]; ok {
return row, nil
}
if row, err := strconv.Atoi(normalized); err == nil {
return row, nil
}
return 0, fmt.Errorf("unknown source row %q", name)
}
func (c *Client) InstalledEntry(slug string) (Entry, error) {
if err := validateSlug(slug); err != nil {
return Entry{}, err
}
metadataData, err := os.ReadFile(filepath.Join(c.installedDir(), slug, "source.json"))
if err != nil {
return Entry{}, fmt.Errorf("read installed pet: %w", err)
}
var metadata installedMetadata
if err := json.Unmarshal(metadataData, &metadata); err != nil {
return Entry{}, fmt.Errorf("read installed pet metadata: %w", err)
}
if metadata.Slug != slug {
return Entry{}, fmt.Errorf("installed pet metadata does not match %q", slug)
}
metadata.Local = true
return metadata.Entry, nil
}
func (c *Client) remoteCatalog(ctx context.Context) ([]Entry, error) {
cachePath := filepath.Join(c.cacheDir(), "petdex-v2.json")
if info, err := os.Stat(cachePath); err == nil && c.now().Sub(info.ModTime()) < manifestCacheAge {
if data, readErr := os.ReadFile(cachePath); readErr == nil {
if entries, decodeErr := c.decodeCatalog(data); decodeErr == nil {
return entries, nil
}
}
}
data, err := c.fetch(ctx, c.ManifestURL, maxManifestBytes)
if err == nil {
entries, decodeErr := c.decodeCatalog(data)
if decodeErr == nil {
_ = c.cacheFile(cachePath, data)
return entries, nil
}
err = decodeErr
}
if cached, readErr := os.ReadFile(cachePath); readErr == nil {
if entries, decodeErr := c.decodeCatalog(cached); decodeErr == nil {
return entries, nil
}
}
return nil, fmt.Errorf("load pet catalog: %w", err)
}
func (c *Client) decodeCatalog(data []byte) ([]Entry, error) {
var manifest compactManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("decode pet catalog: %w", err)
}
if manifest.Version != 2 {
return nil, fmt.Errorf("unsupported pet catalog version %d", manifest.Version)
}
assetBase, err := c.trustedAssetBase(manifest.AssetBase)
if err != nil {
return nil, err
}
wanted := map[string]int{}
for index, field := range manifest.Fields {
wanted[field] = index
}
required := []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"}
for _, field := range required {
if _, ok := wanted[field]; !ok {
return nil, fmt.Errorf("pet catalog is missing %q", field)
}
}
entries := make([]Entry, 0, len(manifest.Pets))
seen := map[string]bool{}
var firstRowError error
for _, rawRow := range manifest.Pets {
entry, rowErr := func() (Entry, error) {
var row []json.RawMessage
if err := json.Unmarshal(rawRow, &row); err != nil {
return Entry{}, fmt.Errorf("decode pet catalog row: %w", err)
}
value := func(field string, target any) error {
index := wanted[field]
if index >= len(row) {
return fmt.Errorf("pet catalog row is missing %q", field)
}
return json.Unmarshal(row[index], target)
}
var entry Entry
if err := value("slug", &entry.Slug); err != nil {
return Entry{}, err
}
if err := validateSlug(entry.Slug); err != nil {
return Entry{}, err
}
if err := value("displayName", &entry.DisplayName); err != nil {
return Entry{}, err
}
if err := value("kind", &entry.Kind); err != nil {
return Entry{}, err
}
// submittedBy is nullable in the public manifest; null intentionally maps
// to an empty creator label.
_ = value("submittedBy", &entry.SubmittedBy)
if err := value("spritesheet", &entry.SpritesheetURL); err != nil {
return Entry{}, err
}
if err := value("petJson", &entry.PetJSONURL); err != nil {
return Entry{}, err
}
if err := value("spriteVersionNumber", &entry.SpriteVersion); err != nil {
return Entry{}, err
}
if entry.SpriteVersion != 1 && entry.SpriteVersion != 2 {
return Entry{}, fmt.Errorf("pet %q has unsupported sprite version %d", entry.Slug, entry.SpriteVersion)
}
entry.AssetBase = assetBase
resolved, err := c.resolveAssetURL(assetBase, entry.SpritesheetURL)
if err != nil {
return Entry{}, fmt.Errorf("pet %q: %w", entry.Slug, err)
}
entry.SpritesheetURL = resolved
if strings.TrimSpace(entry.PetJSONURL) != "" {
resolved, err = c.resolveAssetURL(assetBase, entry.PetJSONURL)
if err != nil {
return Entry{}, fmt.Errorf("pet %q: %w", entry.Slug, err)
}
entry.PetJSONURL = resolved
}
return entry, nil
}()
if rowErr != nil {
if firstRowError == nil {
firstRowError = rowErr
}
continue
}
if seen[entry.Slug] {
continue
}
seen[entry.Slug] = true
entries = append(entries, entry)
}
if len(entries) == 0 && firstRowError != nil {
return nil, firstRowError
}
sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i].Label()) < strings.ToLower(entries[j].Label()) })
return entries, nil
}
func (c *Client) localCatalog() ([]Entry, error) {
dirs, err := os.ReadDir(c.installedDir())
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
entries := make([]Entry, 0, len(dirs))
for _, dir := range dirs {
if !dir.IsDir() || validateSlug(dir.Name()) != nil {
continue
}
entry, readErr := c.InstalledEntry(dir.Name())
if readErr != nil {
continue
}
entries = append(entries, entry)
}
return entries, nil
}
func (c *Client) fetch(ctx context.Context, address string, limit int64) ([]byte, error) {
return c.fetchLimit(ctx, address, limit, false)
}
func (c *Client) fetchAsset(ctx context.Context, address string, limit int64) ([]byte, error) {
return c.fetchLimit(ctx, address, limit, true)
}
func (c *Client) fetchLimit(ctx context.Context, address string, limit int64, assetOnly bool) ([]byte, error) {
if _, err := c.trustedURL(address); err != nil {
return nil, err
}
if assetOnly {
if _, err := c.trustedAssetURL(address); err != nil {
return nil, err
}
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
if err != nil {
return nil, err
}
baseClient := c.httpClient()
requestClient := *baseClient
requestClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
if assetOnly {
_, err := c.trustedAssetURL(request.URL.String())
return err
}
_, err := c.trustedURL(request.URL.String())
return err
}
response, err := requestClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if _, err := c.trustedURL(response.Request.URL.String()); err != nil {
return nil, err
}
if assetOnly {
if _, err := c.trustedAssetURL(response.Request.URL.String()); err != nil {
return nil, err
}
}
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request returned %s", response.Status)
}
reader := io.LimitReader(response.Body, limit+1)
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("download exceeds %d-byte limit", limit)
}
return data, nil
}
func (c *Client) trustedAssetBase(raw string) (string, error) {
parsed, err := c.trustedURL(raw)
if err != nil {
return "", fmt.Errorf("invalid asset base: %w", err)
}
if parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
return "", fmt.Errorf("asset base must not contain a path, query, or fragment")
}
if !c.assetHosts()[strings.ToLower(parsed.Hostname())] {
return "", fmt.Errorf("untrusted asset host %q", parsed.Hostname())
}
return strings.TrimSuffix(parsed.String(), "/"), nil
}
func (c *Client) trustedAssetURL(raw string) (string, error) {
parsed, err := c.trustedURL(raw)
if err != nil {
return "", err
}
if !c.assetHosts()[strings.ToLower(parsed.Hostname())] {
return "", fmt.Errorf("untrusted asset host %q", parsed.Hostname())
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("asset URL must not contain a query or fragment")
}
cleaned := path.Clean(parsed.Path)
escaped := strings.ToLower(parsed.EscapedPath())
if !allowedAssetPath(cleaned) || cleaned != parsed.Path || strings.Contains(escaped, "%2f") || strings.Contains(escaped, "%5c") || strings.Contains(parsed.Path, "\\") {
return "", fmt.Errorf("asset path is outside the supported catalog roots")
}
return parsed.String(), nil
}
func (c *Client) resolveAssetURL(assetBase, reference string) (string, error) {
reference = strings.TrimSpace(reference)
if reference == "" {
return "", fmt.Errorf("asset path is empty")
}
if parsed, err := url.Parse(reference); err == nil && parsed.IsAbs() {
return c.trustedAssetURL(reference)
}
parsed, err := url.Parse(reference)
if err != nil || parsed.Host != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("invalid relative asset path")
}
cleaned := path.Clean("/" + strings.TrimPrefix(parsed.Path, "/"))
if !allowedAssetPath(cleaned) || strings.Contains(parsed.Path, "\\") {
return "", fmt.Errorf("asset path is outside the supported catalog roots")
}
return c.trustedAssetURL(strings.TrimSuffix(assetBase, "/") + cleaned)
}
func allowedAssetPath(value string) bool {
return strings.HasPrefix(value, "/pets/") || strings.HasPrefix(value, "/community/") || strings.HasPrefix(value, "/curated/")
}
func (c *Client) assetHosts() map[string]bool {
if c.TrustedAssetHosts != nil {
return c.TrustedAssetHosts
}
return map[string]bool{TrustedAssetHost: true}
}
func (c *Client) trustedURL(raw string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" {
return nil, fmt.Errorf("URL must use HTTPS without credentials")
}
hosts := c.TrustedHosts
if hosts == nil {
hosts = map[string]bool{"petdex.dev": true, TrustedAssetHost: true}
}
if !hosts[strings.ToLower(parsed.Hostname())] {
return nil, fmt.Errorf("untrusted host %q", parsed.Hostname())
}
return parsed, nil
}
func decodePreview(data []byte) (*Animation, error) {
imageValue, err := decodeImage(data)
if err != nil {
return nil, err
}
return PreviewAnimation(imageValue)
}
func previewCacheName(entry Entry) string {
digest := sha256.Sum256([]byte(entry.SpritesheetURL))
return fmt.Sprintf("%s-%x.webp", entry.Slug, digest[:6])
}
func decodeImage(data []byte) (image.Image, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image header: %w", err)
}
if config.Width < 1 || config.Height < 1 || config.Width > maxImageSide || config.Height > maxImageSide || int64(config.Width)*int64(config.Height) > maxImagePixels {
return nil, fmt.Errorf("image dimensions %dx%d exceed limits", config.Width, config.Height)
}
imageValue, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
return imageValue, nil
}
func (c *Client) cacheFile(target string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
return installtxn.WriteFileAtomically(target, data, 0o600)
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
client := &http.Client{Timeout: 20 * time.Second}
return client
}
func (c *Client) now() time.Time {
if c.Now != nil {
return c.Now()
}
return time.Now()
}
func (c *Client) cacheDir() string { return filepath.Join(c.RootDir, "pets", "cache") }
func (c *Client) previewDir() string { return filepath.Join(c.cacheDir(), "previews") }
func (c *Client) installedDir() string { return filepath.Join(c.RootDir, "pets", "installed") }
func validateSlug(slug string) error {
if slug == "" || len(slug) > 128 {
return fmt.Errorf("invalid pet slug %q", slug)
}
for index, value := range slug {
if (value >= 'a' && value <= 'z') || (value >= '0' && value <= '9') || (index > 0 && (value == '-' || value == '_')) {
continue
}
return fmt.Errorf("invalid pet slug %q", slug)
}
return nil
}
func mergeEntries(local, remote []Entry) []Entry {
bySlug := make(map[string]Entry, len(local)+len(remote))
for _, entry := range remote {
bySlug[entry.Slug] = entry
}
for _, entry := range local {
bySlug[entry.Slug] = entry
}
entries := make([]Entry, 0, len(bySlug))
for _, entry := range bySlug {
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Local != entries[j].Local {
return entries[i].Local
}
return strings.ToLower(entries[i].Label()) < strings.ToLower(entries[j].Label())
})
return entries
}
func sortEntriesByRanking(entries []Entry, ranking []string) {
rank := make(map[string]int, len(ranking))
for index, slug := range ranking {
rank[slug] = index
}
sort.SliceStable(entries, func(i, j int) bool {
left, right := entries[i], entries[j]
if left.Local != right.Local {
return left.Local
}
if left.Local {
return strings.ToLower(left.Label()) < strings.ToLower(right.Label())
}
leftRank, leftRanked := rank[left.Slug]
rightRank, rightRanked := rank[right.Slug]
if leftRanked != rightRanked {
return leftRanked
}
if leftRanked {
return leftRank < rightRank
}
return strings.ToLower(left.Label()) < strings.ToLower(right.Label())
})
}
+485
View File
@@ -0,0 +1,485 @@
package terminalpet
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestCatalogPreviewInstallAndOfflineReload(t *testing.T) {
preview := encodedPNG(t, 24*previewFrameCount, 26)
atlas := encodedPNG(t, 24*atlasColumns, 26*11)
var assetBase atomic.Pointer[string]
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/manifest":
base := assetBase.Load()
if base == nil {
http.Error(writer, "server not ready", http.StatusServiceUnavailable)
return
}
manifest := compactManifest{
Version: 2,
AssetBase: *base,
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "zip", "spriteVersionNumber"},
}
row := []any{"boba", "Boba", "animal", "tester", "pets/boba/sprite.webp", "pets/boba/petjson.json", "pets/boba/archive.zip", 2}
encoded, _ := json.Marshal(row)
manifest.Pets = []json.RawMessage{encoded}
_ = json.NewEncoder(writer).Encode(manifest)
case "/pets/boba/preview.webp":
_, _ = writer.Write(preview)
case "/pets/boba/sprite.webp":
_, _ = writer.Write(atlas)
case "/pets/boba/petjson.json":
_, _ = writer.Write([]byte(`{"displayName":"Boba","description":"A calm blue-screen gremlin."}`))
case "/ranking":
_, _ = writer.Write([]byte(`{"pets":[{"slug":"boba"}],"nextCursor":null}`))
default:
http.NotFound(writer, request)
}
}))
serverURL := server.URL
assetBase.Store(&serverURL)
defer server.Close()
root := t.TempDir()
client := testClient(t, root, server)
entries, err := client.Catalog(context.Background())
if err != nil || len(entries) != 1 {
t.Fatalf("Catalog() = %#v, %v", entries, err)
}
entry := entries[0]
if entry.Slug != "boba" || entry.SpriteVersion != 2 || entry.AssetBase != server.URL {
t.Fatalf("unexpected catalog entry: %#v", entry)
}
if entry.SpritesheetURL != server.URL+"/pets/boba/sprite.webp" {
t.Fatalf("relative spritesheet was not resolved: %q", entry.SpritesheetURL)
}
previewAnimation, err := client.Preview(context.Background(), entry)
if err != nil || previewAnimation.Frame(Idle, 5) == nil {
t.Fatalf("Preview() animation=%v err=%v", previewAnimation, err)
}
installed, err := client.Install(context.Background(), entry)
if err != nil || installed.Frame(Running, 3) == nil {
t.Fatalf("Install() animation=%v err=%v", installed, err)
}
if _, err := os.Stat(filepath.Join(root, "pets", "installed", "boba", "source.json")); err != nil {
t.Fatalf("installed source marker: %v", err)
}
server.Close()
offlineEntries, err := client.Catalog(context.Background())
if err != nil || len(offlineEntries) != 1 || !offlineEntries[0].Local {
t.Fatalf("offline Catalog() = %#v, %v", offlineEntries, err)
}
if _, err := client.LoadInstalled("boba"); err != nil {
t.Fatalf("LoadInstalled: %v", err)
}
}
func TestInstallUsesPetMetadataSpriteVersionWhenManifestIsStale(t *testing.T) {
atlas := encodedPNG(t, 24*atlasColumns, 26*11)
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/pets/luffy/sprite.webp":
_, _ = writer.Write(atlas)
case "/pets/luffy/petjson.json":
_, _ = writer.Write([]byte(`{
"spriteVersionNumber":2,
"atlasRows":11,
"interactions":{"click":{"animations":["bajrang-gun"],"mode":"cycle"}},
"animations":{"bajrang-gun":{"sourceRowIndex":3,"frameCount":8,"timingMs":[100,110,120,130,140,150,160,320]}}
}`))
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client := testClient(t, t.TempDir(), server)
entry := Entry{
Slug: "luffy",
DisplayName: "Luffy Gear 5",
SpritesheetURL: server.URL + "/pets/luffy/sprite.webp",
PetJSONURL: server.URL + "/pets/luffy/petjson.json",
SpriteVersion: 1,
}
animation, err := client.Install(context.Background(), entry)
if err != nil {
t.Fatalf("Install() with corrected pet metadata: %v", err)
}
if animation.Frame(Review, 0) == nil {
t.Fatal("Install() did not load the version 2 review row")
}
customState := State("bajrang-gun")
if _, key := animation.frame(customState, 7); key.index != 7 {
t.Fatalf("custom waving final frame index = %d, want 7", key.index)
}
if got := animation.FrameDelay(customState, 7); got != 320*time.Millisecond {
t.Fatalf("custom waving final frame delay = %s, want 320ms", got)
}
if got := animation.PrimaryDuration(customState); got != 1230*time.Millisecond {
t.Fatalf("custom waving duration = %s, want unchanged 1.23s", got)
}
if got := animation.FrameDelay(customState, 8); got != 1680*time.Millisecond {
t.Fatalf("custom one-shot fallback delay = %s, want first idle frame at 1.68s", got)
}
if got, ok := animation.ClickAnimation(0); !ok || got != customState {
t.Fatalf("click animation = %q, %v; want %q", got, ok, customState)
}
installed, err := client.InstalledEntry(entry.Slug)
if err != nil {
t.Fatal(err)
}
if installed.SpriteVersion != 2 {
t.Fatalf("persisted sprite version = %d, want 2", installed.SpriteVersion)
}
if _, err := client.LoadInstalled(entry.Slug); err != nil {
t.Fatalf("LoadInstalled() with corrected sprite version: %v", err)
}
}
func TestPetMetadataAllowsSeveralNamedAnimationsOnOneRow(t *testing.T) {
row := 0
document := petMetadata{Animations: map[string]petAnimationMetadata{
"blink": {SourceRowIndex: &row, FrameCount: 4, TimingMS: []int{100, 100, 100, 100}},
"idle": {SourceRow: json.RawMessage(`"idle"`), FrameCount: 6, TimingMS: []int{200, 200, 200, 200, 200, 400}},
}}
tracks, err := document.atlasTracks()
if err != nil {
t.Fatal(err)
}
if got := tracks[Idle].count; got != 6 {
t.Fatalf("idle frame count = %d, want six", got)
}
if got := tracks[State("blink")].count; got != 4 {
t.Fatalf("blink frame count = %d, want four", got)
}
}
func TestPetMetadataAlwaysLoopsIdleAnimation(t *testing.T) {
row := 0
document := petMetadata{Animations: map[string]petAnimationMetadata{
"idle": {
SourceRowIndex: &row,
FrameCount: 2,
TimingMS: []int{100, 200},
Playback: "once",
},
}}
tracks, err := document.atlasTracks()
if err != nil {
t.Fatal(err)
}
idle := tracks[Idle]
if !idle.loop || idle.fallbackIdle {
t.Fatalf("idle track = %#v, want looping without fallback", idle)
}
}
func TestPetMetadataAcceptsNamedAndNumericSourceRows(t *testing.T) {
document := petMetadata{Animations: map[string]petAnimationMetadata{
"dash": {SourceRow: json.RawMessage(`"running-right"`), FrameCount: 8},
"skill": {SourceRow: json.RawMessage(`3`), FrameCount: 8},
}}
tracks, err := document.atlasTracks()
if err != nil {
t.Fatal(err)
}
if tracks[State("dash")].row != 1 || tracks[State("skill")].row != 3 {
t.Fatalf("normalized rows = dash:%d skill:%d", tracks[State("dash")].row, tracks[State("skill")].row)
}
}
func TestCatalogRejectsUntrustedAssetHost(t *testing.T) {
client := NewClient(t.TempDir())
manifest := compactManifest{
Version: 2,
AssetBase: "https://evil.example",
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"},
}
row, _ := json.Marshal([]any{"boba", "Boba", "animal", nil, "https://evil.example/pets/boba/sprite.webp", "", 2})
manifest.Pets = []json.RawMessage{row}
data, _ := json.Marshal(manifest)
if _, err := client.decodeCatalog(data); err == nil {
t.Fatal("decodeCatalog accepted an untrusted asset host")
}
}
func TestCatalogSkipsMalformedRowWhenValidRowsRemain(t *testing.T) {
client := NewClient(t.TempDir())
manifest := compactManifest{
Version: 2,
AssetBase: "https://assets.petdex.dev",
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"},
}
invalid, _ := json.Marshal([]any{"broken slug", "Broken", "creature", "tester", "pets/broken/sprite.webp", "", 2})
valid, _ := json.Marshal([]any{"boba", "Boba", "creature", "tester", "pets/boba/sprite.webp", "", 2})
manifest.Pets = []json.RawMessage{invalid, valid}
data, _ := json.Marshal(manifest)
entries, err := client.decodeCatalog(data)
if err != nil {
t.Fatal(err)
}
if got := entrySlugs(entries); !slices.Equal(got, []string{"boba"}) {
t.Fatalf("catalog entries = %v, want [boba]", got)
}
}
func TestCatalogOrdersRankedPetsBeforeAlphabeticalRemainder(t *testing.T) {
manifest := compactManifest{
Version: 2,
AssetBase: "",
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "zip", "spriteVersionNumber"},
}
for _, row := range [][]any{
{"alpha", "Alpha", "creature", "tester", "pets/alpha/sprite.webp", "pets/alpha/pet.json", nil, 1},
{"beta", "Beta", "creature", "tester", "pets/beta/sprite.webp", "pets/beta/pet.json", nil, 1},
{"zeta", "Zeta", "creature", "tester", "pets/zeta/sprite.webp", "pets/zeta/pet.json", nil, 1},
} {
encoded, _ := json.Marshal(row)
manifest.Pets = append(manifest.Pets, encoded)
}
var rankingFails atomic.Bool
var assetBase atomic.Pointer[string]
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/manifest":
base := assetBase.Load()
if base == nil {
http.Error(writer, "server not ready", http.StatusServiceUnavailable)
return
}
manifest.AssetBase = *base
_ = json.NewEncoder(writer).Encode(manifest)
case "/ranking":
if rankingFails.Load() {
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
return
}
_, _ = writer.Write([]byte(`{"pets":[{"slug":"zeta"},{"slug":"alpha"}],"nextCursor":60}`))
default:
http.NotFound(writer, request)
}
}))
serverURL := server.URL
assetBase.Store(&serverURL)
defer server.Close()
client := testClient(t, t.TempDir(), server)
entries, err := client.Catalog(context.Background())
if err != nil {
t.Fatal(err)
}
if got := entrySlugs(entries); !slices.Equal(got, []string{"zeta", "alpha", "beta"}) {
t.Fatalf("ranked catalog = %v", got)
}
rankingFails.Store(true)
fallback := testClient(t, t.TempDir(), server)
entries, err = fallback.Catalog(context.Background())
if err != nil {
t.Fatal(err)
}
if got := entrySlugs(entries); !slices.Equal(got, []string{"alpha", "beta", "zeta"}) {
t.Fatalf("fallback catalog = %v", got)
}
}
func TestAssetPathAndSlugValidation(t *testing.T) {
client := NewClient(t.TempDir())
for _, raw := range []string{
"https://assets.petdex.dev/other/sprite.webp",
"https://assets.petdex.dev/pets/%2e%2e/secret",
"https://[email protected]/pets/boba/sprite.webp",
} {
if _, err := client.trustedAssetURL(raw); err == nil {
t.Errorf("trustedAssetURL(%q) unexpectedly succeeded", raw)
}
}
for _, slug := range []string{"../boba", "/boba", "Boba", "boba/other", ""} {
if err := validateSlug(slug); err == nil {
t.Errorf("validateSlug(%q) unexpectedly succeeded", slug)
}
}
}
func TestAssetRedirectToUntrustedHostIsRejectedBeforeFollowing(t *testing.T) {
redirect := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
http.Redirect(writer, request, "https://example.com/pets/boba/sprite.webp", http.StatusFound)
}))
defer redirect.Close()
parsed, _ := url.Parse(redirect.URL)
client := NewClient(t.TempDir())
client.HTTPClient = redirect.Client()
client.TrustedHosts = map[string]bool{parsed.Hostname(): true}
client.TrustedAssetHosts = map[string]bool{parsed.Hostname(): true}
if _, err := client.fetchAsset(context.Background(), redirect.URL+"/pets/boba/sprite.webp", maxSpriteBytes); err == nil {
t.Fatal("fetchAsset followed a redirect to an untrusted host")
}
}
func TestResolveAssetURLRejectsPathsOutsideCatalogRoots(t *testing.T) {
client := NewClient(t.TempDir())
for _, reference := range []string{"../secret.png", "/other/sprite.webp", `pets\\boba\\sprite.webp`} {
if _, err := client.resolveAssetURL("https://assets.petdex.dev", reference); err == nil {
t.Errorf("resolveAssetURL(%q) unexpectedly succeeded", reference)
}
}
}
func TestFetchAssetRejectsOversizedDownload(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte("12345"))
}))
defer server.Close()
client := testClient(t, t.TempDir(), server)
if _, err := client.fetchAsset(context.Background(), server.URL+"/pets/boba/sprite.webp", 4); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("oversized fetch error = %v", err)
}
}
func TestDecodeImageRejectsOversizedDimensions(t *testing.T) {
data := encodedPNG(t, maxImageSide+1, 1)
if _, err := decodeImage(data); err == nil || !strings.Contains(err.Error(), "dimensions") {
t.Fatalf("oversized image error = %v", err)
}
}
func TestAtlasAnimationRejectsWrongGeometry(t *testing.T) {
bad := image.NewNRGBA(image.Rect(0, 0, 192, 285))
if _, err := AtlasAnimation(bad, 2); err == nil {
t.Fatal("AtlasAnimation accepted a non-grid image")
}
if _, err := AtlasAnimation(image.NewNRGBA(image.Rect(0, 0, 192, 286)), 3); err == nil {
t.Fatal("AtlasAnimation accepted an unknown sprite version")
}
}
func TestAtlasAnimationUsesPerStateFrameCounts(t *testing.T) {
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
for column := 0; column < 6; column++ {
atlas.SetNRGBA(column*24, 0, color.NRGBA{R: 255, A: 255})
}
animation, err := AtlasAnimation(atlas, 1)
if err != nil {
t.Fatal(err)
}
// Idle has six frames: phase 6 wraps to frame zero instead of entering the
// two unused atlas columns, which are commonly transparent.
_, _, _, alpha := animation.Frame(Idle, 6).At(0, 0).RGBA()
if alpha == 0 {
t.Fatal("idle phase 6 selected an unused transparent atlas column")
}
}
func TestAtlasAnimationRunningRepeatsThreeTimesThenLoopsIdle(t *testing.T) {
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
for x := 0; x < 6*24; x++ {
for y := 0; y < 26; y++ {
atlas.SetNRGBA(x, y, color.NRGBA{R: 255, A: 255})
atlas.SetNRGBA(x, 7*26+y, color.NRGBA{B: 255, A: 255})
}
}
animation, err := AtlasAnimation(atlas, 1)
if err != nil {
t.Fatal(err)
}
// The six-frame running row is authored to play three times. The next
// frame is idle, and subsequent phases loop only the appended idle row.
running := color.NRGBAModel.Convert(animation.Frame(Running, 17).At(0, 0)).(color.NRGBA)
settled := color.NRGBAModel.Convert(animation.Frame(Running, 18).At(0, 0)).(color.NRGBA)
looped := color.NRGBAModel.Convert(animation.Frame(Running, 24).At(0, 0)).(color.NRGBA)
if running.B != 255 || settled.R != 255 || looped.R != 255 {
t.Fatalf("running transition colors = running:%#v settled:%#v looped:%#v", running, settled, looped)
}
if got := animation.FrameDelay(Running, 18); got != 1680*time.Millisecond {
t.Fatalf("first fallback idle delay = %s, want 1.68s", got)
}
if got := animation.PrimaryDuration(Waving); got != 2100*time.Millisecond {
t.Fatalf("waving primary duration = %s, want 2.1s", got)
}
if got := animation.PrimaryDuration(Jumping); got != 2520*time.Millisecond {
t.Fatalf("jumping primary duration = %s, want 2.52s", got)
}
}
func TestAtlasAnimationSuppliesDefaultTimingForMetadataWithoutTiming(t *testing.T) {
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
animation, err := atlasAnimation(atlas, 1, map[State]atlasTrack{
State("dash"): {row: 1, count: 3, fallbackIdle: true},
})
if err != nil {
t.Fatal(err)
}
if got := animation.FrameDelay(State("dash"), 0); got <= 0 {
t.Fatalf("metadata animation delay = %s, want a positive default", got)
}
}
func TestAtlasAnimationIncludesInteractiveStates(t *testing.T) {
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
animation, err := AtlasAnimation(atlas, 1)
if err != nil {
t.Fatal(err)
}
for _, state := range []State{MoveRight, MoveLeft, Waving, Jumping} {
if frame := animation.Frame(state, 0); frame == nil {
t.Fatalf("interactive state %q has no frame", state)
}
}
}
func testClient(t *testing.T, root string, server *httptest.Server) *Client {
t.Helper()
parsed, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
host := parsed.Hostname()
client := NewClient(root)
client.ManifestURL = server.URL + "/manifest"
client.RankingURL = server.URL + "/ranking"
client.HTTPClient = server.Client()
client.TrustedHosts = map[string]bool{host: true}
client.TrustedAssetHosts = map[string]bool{host: true}
return client
}
func entrySlugs(entries []Entry) []string {
result := make([]string, len(entries))
for index, entry := range entries {
result[index] = entry.Slug
}
return result
}
func encodedPNG(t *testing.T, width, height int) []byte {
t.Helper()
value := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
value.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: 180, A: 255})
}
}
var output bytes.Buffer
if err := png.Encode(&output, value); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
+421
View File
@@ -0,0 +1,421 @@
package terminalpet
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"image"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
xdraw "golang.org/x/image/draw"
"github.com/Gitlawb/zero/internal/installtxn"
)
const kittyChunkSize = 4096
type ImageProtocol uint8
const (
ImageProtocolNone ImageProtocol = iota
ImageProtocolKitty
ImageProtocolKittyLocalFile
ImageProtocolSixel
)
type ImageSupport struct {
Protocol ImageProtocol
Reason string
}
func (s ImageSupport) Supported() bool {
return s.Protocol != ImageProtocolNone
}
func DetectImageSupport(getenv func(string) string) ImageSupport {
if strings.TrimSpace(getenv("TMUX")) != "" || strings.TrimSpace(getenv("TMUX_PANE")) != "" {
return ImageSupport{Reason: "Terminal companions are disabled in tmux because terminal images are not reliably pane-local."}
}
if strings.TrimSpace(getenv("ZELLIJ")) != "" || strings.TrimSpace(getenv("ZELLIJ_SESSION_NAME")) != "" || strings.TrimSpace(getenv("ZELLIJ_VERSION")) != "" {
return ImageSupport{Reason: "Terminal companions are disabled in Zellij because terminal images are not reliably pane-local."}
}
if strings.TrimSpace(getenv("KITTY_WINDOW_ID")) != "" || strings.TrimSpace(getenv("WEZTERM_EXECUTABLE")) != "" || strings.TrimSpace(getenv("WEZTERM_VERSION")) != "" {
return ImageSupport{Protocol: ImageProtocolKitty}
}
term := strings.ToLower(getenv("TERM"))
program := strings.ToLower(getenv("TERM_PROGRAM"))
if strings.Contains(program, "iterm") {
if dottedVersionAtLeast(getenv("TERM_PROGRAM_VERSION"), 3, 6, 0) {
return ImageSupport{Protocol: ImageProtocolKittyLocalFile}
}
return ImageSupport{Reason: "Terminal companions require iTerm2 3.6 or newer."}
}
if strings.Contains(term, "ghostty") || strings.Contains(program, "ghostty") || strings.TrimSpace(getenv("GHOSTTY_RESOURCES_DIR")) != "" ||
strings.Contains(term, "kitty") || strings.Contains(program, "kitty") || strings.Contains(term, "wezterm") || strings.Contains(program, "wezterm") {
return ImageSupport{Protocol: ImageProtocolKitty}
}
if strings.TrimSpace(getenv("WT_SESSION")) != "" || strings.Contains(term, "sixel") || strings.Contains(term, "mlterm") || strings.Contains(term, "foot") {
return ImageSupport{Protocol: ImageProtocolSixel}
}
return ImageSupport{Reason: "Terminal companions need Kitty graphics or Sixel image support."}
}
type ImageDraw struct {
ID uint32
Animation *Animation
State State
Phase int
X int
Y int
OffsetX int
OffsetY int
Columns int
Rows int
HeightPixels int
}
type imageDrawKey struct {
protocol ImageProtocol
id uint32
animation *Animation
frame frameCacheKey
x int
y int
offsetX int
offsetY int
columns int
rows int
height int
}
type ImageRenderer struct {
mu sync.Mutex
support ImageSupport
cache string
desired *ImageDraw
last *imageDrawKey
}
func NewImageRenderer(support ImageSupport) *ImageRenderer {
return &ImageRenderer{support: support}
}
func NewImageRendererWithCache(support ImageSupport, cacheDir string) *ImageRenderer {
return &ImageRenderer{support: support, cache: strings.TrimSpace(cacheDir)}
}
func (r *ImageRenderer) Support() ImageSupport {
if r == nil {
return ImageSupport{Reason: "Terminal image rendering is unavailable."}
}
return r.support
}
func (r *ImageRenderer) Set(draw *ImageDraw) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if draw == nil {
r.desired = nil
return
}
copyValue := *draw
r.desired = &copyValue
}
func (r *ImageRenderer) Invalidate() {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.last = nil
}
func (r *ImageRenderer) Render(writer io.Writer) error {
if r == nil || writer == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if !r.support.Supported() {
return nil
}
var desiredKey *imageDrawKey
var pngBytes []byte
if r.desired != nil && r.desired.Animation != nil {
var frameKey frameCacheKey
var err error
pngBytes, frameKey, err = r.desired.Animation.framePNG(r.desired.State, r.desired.Phase)
if err != nil {
return err
}
desiredKey = &imageDrawKey{
protocol: r.support.Protocol, id: r.desired.ID, animation: r.desired.Animation, frame: frameKey,
x: r.desired.X, y: r.desired.Y, offsetX: r.desired.OffsetX, offsetY: r.desired.OffsetY,
columns: r.desired.Columns, rows: r.desired.Rows,
height: r.desired.HeightPixels,
}
}
if imageKeysEqual(r.last, desiredKey) {
return nil
}
if kittyImageContentEqual(r.last, desiredKey) {
if _, err := fmt.Fprintf(writer, "\x1b[s\x1b[%d;%dH%s\x1b[u", desiredKey.y+1, desiredKey.x+1, kittyPlace(desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
return err
}
copyKey := *desiredKey
r.last = &copyKey
return nil
}
if r.last != nil {
if err := clearRenderedImage(writer, *r.last); err != nil {
return err
}
}
if desiredKey == nil {
r.last = nil
return nil
}
if _, err := fmt.Fprintf(writer, "\x1b[s\x1b[%d;%dH", desiredKey.y+1, desiredKey.x+1); err != nil {
return err
}
switch r.support.Protocol {
case ImageProtocolKitty:
if _, err := io.WriteString(writer, kittyTransmitPNG(pngBytes, desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
return err
}
case ImageProtocolKittyLocalFile:
path, err := cachePNGFrame(r.cache, pngBytes)
if err != nil {
return err
}
if _, err := io.WriteString(writer, kittyTransmitPNGFile(path, desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
return err
}
case ImageProtocolSixel:
frame := r.desired.Animation.Frame(r.desired.State, r.desired.Phase)
sixel, err := renderSixel(frame, r.desired.HeightPixels)
if err != nil {
return err
}
if _, err := writer.Write(sixel); err != nil {
return err
}
default:
return nil
}
if _, err := io.WriteString(writer, "\x1b[u"); err != nil {
return err
}
copyKey := *desiredKey
r.last = &copyKey
return nil
}
func (r *ImageRenderer) Clear(writer io.Writer) error {
r.Set(nil)
return r.Render(writer)
}
// DeleteImages removes known application-owned Kitty image placements even when
// this renderer no longer considers them active. This is intended for process
// shutdown and recovery from a previous interrupted render.
func (r *ImageRenderer) DeleteImages(writer io.Writer, ids ...uint32) error {
if r == nil || writer == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
r.desired = nil
r.last = nil
if r.support.Protocol != ImageProtocolKitty && r.support.Protocol != ImageProtocolKittyLocalFile {
return nil
}
for _, id := range ids {
if _, err := io.WriteString(writer, kittyDelete(id)); err != nil {
return err
}
}
return nil
}
func imageKeysEqual(left, right *imageDrawKey) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func kittyImageContentEqual(left, right *imageDrawKey) bool {
if left == nil || right == nil || left.protocol != right.protocol {
return false
}
if left.protocol != ImageProtocolKitty && left.protocol != ImageProtocolKittyLocalFile {
return false
}
leftValue, rightValue := *left, *right
leftValue.x, leftValue.y = 0, 0
leftValue.offsetX, leftValue.offsetY = 0, 0
rightValue.x, rightValue.y = 0, 0
rightValue.offsetX, rightValue.offsetY = 0, 0
return leftValue == rightValue
}
func kittyDelete(id uint32) string {
return fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2;\x1b\\", id)
}
func clearRenderedImage(writer io.Writer, key imageDrawKey) (err error) {
if key.protocol == ImageProtocolKitty || key.protocol == ImageProtocolKittyLocalFile {
_, err := io.WriteString(writer, kittyDelete(key.id))
return err
}
if key.protocol != ImageProtocolSixel {
return nil
}
// Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty
// hands the terminal an id and the placement goes away; a sixel is already in
// the cell grid, so the only way to remove it is to write over it. That makes
// this the one path where the caller's SGR state matters, and it was not
// being normalised.
//
// The spaces below are ordinary characters, so they carry whatever background
// the TUI last set. Every erase therefore stamped a filled block in the
// ambient colour, and since a drag erases and repaints on every pixel, that
// block was continuously refreshed over the interface. It stayed invisible on
// a terminal whose ambient background matched its theme, which is why it
// never appeared on the Kitty-protocol terminals — ghostty, kitty, WezTerm,
// iTerm — that do not reach this code at all. Windows Terminal is the only
// mainstream terminal that lands here.
//
// DECSC/DECRC rather than CSI s/u: this now has to put back the SGR state as
// well as the cursor, and only DECSC saves attributes. Restoring them matters
// because the erase is emitted mid-frame, between styled writes the TUI has
// already started.
if _, err = io.WriteString(writer, "\x1b7\x1b[0m"); err != nil {
return err
}
defer func() {
if _, restoreErr := io.WriteString(writer, "\x1b8"); err == nil {
err = restoreErr
}
}()
for row := 0; row < key.rows; row++ {
if _, err = fmt.Fprintf(writer, "\x1b[%d;%dH%s", key.y+row+1, key.x+1, strings.Repeat(" ", key.columns)); err != nil {
return err
}
}
return nil
}
func kittyTransmitPNGFile(path string, id uint32, columns, rows, offsetX, offsetY int) string {
payload := base64.StdEncoding.EncodeToString([]byte(path))
return fmt.Sprintf("\x1b_Ga=T,t=f,f=100,c=%d,r=%d%s,q=2,C=1,i=%d,p=%d;%s\x1b\\", columns, rows, kittyOffsetControl(offsetX, offsetY), id, id, payload)
}
func kittyPlace(id uint32, columns, rows, offsetX, offsetY int) string {
return fmt.Sprintf("\x1b_Ga=p,i=%d,p=%d,c=%d,r=%d%s,q=2,C=1;\x1b\\", id, id, columns, rows, kittyOffsetControl(offsetX, offsetY))
}
func kittyOffsetControl(offsetX, offsetY int) string {
if offsetX == 0 && offsetY == 0 {
return ""
}
return fmt.Sprintf(",X=%d,Y=%d", offsetX, offsetY)
}
func cachePNGFrame(cacheDir string, pngBytes []byte) (string, error) {
if cacheDir == "" {
return "", fmt.Errorf("terminal pet image cache is unavailable")
}
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
return "", fmt.Errorf("create terminal pet image cache: %w", err)
}
digest := sha256.Sum256(pngBytes)
path := filepath.Join(cacheDir, fmt.Sprintf("%x.png", digest))
if _, err := os.Stat(path); err != nil {
if !os.IsNotExist(err) {
return "", fmt.Errorf("inspect cached terminal pet frame: %w", err)
}
if err := installtxn.WriteFileAtomically(path, pngBytes, 0o600); err != nil {
return "", fmt.Errorf("cache terminal pet frame: %w", err)
}
}
absolute, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolve cached terminal pet frame: %w", err)
}
return absolute, nil
}
func dottedVersionAtLeast(value string, major, minor, patch int) bool {
parts := strings.Split(strings.TrimSpace(value), ".")
if len(parts) == 0 || len(parts) > 3 {
return false
}
parsed := [3]int{}
for index, part := range parts {
digits := strings.TrimLeftFunc(part, func(value rune) bool { return value < '0' || value > '9' })
end := 0
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
end++
}
if end == 0 {
return false
}
number, err := strconv.Atoi(digits[:end])
if err != nil {
return false
}
parsed[index] = number
}
want := [3]int{major, minor, patch}
for index := range parsed {
if parsed[index] != want[index] {
return parsed[index] > want[index]
}
}
return true
}
func renderSixel(frame image.Image, heightPixels int) ([]byte, error) {
if frame == nil || frame.Bounds().Empty() {
return nil, fmt.Errorf("pet animation has no frame to encode")
}
if heightPixels < 1 {
heightPixels = 75
}
bounds := frame.Bounds()
widthPixels := max(1, bounds.Dx()*heightPixels/bounds.Dy())
scaled := image.NewNRGBA(image.Rect(0, 0, widthPixels, heightPixels))
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), frame, bounds, xdraw.Over, nil)
return encodeSixel(scaled)
}
func kittyTransmitPNG(pngBytes []byte, id uint32, columns, rows, offsetX, offsetY int) string {
payload := base64.StdEncoding.EncodeToString(pngBytes)
var output strings.Builder
for offset := 0; offset < len(payload); offset += kittyChunkSize {
end := min(offset+kittyChunkSize, len(payload))
more := 0
if end < len(payload) {
more = 1
}
if offset == 0 {
fmt.Fprintf(&output, "\x1b_Ga=T,t=d,f=100,c=%d,r=%d%s,q=2,C=1,i=%d,p=%d,m=%d;%s\x1b\\", columns, rows, kittyOffsetControl(offsetX, offsetY), id, id, more, payload[offset:end])
} else {
fmt.Fprintf(&output, "\x1b_Gm=%d;%s\x1b\\", more, payload[offset:end])
}
}
return output.String()
}
+173
View File
@@ -0,0 +1,173 @@
package terminalpet
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"os"
"strings"
"testing"
)
func TestDetectImageSupportUsesKittyGraphicsInGhostty(t *testing.T) {
env := map[string]string{
"TERM": "xterm-ghostty",
"TERM_PROGRAM": "ghostty",
}
support := DetectImageSupport(func(name string) string { return env[name] })
if support.Protocol != ImageProtocolKitty {
t.Fatalf("Ghostty protocol = %v, want Kitty", support.Protocol)
}
}
func TestDetectImageSupportRejectsTmuxBeforeTerminalProtocol(t *testing.T) {
env := map[string]string{"TMUX": "/tmp/tmux-1000/default,1,0", "TERM": "xterm-ghostty"}
support := DetectImageSupport(func(name string) string { return env[name] })
if support.Supported() || !strings.Contains(strings.ToLower(support.Reason), "tmux") {
t.Fatalf("tmux support = %#v, want unsupported tmux reason", support)
}
}
func TestDetectImageSupportUsesSixelInWindowsTerminal(t *testing.T) {
env := map[string]string{"WT_SESSION": "session-id", "TERM": "xterm-256color"}
support := DetectImageSupport(func(name string) string { return env[name] })
if support.Protocol != ImageProtocolSixel {
t.Fatalf("Windows Terminal protocol = %v, want Sixel", support.Protocol)
}
}
func TestDetectImageSupportUsesLocalKittyFilesInNewIterm(t *testing.T) {
env := map[string]string{"TERM_PROGRAM": "iTerm.app", "TERM_PROGRAM_VERSION": "3.6.1"}
support := DetectImageSupport(func(name string) string { return env[name] })
if support.Protocol != ImageProtocolKittyLocalFile {
t.Fatalf("iTerm2 protocol = %v, want Kitty local file", support.Protocol)
}
env["TERM_PROGRAM_VERSION"] = "3.5.9"
support = DetectImageSupport(func(name string) string { return env[name] })
if support.Supported() || !strings.Contains(support.Reason, "3.6") {
t.Fatalf("old iTerm2 support = %#v, want version error", support)
}
}
func TestImageRendererTransmitsPNGAndDeletesItWhenCleared(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
frame.SetNRGBA(0, 0, color.NRGBA{R: 240, G: 80, B: 120, A: 255})
animation, err := ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolKitty})
renderer.Set(&ImageDraw{
ID: 0xC0DF,
Animation: animation,
State: Idle,
Columns: 11,
Rows: 6,
X: 20,
Y: 10,
})
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
got := output.String()
for _, want := range []string{"\x1b[11;21H", "_Ga=T,t=d,f=100,c=11,r=6,q=2,C=1,i=49375,p=49375", "iVBOR"} {
if !strings.Contains(got, want) {
t.Fatalf("Kitty output does not contain %q: %q", want, got)
}
}
output.Reset()
renderer.Set(nil)
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "_Ga=d,d=I,i=49375,q=2") {
t.Fatalf("clear output did not delete image: %q", got)
}
}
func TestImageRendererMovesExistingKittyPlacementWithoutRetransmitting(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
frame.SetNRGBA(0, 0, color.NRGBA{R: 240, A: 255})
animation, err := ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolKitty})
draw := &ImageDraw{ID: 31, Animation: animation, State: Idle, Columns: 4, Rows: 3, X: 2, Y: 3}
renderer.Set(draw)
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
output.Reset()
draw.X, draw.Y = 18, 9
draw.OffsetX, draw.OffsetY = 3, 7
renderer.Set(draw)
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "\x1b[10;19H") || !strings.Contains(got, "_Ga=p,i=31,p=31,c=4,r=3,X=3,Y=7,q=2,C=1;") {
t.Fatalf("placement-only move missing: %q", got)
}
if strings.Contains(got, "a=T") || strings.Contains(got, "iVBOR") || strings.Contains(got, "a=d") {
t.Fatalf("placement-only move retransmitted or deleted image data: %q", got)
}
}
func TestImageRendererEmitsSixelForSixelTerminal(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
frame.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
animation, err := ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolSixel})
renderer.Set(&ImageDraw{ID: 9, Animation: animation, State: Idle, Columns: 4, Rows: 3, HeightPixels: 12})
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "\x1bP9;1;0q") || !strings.HasSuffix(got, "\x1b\\\x1b[u") {
t.Fatalf("Sixel output is missing its image payload: %q", got)
}
if strings.Contains(got, "_Ga=T") {
t.Fatalf("Sixel renderer emitted Kitty graphics: %q", got)
}
}
func TestImageRendererUsesLocalPNGForIterm(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
frame.SetNRGBA(0, 0, color.NRGBA{G: 255, A: 255})
animation, err := ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := NewImageRendererWithCache(ImageSupport{Protocol: ImageProtocolKittyLocalFile}, t.TempDir())
renderer.Set(&ImageDraw{ID: 11, Animation: animation, State: Idle, Columns: 4, Rows: 3})
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
got := output.String()
prefix := "_Ga=T,t=f,f=100,c=4,r=3,q=2,C=1,i=11,p=11;"
start := strings.Index(got, prefix)
if start < 0 {
t.Fatalf("local-file command missing: %q", got)
}
payload := got[start+len(prefix):]
payload = strings.SplitN(payload, "\x1b\\", 2)[0]
pathBytes, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatalf("decode local-file payload: %v", err)
}
if _, err := os.Stat(string(pathBytes)); err != nil {
t.Fatalf("cached PNG path is unavailable: %v", err)
}
}
+375
View File
@@ -0,0 +1,375 @@
package terminalpet
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"strings"
"sync"
"time"
)
const (
DisabledID = "disabled"
DefaultManifestURL = "https://petdex.dev/api/manifest/v2"
DefaultRankingURL = "https://petdex.dev/api/pets/search?sort=installed&limit=60&includeMeta=0"
TrustedAssetHost = "assets.petdex.dev"
previewFrameCount = 6
atlasColumns = 8
)
type State string
const (
Idle State = "idle"
MoveRight State = "running-right"
MoveLeft State = "running-left"
Waving State = "waving"
Jumping State = "jumping"
Running State = "running"
Waiting State = "waiting"
Review State = "review"
Failed State = "failed"
)
type Entry struct {
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Kind string `json:"kind,omitempty"`
SubmittedBy string `json:"submittedBy,omitempty"`
SpritesheetURL string `json:"spritesheet"`
PetJSONURL string `json:"petJson,omitempty"`
AssetBase string `json:"assetBase,omitempty"`
SpriteVersion int `json:"spriteVersionNumber"`
Local bool `json:"-"`
}
func (e Entry) Label() string {
if name := strings.TrimSpace(e.DisplayName); name != "" {
return name
}
return e.Slug
}
type Animation struct {
frames map[State][]image.Image
durations map[State][]time.Duration
loopStarts map[State]int
clickAnimations []State
pngMu sync.Mutex
pngCache map[frameCacheKey][]byte
}
type atlasTrack struct {
row int
count int
durations []time.Duration
repeat int
loop bool
fallbackIdle bool
}
type frameCacheKey struct {
state State
index int
}
func (a *Animation) Frame(state State, phase int) image.Image {
frame, _ := a.frame(state, phase)
return frame
}
func (a *Animation) FrameDelay(state State, phase int) time.Duration {
if a == nil {
return 0
}
frames := a.frames[state]
if len(frames) == 0 {
state = Idle
frames = a.frames[state]
}
if len(frames) == 0 {
return 0
}
durations := a.durations[state]
if len(durations) != len(frames) {
return 0
}
return durations[a.frameIndex(state, phase, len(frames))]
}
// PrimaryDuration reports how long a non-idle action plays before it settles
// into its fallback loop. A zero duration means the state loops from its first
// frame or has no configured playback boundary.
func (a *Animation) PrimaryDuration(state State) time.Duration {
if a == nil {
return 0
}
loopStart, ok := a.loopStarts[state]
durations := a.durations[state]
if !ok || loopStart == 0 || loopStart > len(durations) || len(durations) == 0 {
return 0
}
if loopStart < 0 {
loopStart = len(durations)
}
var total time.Duration
for _, duration := range durations[:loopStart] {
total += duration
}
return total
}
// ClickDuration reports the duration of an authored click action.
// The boolean is false for ordinary activity states and built-in interactions.
func (a *Animation) ClickDuration(state State) (time.Duration, bool) {
if a == nil {
return 0, false
}
isClick := false
for _, candidate := range a.clickAnimations {
if candidate == state {
isClick = true
break
}
}
if !isClick {
return 0, false
}
return a.PrimaryDuration(state), true
}
func (a *Animation) ClickAnimation(index int) (State, bool) {
if a == nil || len(a.clickAnimations) == 0 {
return "", false
}
if index < 0 {
index = -index
}
return a.clickAnimations[index%len(a.clickAnimations)], true
}
func (a *Animation) setClickAnimations(names []string) {
if a == nil {
return
}
a.clickAnimations = a.clickAnimations[:0]
seen := make(map[State]bool, len(names))
for _, name := range names {
state := State(strings.ToLower(strings.TrimSpace(name)))
if state == "" || seen[state] || len(a.frames[state]) == 0 {
continue
}
seen[state] = true
a.clickAnimations = append(a.clickAnimations, state)
}
}
func (a *Animation) frame(state State, phase int) (image.Image, frameCacheKey) {
if a == nil {
return nil, frameCacheKey{}
}
frames := a.frames[state]
if len(frames) == 0 {
state = Idle
frames = a.frames[Idle]
}
if len(frames) == 0 {
return nil, frameCacheKey{}
}
index := a.frameIndex(state, phase, len(frames))
return frames[index], frameCacheKey{state: state, index: index}
}
func (a *Animation) frameIndex(state State, phase, frameCount int) int {
if frameCount < 1 {
return 0
}
if phase < 0 {
phase = -phase
}
loopStart, configured := a.loopStarts[state]
if !configured {
return phase % frameCount
}
if phase < frameCount {
return phase
}
if loopStart >= 0 && loopStart < frameCount {
return loopStart + (phase-loopStart)%(frameCount-loopStart)
}
return frameCount - 1
}
func (a *Animation) framePNG(state State, phase int) ([]byte, frameCacheKey, error) {
frame, key := a.frame(state, phase)
if frame == nil {
return nil, key, fmt.Errorf("pet animation has no frames")
}
a.pngMu.Lock()
defer a.pngMu.Unlock()
if cached := a.pngCache[key]; len(cached) > 0 {
return cached, key, nil
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, frame); err != nil {
return nil, key, fmt.Errorf("encode pet frame: %w", err)
}
if a.pngCache == nil {
a.pngCache = make(map[frameCacheKey][]byte)
}
value := append([]byte(nil), encoded.Bytes()...)
a.pngCache[key] = value
return value, key, nil
}
func PreviewAnimation(sheet image.Image) (*Animation, error) {
if sheet == nil {
return nil, fmt.Errorf("preview image is empty")
}
bounds := sheet.Bounds()
if bounds.Dx() < previewFrameCount || bounds.Dx()%previewFrameCount != 0 || bounds.Dy() < 1 {
return nil, fmt.Errorf("preview must contain %d equal-width frames", previewFrameCount)
}
frames := splitRow(sheet, previewFrameCount, 0, bounds.Dy())
return &Animation{
frames: map[State][]image.Image{Idle: frames},
durations: map[State][]time.Duration{Idle: repeatedDurations(len(frames), 140*time.Millisecond)},
}, nil
}
func ThumbnailAnimation(imageValue image.Image) (*Animation, error) {
if imageValue == nil || imageValue.Bounds().Empty() {
return nil, fmt.Errorf("thumbnail image is empty")
}
return &Animation{frames: map[State][]image.Image{Idle: {imageValue}}}, nil
}
func AtlasAnimation(sheet image.Image, spriteVersion int) (*Animation, error) {
return atlasAnimation(sheet, spriteVersion, nil)
}
func atlasAnimation(sheet image.Image, spriteVersion int, overrides map[State]atlasTrack) (*Animation, error) {
if sheet == nil {
return nil, fmt.Errorf("spritesheet is empty")
}
rows := 9
if spriteVersion == 2 {
rows = 11
} else if spriteVersion != 0 && spriteVersion != 1 {
return nil, fmt.Errorf("unsupported sprite version %d", spriteVersion)
}
bounds := sheet.Bounds()
if bounds.Dx()%atlasColumns != 0 || bounds.Dy()%rows != 0 {
return nil, fmt.Errorf("spritesheet must be an 8x%d grid", rows)
}
frameWidth := bounds.Dx() / atlasColumns
frameHeight := bounds.Dy() / rows
if frameWidth < 1 || frameHeight < 1 || frameWidth*208 != frameHeight*192 {
return nil, fmt.Errorf("spritesheet frames must use the 192x208 aspect ratio")
}
stateRows := map[State]atlasTrack{
Idle: {row: 0, count: 6, durations: durations(1680, 660, 660, 840, 840, 1920), loop: true},
MoveRight: {row: 1, count: 8, durations: finalFrameDurations(8, 120, 220), repeat: 3, fallbackIdle: true},
MoveLeft: {row: 2, count: 8, durations: finalFrameDurations(8, 120, 220), repeat: 3, fallbackIdle: true},
Waving: {row: 3, count: 4, durations: finalFrameDurations(4, 140, 280), repeat: 3, fallbackIdle: true},
Jumping: {row: 4, count: 5, durations: finalFrameDurations(5, 140, 280), repeat: 3, fallbackIdle: true},
Failed: {row: 5, count: 8, durations: finalFrameDurations(8, 140, 240), repeat: 3, fallbackIdle: true},
Waiting: {row: 6, count: 6, durations: finalFrameDurations(6, 150, 260), repeat: 3, fallbackIdle: true},
Running: {row: 7, count: 6, durations: finalFrameDurations(6, 120, 220), repeat: 3, fallbackIdle: true},
Review: {row: 8, count: 6, durations: finalFrameDurations(6, 150, 280), repeat: 3, fallbackIdle: true},
}
for state, override := range overrides {
stateRows[state] = override
}
rawFrames := make(map[State][]image.Image, len(stateRows))
for state, spec := range stateRows {
if spec.row < 0 || spec.row >= rows || spec.count < 1 || spec.count > atlasColumns {
return nil, fmt.Errorf("invalid %s animation geometry", state)
}
if len(spec.durations) == 0 {
spec.durations = repeatedDurations(spec.count, 180*time.Millisecond)
stateRows[state] = spec
} else if len(spec.durations) != spec.count {
return nil, fmt.Errorf("%s animation has %d durations for %d frames", state, len(spec.durations), spec.count)
}
rawFrames[state] = splitRow(sheet, atlasColumns, spec.row*frameHeight, frameHeight)[:spec.count]
}
idleFrames := rawFrames[Idle]
idleDurations := stateRows[Idle].durations
frames := make(map[State][]image.Image, len(stateRows))
frameDurations := make(map[State][]time.Duration, len(stateRows))
loopStarts := make(map[State]int, len(stateRows))
for state, spec := range stateRows {
repeat := max(1, spec.repeat)
for range repeat {
frames[state] = append(frames[state], rawFrames[state]...)
if len(spec.durations) == spec.count {
frameDurations[state] = append(frameDurations[state], spec.durations...)
}
}
switch {
case spec.fallbackIdle && state != Idle:
loopStarts[state] = len(frames[state])
frames[state] = append(frames[state], idleFrames...)
frameDurations[state] = append(frameDurations[state], idleDurations...)
case spec.loop:
loopStarts[state] = 0
default:
loopStarts[state] = -1
}
}
return &Animation{frames: frames, durations: frameDurations, loopStarts: loopStarts}, nil
}
func durations(milliseconds ...int) []time.Duration {
result := make([]time.Duration, len(milliseconds))
for index, value := range milliseconds {
result[index] = time.Duration(value) * time.Millisecond
}
return result
}
func repeatedDurations(count int, duration time.Duration) []time.Duration {
result := make([]time.Duration, count)
for index := range result {
result[index] = duration
}
return result
}
func finalFrameDurations(count, regularMilliseconds, finalMilliseconds int) []time.Duration {
result := repeatedDurations(count, time.Duration(regularMilliseconds)*time.Millisecond)
if len(result) > 0 {
result[len(result)-1] = time.Duration(finalMilliseconds) * time.Millisecond
}
return result
}
func splitRow(sheet image.Image, count, y, height int) []image.Image {
bounds := sheet.Bounds()
width := bounds.Dx() / count
frames := make([]image.Image, 0, count)
for index := 0; index < count; index++ {
frames = append(frames, cropImage{
source: sheet,
bounds: image.Rect(0, 0, width, height),
offset: image.Pt(bounds.Min.X+index*width, bounds.Min.Y+y),
})
}
return frames
}
type cropImage struct {
source image.Image
bounds image.Rectangle
offset image.Point
}
func (c cropImage) ColorModel() color.Model { return c.source.ColorModel() }
func (c cropImage) Bounds() image.Rectangle { return c.bounds }
func (c cropImage) At(x, y int) color.Color {
return c.source.At(x+c.offset.X, y+c.offset.Y)
}
+38
View File
@@ -0,0 +1,38 @@
package terminalpet
import (
"image"
"testing"
"time"
)
func TestClickDurationEndsOnceAnimationWithoutGenericHold(t *testing.T) {
action := State("custom-action")
animation := &Animation{
durations: map[State][]time.Duration{
action: {200 * time.Millisecond, 100 * time.Millisecond, 300 * time.Millisecond, 200 * time.Millisecond},
},
loopStarts: map[State]int{action: -1},
clickAnimations: []State{action},
}
if got := animation.PrimaryDuration(action); got != 800*time.Millisecond {
t.Fatalf("once-action duration = %s, want 800ms", got)
}
if got, ok := animation.ClickDuration(action); !ok || got != 800*time.Millisecond {
t.Fatalf("click duration = %s, %t; want 800ms, true", got, ok)
}
if got, ok := animation.ClickDuration(Running); ok || got != 0 {
t.Fatalf("ordinary activity duration = %s, %t; want 0, false", got, ok)
}
}
func TestAtlasAnimationRejectsMismatchedOverrideDurations(t *testing.T) {
sheet := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
_, err := atlasAnimation(sheet, 1, map[State]atlasTrack{
Idle: {row: 0, count: 2, durations: []time.Duration{100 * time.Millisecond}},
})
if err == nil {
t.Fatal("atlasAnimation accepted mismatched override durations")
}
}
+93
View File
@@ -0,0 +1,93 @@
package terminalpet
import (
"fmt"
"image"
"sort"
"strings"
)
const sixelBandHeight = 6
func encodeSixel(source image.Image) ([]byte, error) {
if source == nil || source.Bounds().Empty() {
return nil, fmt.Errorf("sixel image dimensions must be non-zero")
}
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
pixels := make([]int16, width*height)
for index := range pixels {
pixels[index] = -1
}
used := make(map[uint8]struct{})
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
red, green, blue, alpha := source.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
if alpha>>8 < 128 {
continue
}
index := uint8((red>>13)<<5 | (green>>13)<<2 | blue>>14)
pixels[y*width+x] = int16(index)
used[index] = struct{}{}
}
}
colors := make([]int, 0, len(used))
for index := range used {
colors = append(colors, int(index))
}
sort.Ints(colors)
var output strings.Builder
output.WriteString("\x1bP9;1;0q")
fmt.Fprintf(&output, "\"1;1;%d;%d", width, height)
for _, index := range colors {
red := ((index >> 5) & 7) * 100 / 7
green := ((index >> 2) & 7) * 100 / 7
blue := (index & 3) * 100 / 3
fmt.Fprintf(&output, "#%d;2;%d;%d;%d", index, red, green, blue)
}
for bandTop := 0; bandTop < height; bandTop += sixelBandHeight {
active := activeSixelColors(pixels, width, height, bandTop, colors)
for colorPosition, colorIndex := range active {
fmt.Fprintf(&output, "#%d", colorIndex)
for x := 0; x < width; x++ {
mask := byte(0)
for bit := 0; bit < sixelBandHeight && bandTop+bit < height; bit++ {
if pixels[(bandTop+bit)*width+x] == int16(colorIndex) {
mask |= 1 << bit
}
}
output.WriteByte('?' + mask)
}
if colorPosition+1 < len(active) {
output.WriteByte('$')
}
}
if bandTop+sixelBandHeight < height {
if len(active) > 0 {
output.WriteByte('$')
}
output.WriteByte('-')
}
}
output.WriteString("\x1b\\")
return []byte(output.String()), nil
}
func activeSixelColors(pixels []int16, width, height, bandTop int, colors []int) []int {
activeSet := make(map[int16]struct{})
for y := bandTop; y < min(height, bandTop+sixelBandHeight); y++ {
for x := 0; x < width; x++ {
if color := pixels[y*width+x]; color >= 0 {
activeSet[color] = struct{}{}
}
}
}
active := make([]int, 0, len(activeSet))
for _, color := range colors {
if _, ok := activeSet[int16(color)]; ok {
active = append(active, color)
}
}
return active
}
+113
View File
@@ -0,0 +1,113 @@
package terminalpet
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
)
// A sixel erase paints characters, so it must not inherit the caller's colours.
//
// This is the black rectangle reported on Windows Terminal. The erase writes
// literal spaces over the cells the image occupied, and those spaces are drawn
// with whatever SGR was last set; without a reset every erase stamps a filled
// block in the ambient background. A drag erases and repaints on every pixel, so
// the block is continuously refreshed over the interface.
//
// It never showed on ghostty, kitty, WezTerm or iTerm because those resolve to
// the Kitty protocol and delete by image id, never reaching this path. Windows
// Terminal is the only mainstream terminal that does, which is why the platform
// looked at fault when the real cause is an untested code path.
func TestSixelEraseDoesNotPaintWithTheCallersColours(t *testing.T) {
var out bytes.Buffer
key := imageDrawKey{protocol: ImageProtocolSixel, x: 40, y: 2, columns: 6, rows: 3}
if err := clearRenderedImage(&out, key); err != nil {
t.Fatalf("clearRenderedImage: %v", err)
}
got := out.String()
reset := strings.Index(got, "\x1b[0m")
if reset < 0 {
t.Fatalf("the erase never resets SGR, so it paints a block in the ambient background: %q", got)
}
// Before the first space, not merely present somewhere.
if firstSpace := strings.Index(got, strings.Repeat(" ", key.columns)); firstSpace >= 0 && reset > firstSpace {
t.Errorf("SGR is reset only after the first row is painted, so that row still carries the caller's colours: %q", got)
}
// DECSC/DECRC, because only those restore the attributes this now clobbers.
// CSI s/u save the cursor alone and would leave the TUI drawing in the reset
// state for the remainder of the frame.
if !strings.HasPrefix(got, "\x1b7") {
t.Errorf("erase must open with DECSC to save cursor AND attributes, got %q", got)
}
if !strings.HasSuffix(got, "\x1b8") {
t.Errorf("erase must close with DECRC to restore them, got %q", got)
}
}
// The erase still covers exactly the cells the image claimed, or the fix above
// would be satisfied by an erase that does nothing.
func TestSixelEraseStillCoversEveryClaimedCell(t *testing.T) {
var out bytes.Buffer
key := imageDrawKey{protocol: ImageProtocolSixel, x: 40, y: 2, columns: 6, rows: 3}
if err := clearRenderedImage(&out, key); err != nil {
t.Fatalf("clearRenderedImage: %v", err)
}
got := out.String()
for row := 0; row < key.rows; row++ {
position := fmt.Sprintf("\x1b[%d;%dH", key.y+row+1, key.x+1)
erase := position + strings.Repeat(" ", key.columns)
if !strings.Contains(got, erase) {
t.Fatalf("erase does not position row %d at %q: %q", row, position, got)
}
}
if strings.Count(got, strings.Repeat(" ", key.columns)) != key.rows {
t.Errorf("erase covered %d rows of %d columns, want %d rows: %q",
strings.Count(got, strings.Repeat(" ", key.columns)), key.columns, key.rows, got)
}
}
func TestSixelEraseRestoresTerminalStateAfterRowWriteFailure(t *testing.T) {
rowErr := errors.New("row write failed")
writer := &failOnceWriter{failOnWrite: 2, err: rowErr}
key := imageDrawKey{protocol: ImageProtocolSixel, x: 4, y: 2, columns: 3, rows: 2}
err := clearRenderedImage(writer, key)
if !errors.Is(err, rowErr) {
t.Fatalf("clearRenderedImage error = %v, want primary row error", err)
}
if !strings.HasSuffix(writer.String(), "\x1b8") {
t.Fatalf("terminal state was not restored after row failure: %q", writer.String())
}
}
type failOnceWriter struct {
bytes.Buffer
writes int
failOnWrite int
err error
}
func (w *failOnceWriter) Write(value []byte) (int, error) {
w.writes++
if w.writes == w.failOnWrite {
return 0, w.err
}
return w.Buffer.Write(value)
}
// Kitty must NOT gain the character-painting erase: it deletes by image id, and
// writing spaces there would erase cells the terminal is about to redraw itself.
func TestKittyEraseStillDeletesByImageID(t *testing.T) {
for _, protocol := range []ImageProtocol{ImageProtocolKitty, ImageProtocolKittyLocalFile} {
var out bytes.Buffer
if err := clearRenderedImage(&out, imageDrawKey{protocol: protocol, id: 7, columns: 6, rows: 3}); err != nil {
t.Fatalf("clearRenderedImage: %v", err)
}
if got := out.String(); strings.Contains(got, " ") || !strings.Contains(got, "\x1b_G") {
t.Errorf("protocol %v should delete by id, not paint cells: %q", protocol, got)
}
}
}
+5 -2
View File
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
return applyPatchTool{
baseTool: baseTool{
name: "apply_patch",
description: "Apply a unified diff patch inside the workspace or an explicitly granted extra write root.",
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"patch": {Type: "string", Description: "Unified diff patch to apply."},
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch patch to apply."},
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
},
Required: []string{"patch"},
@@ -56,6 +56,9 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if isStructuredPatch(patch) {
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
}
if err := validatePatchPaths(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
-8
View File
@@ -162,11 +162,3 @@ func ValidateCapabilities(name string, caps ToolCapabilities) []string {
}
return problems
}
// ValidateToolCapabilities validates CapabilitiesOf(tool) for a registered tool.
func ValidateToolCapabilities(tool Tool) []string {
if tool == nil {
return []string{"<nil tool>"}
}
return ValidateCapabilities(tool.Name(), CapabilitiesOf(tool))
}
+19
View File
@@ -231,6 +231,25 @@ func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
}
}
func TestApplyPatchResourceKeysFromStructuredPatchAndCwd(t *testing.T) {
patch := "*** Begin Patch\n*** Update File: old.go\n*** Move to: new.go\n@@\n-old\n+new\n*** End Patch\n"
keys := applyPatchResourceKeys(map[string]any{"patch": patch, "cwd": "pkg"})
want := map[string]bool{
ResourceKeyDirectory + "pkg": true,
ResourceKeyFile + "pkg/old.go": true,
ResourceKeyFile + "pkg/new.go": true,
}
for _, key := range keys {
if !want[key] {
t.Fatalf("unexpected key %q in %v", key, keys)
}
delete(want, key)
}
if len(want) != 0 {
t.Fatalf("missing keys %v from %v", want, keys)
}
}
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
// must still see the raw declaration so miswired constructors fail the gate.
+106
View File
@@ -221,6 +221,112 @@ func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
}
}
func TestStructuredPatchDoesNotCreditUnreadFileForOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-beta",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if tracker.SeenWhole(trackedPath) {
t.Fatal("a partial structured patch credited the unread file as fully seen")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "blind overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
t.Fatalf("blind overwrite after a partial patch was not refused: %#v", result)
}
if got, want := mustReadTestFile(t, path), "alpha\nupdated\ngamma\n"; got != want {
t.Fatalf("file = %q, want %q", got, want)
}
}
func TestStructuredPatchPreservesWholeFileObservation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
t.Fatalf("read_file failed: %s", result.Output)
}
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-alpha",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(trackedPath) {
t.Fatal("structured patch discarded an existing whole-file observation")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "known overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusOK {
t.Fatalf("overwrite after a whole-file read was refused: %s", result.Output)
}
}
func TestStructuredPatchCreationCreditsSuppliedFile(t *testing.T) {
dir := t.TempDir()
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: created.txt",
"+supplied",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
path, err := filepath.EvalSymlinks(filepath.Join(dir, "created.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(path) {
t.Fatal("a fully supplied structured add was not credited as fully seen")
}
}
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
+4 -2
View File
@@ -78,14 +78,16 @@ func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenCon
if !ok {
return writtenContent
}
if _, err := exec.LookPath(command[0]); err != nil {
binaryPath, err := exec.LookPath(command[0])
if err != nil {
return writtenContent
}
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
defer cancel()
arguments := append(append([]string(nil), command[1:]...), absolutePath)
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
formatter := exec.CommandContext(formatCtx, binaryPath, arguments...)
formatter.Dir = filepath.Dir(absolutePath)
formatter.Stdin = strings.NewReader("")
if err := formatter.Run(); err != nil {
return writtenContent
}
+14
View File
@@ -102,3 +102,17 @@ func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
t.Fatalf("unknown extension must pass through: %q", content)
}
}
func TestFormatOnWriteFormatterLookupFailure(t *testing.T) {
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
t.Setenv("PATH", t.TempDir())
targetPath := filepath.Join(t.TempDir(), "a.go")
uglyContent := "package a\n\nfunc A( ) { }\n"
if err := os.WriteFile(targetPath, []byte(uglyContent), 0o644); err != nil {
t.Fatal(err)
}
content := maybeFormatWrittenFile(context.Background(), targetPath, uglyContent)
if content != uglyContent {
t.Fatalf("missing formatter must return written content, got %q", content)
}
}
+9
View File
@@ -0,0 +1,9 @@
package tools
// ModelVisible reports whether a registered tool is advertised to an agent.
// edit_file remains implemented for compatibility with existing integrations,
// but its text-only replacement contract cannot safely disambiguate repeated
// source fragments. Agents use apply_patch for contextual existing-file edits.
func ModelVisible(tool Tool) bool {
return tool != nil && tool.Name() != "edit_file"
}
+18
View File
@@ -0,0 +1,18 @@
package tools
import "testing"
func TestToolSearchDoesNotSurfaceLegacyStringReplacement(t *testing.T) {
registry := NewRegistry()
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
search := NewToolSearchTool(registry).(toolSearchTool)
eager := search.visibleEagerToolNames(nil, nil, "ask")
if eager["edit_file"] {
t.Fatal("tool_search must not describe edit_file as available to the model")
}
if !eager["apply_patch"] {
t.Fatal("tool_search must retain apply_patch as the available edit tool")
}
}
+34
View File
@@ -1,5 +1,7 @@
package tools
import "path/filepath"
// MutationTargets returns the workspace-relative paths a tool call will write to,
// so the session layer can snapshot their before-state for safe rewind. It is a
// pure helper (no I/O beyond path resolution) and returns nil for read-only tools
@@ -36,6 +38,19 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
if err != nil {
return nil
}
if isStructuredPatch(patch) {
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
paths := structuredPatchOperationPaths(operations)
for _, path := range paths {
if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil {
return nil
}
}
return prefixPatchPaths(relativeRoot, paths)
}
// Enforce the same workspace confinement apply_patch applies (against the
// resolved apply dir), so a patch with a traversal path (../x) never yields
// an out-of-workspace target.
@@ -51,3 +66,22 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
return nil
}
}
func prefixPatchPaths(relativeRoot string, paths []string) []string {
if len(paths) == 0 {
return nil
}
seen := make(map[string]bool)
var out []string
for _, path := range paths {
workspacePath := path
if relativeRoot != "" && relativeRoot != "." {
workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[workspacePath] {
seen[workspacePath] = true
out = append(out, workspacePath)
}
}
return out
}
+11
View File
@@ -17,6 +17,7 @@ func TestMutationTargets(t *testing.T) {
{"write_file", map[string]any{"path": "a/b.txt", "content": "x"}, []string{"a/b.txt"}},
{"edit_file", map[string]any{"path": "c.txt", "old_string": "x", "new_string": "y"}, []string{"c.txt"}},
{"apply_patch", map[string]any{"patch": "--- a/d.txt\n+++ b/d.txt\n@@ -1 +1 @@\n-x\n+y\n"}, []string{"d.txt"}},
{"apply_patch", map[string]any{"patch": "*** Begin Patch\n*** Add File: nested/new.txt\n+x\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-old\n+new\n*** End Patch\n"}, []string{"nested/new.txt", "old.txt", "moved.txt"}},
{"bash", map[string]any{"command": "echo hi"}, nil},
{"read_file", map[string]any{"path": "e.txt"}, nil},
{"grep", map[string]any{"pattern": "x"}, nil},
@@ -62,6 +63,10 @@ func TestMutationTargetsRejectsEscapingPaths(t *testing.T) {
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": escapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping apply_patch, got %v", got)
}
structuredEscapePatch := "*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n"
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": structuredEscapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping structured apply_patch, got %v", got)
}
}
// Finding 3: with cwd != ".", apply_patch's MutationTargets must return
@@ -82,6 +87,12 @@ func TestMutationTargetsApplyPatchPrefixesCwd(t *testing.T) {
t.Fatalf("got %v, want %v", got, want)
}
structuredPatch := "*** Begin Patch\n*** Update File: d.txt\n@@\n-x\n+y\n*** End Patch\n"
got = MutationTargets(root, "apply_patch", map[string]any{"patch": structuredPatch, "cwd": "sub/dir"})
if !reflect.DeepEqual(got, want) {
t.Fatalf("structured cwd: got %v, want %v", got, want)
}
// cwd "." (the default) must keep paths unprefixed.
got = MutationTargets(root, "apply_patch", map[string]any{"patch": patch, "cwd": "."})
if !reflect.DeepEqual(got, []string{"d.txt"}) {
+12 -1
View File
@@ -178,7 +178,7 @@ func applyPatchResourceKeys(args map[string]any) []string {
keys = append(keys, multiFileResourceKeys(args)...)
cwd := firstStringArg(args, "cwd", "workdir")
patch := firstStringArg(args, "patch", "diff")
for _, p := range pathsFromUnifiedDiff(patch) {
for _, p := range pathsFromApplyPatch(patch) {
joined := joinUnderResourceCwd(cwd, p)
if n := NormalizeResourcePath(joined); n != "" {
keys = append(keys, ResourceKeyFile+n)
@@ -191,6 +191,17 @@ func applyPatchResourceKeys(args map[string]any) []string {
return keys
}
func pathsFromApplyPatch(patch string) []string {
if !isStructuredPatch(patch) {
return pathsFromUnifiedDiff(patch)
}
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
return structuredPatchOperationPaths(operations)
}
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
// Never touches the filesystem.
+763
View File
@@ -0,0 +1,763 @@
package tools
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/pathjail"
)
const (
structuredPatchBegin = "*** Begin Patch"
structuredPatchEnd = "*** End Patch"
structuredAddFile = "*** Add File: "
structuredDeleteFile = "*** Delete File: "
structuredUpdateFile = "*** Update File: "
structuredMoveTo = "*** Move to: "
structuredEndOfFile = "*** End of File"
structuredHunkContext = "@@ "
)
type structuredPatchKind uint8
const (
structuredPatchAdd structuredPatchKind = iota
structuredPatchDelete
structuredPatchUpdate
)
type structuredPatchOperation struct {
kind structuredPatchKind
path string
movePath string
contents string
chunks []structuredPatchChunk
line int
}
type structuredPatchChunk struct {
context string
hasContext bool
old []string
new []string
newSourceOffsets []int
endOfFile bool
}
type structuredPatchTarget struct {
requested string
absolute string
relative string
}
type structuredPatchChange struct {
kind structuredPatchKind
from structuredPatchTarget
to structuredPatchTarget
before string
after string
mode os.FileMode
}
func isStructuredPatch(patch string) bool {
return strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), structuredPatchBegin)
}
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
operations, err := parseStructuredPatch(patch)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
workspace, err := os.OpenRoot(applyRoot)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
defer workspace.Close()
changes, err := planStructuredPatch(workspace, operations, options.FileTracker)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
wholeBefore := make(map[string]bool, len(changes))
if options.FileTracker != nil {
for _, change := range changes {
if change.kind == structuredPatchUpdate {
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
}
}
}
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
for _, change := range changes {
switch change.kind {
case structuredPatchDelete:
options.FileTracker.Forget(change.from.absolute)
case structuredPatchAdd:
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, true)
case structuredPatchUpdate:
wasWhole := wholeBefore[change.from.absolute]
options.FileTracker.Forget(change.from.absolute)
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
}
}
summary := "Patch applied successfully."
if relativeRoot != "." {
summary = "Patch applied successfully in " + relativeRoot + "."
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
return result
}
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
if normalized == "" {
return nil, fmt.Errorf("structured patch is empty")
}
lines := strings.Split(normalized, "\n")
if strings.TrimSpace(lines[0]) != structuredPatchBegin {
return nil, fmt.Errorf("the first line of a structured patch must be %q", structuredPatchBegin)
}
if strings.TrimSpace(lines[len(lines)-1]) != structuredPatchEnd {
return nil, fmt.Errorf("the last line of a structured patch must be %q", structuredPatchEnd)
}
var operations []structuredPatchOperation
for index := 1; index < len(lines)-1; {
header := strings.TrimSpace(lines[index])
lineNumber := index + 1
if header == "" {
index++
continue
}
switch {
case strings.HasPrefix(header, structuredAddFile):
path, err := structuredPatchPath(header, structuredAddFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchAdd, path: path, line: lineNumber}
index++
var content []string
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
if !strings.HasPrefix(lines[index], "+") {
return nil, structuredPatchLineError(index+1, "added-file contents must start with '+'")
}
content = append(content, strings.TrimPrefix(lines[index], "+"))
index++
}
if len(content) > 0 {
op.contents = strings.Join(content, "\n") + "\n"
}
operations = append(operations, op)
case strings.HasPrefix(header, structuredDeleteFile):
path, err := structuredPatchPath(header, structuredDeleteFile, lineNumber)
if err != nil {
return nil, err
}
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: path, line: lineNumber})
index++
case strings.HasPrefix(header, structuredUpdateFile):
path, err := structuredPatchPath(header, structuredUpdateFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchUpdate, path: path, line: lineNumber}
index++
if index < len(lines)-1 && strings.HasPrefix(strings.TrimSpace(lines[index]), structuredMoveTo) {
op.movePath, err = structuredPatchPath(strings.TrimSpace(lines[index]), structuredMoveTo, index+1)
if err != nil {
return nil, err
}
index++
}
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
line := lines[index]
trimmed := strings.TrimSpace(line)
if trimmed == structuredEndOfFile {
if len(op.chunks) == 0 || (!hasStructuredPatchContent(op.chunks[len(op.chunks)-1])) {
return nil, structuredPatchLineError(index+1, "*** End of File must follow an update hunk")
}
op.chunks[len(op.chunks)-1].endOfFile = true
index++
continue
}
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
chunk := structuredPatchChunk{}
if trimmed != "@@" {
chunk.context = strings.TrimPrefix(trimmed, structuredHunkContext)
chunk.hasContext = true
}
op.chunks = append(op.chunks, chunk)
index++
continue
}
if len(op.chunks) == 0 {
op.chunks = append(op.chunks, structuredPatchChunk{})
}
chunk := &op.chunks[len(op.chunks)-1]
switch {
case line == "":
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, "")
chunk.new = append(chunk.new, "")
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, " "):
content := strings.TrimPrefix(line, " ")
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, content)
chunk.new = append(chunk.new, content)
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, "+"):
chunk.new = append(chunk.new, strings.TrimPrefix(line, "+"))
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
case strings.HasPrefix(line, "-"):
chunk.old = append(chunk.old, strings.TrimPrefix(line, "-"))
default:
return nil, structuredPatchLineError(index+1, "update lines must start with ' ', '+', or '-'")
}
index++
}
if len(op.chunks) == 0 || !allStructuredPatchChunksHaveContent(op.chunks) {
return nil, structuredPatchLineError(lineNumber, "update file hunk is empty")
}
operations = append(operations, op)
default:
return nil, structuredPatchLineError(lineNumber, "expected an Add File, Delete File, or Update File header")
}
}
if len(operations) == 0 {
return nil, fmt.Errorf("structured patch contains no file operations")
}
return operations, nil
}
func structuredPatchPath(header, prefix string, line int) (string, error) {
path := strings.TrimSpace(strings.TrimPrefix(header, prefix))
if path == "" {
return "", structuredPatchLineError(line, "file path is required")
}
return path, nil
}
func isStructuredPatchHeader(line string) bool {
return line == structuredPatchEnd || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
}
func structuredPatchLineError(line int, message string) error {
return fmt.Errorf("invalid structured patch at line %d: %s", line, message)
}
func hasStructuredPatchContent(chunk structuredPatchChunk) bool {
return len(chunk.old) > 0 || len(chunk.new) > 0
}
func allStructuredPatchChunksHaveContent(chunks []structuredPatchChunk) bool {
for _, chunk := range chunks {
if !hasStructuredPatchContent(chunk) {
return false
}
}
return true
}
func structuredPatchOperationPaths(operations []structuredPatchOperation) []string {
seen := make(map[string]bool)
var paths []string
for _, operation := range operations {
for _, path := range []string{operation.path, operation.movePath} {
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, tracker *FileTracker) ([]structuredPatchChange, error) {
seen := make(map[string]struct{})
var changes []structuredPatchChange
for _, operation := range operations {
from, err := resolveStructuredPatchTarget(root.Name(), operation.path)
if err != nil {
return nil, err
}
to := from
if operation.movePath != "" {
to, err = resolveStructuredPatchTarget(root.Name(), operation.movePath)
if err != nil {
return nil, err
}
}
targets := []structuredPatchTarget{from}
if to.absolute != from.absolute {
targets = append(targets, to)
}
for _, target := range targets {
if _, exists := seen[target.absolute]; exists {
return nil, fmt.Errorf("structured patch changes %q more than once", target.relative)
}
seen[target.absolute] = struct{}{}
}
change := structuredPatchChange{kind: operation.kind, from: from, to: to, mode: 0o644}
switch operation.kind {
case structuredPatchAdd:
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot add %s because it already exists", to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
change.after = operation.contents
case structuredPatchDelete, structuredPatchUpdate:
info, err := root.Stat(from.relative)
if err != nil {
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
}
change.mode = info.Mode()
content, err := root.ReadFile(from.relative)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
}
if err := tracker.CheckConflict(from.absolute, content); err != nil {
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
}
change.before = string(content)
if operation.kind == structuredPatchUpdate {
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
if err != nil {
return nil, err
}
change.after = updated
if from.absolute != to.absolute {
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
}
}
}
changes = append(changes, change)
}
return changes, nil
}
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
}
absolute, relative, err := resolveWorkspaceTargetPath(root, path)
if err != nil {
return structuredPatchTarget{}, err
}
return structuredPatchTarget{requested: path, absolute: absolute, relative: relative}, nil
}
func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchChunk) (string, error) {
lineEnding := structuredPatchLineEnding(content)
if lineEnding == "\r\n" {
content = strings.ReplaceAll(content, "\r\n", "\n")
}
lines := strings.Split(content, "\n")
trailingNewline := content != "" && len(lines) > 0 && lines[len(lines)-1] == ""
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
replacements := make([]structuredReplacement, 0, len(chunks))
lineIndex := 0
for _, chunk := range chunks {
if chunk.hasContext {
index, ambiguous := findStructuredPatchSequence(lines, []string{chunk.context}, lineIndex, false)
if ambiguous {
return "", fmt.Errorf("context %q is ambiguous in %s; provide a more specific hunk header", chunk.context, path)
}
if index < 0 {
return "", fmt.Errorf("could not find context %q in %s", chunk.context, path)
}
lineIndex = index + 1
}
if len(chunk.old) == 0 {
// A context anchor identifies the line immediately before a pure
// insertion. Only an explicit end-of-file hunk (or an unanchored
// insertion) belongs at the file end.
start := lineIndex
if chunk.endOfFile || !chunk.hasContext {
start = len(lines)
}
if start > len(lines) {
start = len(lines)
}
replacement, err := structuredPatchReplacement(chunk, lines, start)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: start, replacement: replacement})
lineIndex = start
continue
}
index, ambiguous := findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
if ambiguous {
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
}
if index < 0 {
return "", fmt.Errorf("could not find expected lines in %s:\n%s", path, strings.Join(chunk.old, "\n"))
}
replacement, err := structuredPatchReplacement(chunk, lines, index)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: index, length: len(chunk.old), replacement: replacement})
lineIndex = index + len(chunk.old)
}
for index := 1; index < len(replacements); index++ {
previous := replacements[index-1]
current := replacements[index]
if current.start < previous.start+previous.length {
return "", fmt.Errorf("overlapping or out-of-order hunks in %s; provide non-overlapping context", path)
}
}
for index := len(replacements) - 1; index >= 0; index-- {
replacement := replacements[index]
lines = append(lines[:replacement.start], append(replacement.replacement, lines[replacement.start+replacement.length:]...)...)
}
if len(lines) == 0 {
return "", nil
}
updated := strings.Join(lines, lineEnding)
if trailingNewline {
updated += lineEnding
}
return updated, nil
}
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
if len(chunk.newSourceOffsets) != len(chunk.new) {
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
}
replacement := append([]string(nil), chunk.new...)
for outputIndex, sourceOffset := range chunk.newSourceOffsets {
if sourceOffset < 0 {
continue
}
sourceIndex := start + sourceOffset
if sourceIndex < 0 || sourceIndex >= len(source) {
return nil, fmt.Errorf("context source index %d is outside the matched file", sourceIndex)
}
replacement[outputIndex] = source[sourceIndex]
}
return replacement, nil
}
func structuredPatchLineEnding(content string) string {
if strings.Contains(content, "\r\n") && !strings.Contains(strings.ReplaceAll(content, "\r\n", ""), "\n") {
return "\r\n"
}
return "\n"
}
type structuredReplacement struct {
start int
length int
replacement []string
}
func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bool) (int, bool) {
if len(wanted) == 0 {
return start, false
}
if len(wanted) > len(lines) {
return -1, false
}
searchStart := start
if endOfFile {
searchStart = len(lines) - len(wanted)
if searchStart < start {
searchStart = start
}
}
for _, equal := range []func(string, string) bool{
func(left, right string) bool { return left == right },
func(left, right string) bool {
return strings.TrimRight(left, " \t") == strings.TrimRight(right, " \t")
},
func(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) },
} {
match := -1
for index := searchStart; index+len(wanted) <= len(lines); index++ {
matched := true
for offset, line := range wanted {
if !equal(lines[index+offset], line) {
matched = false
break
}
}
if matched {
if match >= 0 {
return -1, true
}
match = index
}
}
if match >= 0 {
return match, false
}
}
return -1, false
}
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
mutated := false
for _, change := range changes {
committed, err := applyStructuredPatchChange(root, change)
mutated = mutated || committed
if err != nil {
forgetStructuredPatchFiles(tracker, changes)
if mutated {
return fmt.Errorf("%w; structured patch was partially applied; re-read affected files before retrying", err)
}
return err
}
}
return nil
}
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
if tracker == nil {
return
}
for _, change := range changes {
tracker.Forget(change.from.absolute)
if change.to.absolute != change.from.absolute {
tracker.Forget(change.to.absolute)
}
}
}
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
switch change.kind {
case structuredPatchDelete:
if err := root.Remove(change.from.relative); err != nil {
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
}
return true, nil
case structuredPatchAdd:
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
case structuredPatchUpdate:
moving := change.from.absolute != change.to.absolute
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
if err != nil {
return committed, err
}
if moving {
if err := root.Remove(change.from.relative); err != nil {
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
}
}
return true, nil
}
return false, fmt.Errorf("unsupported structured patch operation")
}
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
parent := filepath.Dir(target.relative)
if err := root.MkdirAll(parent, 0o755); err != nil {
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
}
temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp")
if err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
defer func() { _ = removeStructuredPatchTemp(root, tempName) }()
if _, err := temp.WriteString(content); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Chmod(mode.Perm()); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Close(); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if createOnly {
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
}
if err := root.Rename(tempName, target.relative); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
return true, nil
}
func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
}
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
if linkErr := link(source, target); linkErr == nil {
cleanupErr := removeStructuredPatchTemp(root, source)
chmodErr := root.Chmod(target, mode.Perm())
if cleanupErr != nil || chmodErr != nil {
return true, fmt.Errorf("publishing %s: %w", target, errors.Join(cleanupErr, chmodErr))
}
return true, nil
} else if errors.Is(linkErr, os.ErrExist) {
return false, fmt.Errorf("creating %s: %w", target, linkErr)
} else {
committed, copyErr := copyStructuredPatchNoReplace(root, source, target, mode)
if copyErr != nil {
return committed, fmt.Errorf("creating %s without overwrite: hard link failed: %w; exclusive-copy fallback failed: %w", target, linkErr, copyErr)
}
return committed, nil
}
}
func copyStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return copyStructuredPatchNoReplaceWith(root, source, target, mode, io.Copy, removeStructuredPatchTemp)
}
func copyStructuredPatchNoReplaceWith(
root *os.Root,
source, target string,
mode os.FileMode,
copyFile func(io.Writer, io.Reader) (int64, error),
cleanup func(*os.Root, string) error,
) (bool, error) {
sourceFile, err := root.Open(source)
if err != nil {
return false, err
}
sourceClosed := false
defer func() {
if !sourceClosed {
_ = sourceFile.Close()
}
}()
targetFile, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false, err
}
committed := true
cleanupTarget := func(cause error) (bool, error) {
_ = targetFile.Close()
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, cause
} else {
return committed, errors.Join(cause, fmt.Errorf("cleaning partial target: %w", cleanupErr))
}
}
if _, err := copyFile(targetFile, sourceFile); err != nil {
return cleanupTarget(err)
}
if err := sourceFile.Close(); err != nil {
sourceClosed = true
return cleanupTarget(err)
}
sourceClosed = true
if err := targetFile.Chmod(mode.Perm()); err != nil {
return cleanupTarget(err)
}
if err := targetFile.Close(); err != nil {
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, err
} else {
return true, errors.Join(err, fmt.Errorf("cleaning target after close failure: %w", cleanupErr))
}
}
if err := cleanup(root, source); err != nil {
return true, fmt.Errorf("removing published source: %w", err)
}
return true, nil
}
func removeStructuredPatchTemp(root *os.Root, name string) error {
chmodErr := root.Chmod(name, 0o600)
if os.IsNotExist(chmodErr) {
return nil
}
removeErr := root.Remove(name)
if os.IsNotExist(removeErr) {
removeErr = nil
}
if removeErr == nil {
return nil
}
return errors.Join(chmodErr, removeErr)
}
func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, seenWhole bool) {
if tracker == nil {
return
}
content, err := os.ReadFile(absolute)
if err != nil {
tracker.Forget(absolute)
return
}
info, _ := os.Stat(absolute)
tracker.Record(absolute, content, info)
if seenWhole {
lines := lineCount(string(content))
tracker.RecordSeenRange(absolute, 1, lines, lines)
}
if created {
tracker.RecordCreated(absolute)
}
}
func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPatchChange) []string {
seen := make(map[string]bool)
var paths []string
for _, change := range changes {
targets := []structuredPatchTarget{change.to}
if change.from.absolute != change.to.absolute {
targets = append([]structuredPatchTarget{change.from}, targets...)
}
for _, target := range targets {
path := target.relative
if relativeRoot != "" && relativeRoot != "." {
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
func structuredPatchPreview(changes []structuredPatchChange) string {
var preview strings.Builder
for _, change := range changes {
path := change.to.relative
if change.kind == structuredPatchDelete {
path = change.from.relative
}
before, after := change.before, change.after
if change.kind == structuredPatchDelete {
after = ""
}
diff := boundedUnifiedDiff(path, before, after)
if diff == "" {
continue
}
if preview.Len()+len(diff)+1 > maxToolPreviewBytes {
break
}
if preview.Len() > 0 {
preview.WriteByte('\n')
}
preview.WriteString(diff)
}
return capPreviewDiff(preview.String())
}
+6
View File
@@ -130,6 +130,9 @@ func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []str
var deferred []Tool
if tool.registry != nil {
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if !IsDeferred(candidate) {
continue
}
@@ -158,6 +161,9 @@ func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []st
return names
}
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
continue
}
+537
View File
@@ -2,12 +2,23 @@ package tools
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func mustReadTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(content)
}
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreToolsScoped(t.TempDir(), nil)
byName := make(map[string]Tool, len(toolset))
@@ -513,6 +524,532 @@ func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
}
}
func TestApplyPatchToolAppliesStructuredPatch(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
" hello",
"-old",
"+new",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "old.txt"), "old\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: nested/new.txt",
"+created",
"*** Update File: old.txt",
"*** Move to: moved.txt",
"@@",
"-old",
"+moved",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(got) != "created\n" {
t.Fatalf("added file = %q, err = %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(root, "moved.txt")); err != nil || string(got) != "moved\n" {
t.Fatalf("moved file = %q, err = %v", got, err)
}
if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) {
t.Fatalf("old source should be removed, stat err = %v", err)
}
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
t.Fatalf("ChangedFiles = %v", got)
}
}
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), " hello \n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
"-hello",
"+goodbye",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should tolerate surrounding whitespace, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := string(content); got != "goodbye\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesWhitespaceTolerantContext(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt()\n\t}\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
" if cond {",
" doWork()",
"-\t\tlogIt()",
"+\t\tlogIt(ctx)",
" }",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt(ctx)\n\t}\n}\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote unchanged context:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMixedContextLineEndings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\r\n\tcontext\r\n\told\nomega\r\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
" alpha",
" context",
"-\told",
"+\tnew",
" omega",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "alpha\r\n\tcontext\r\n\tnew\nomega\r\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote context line endings:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchInsertsAtContext(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "example.txt"), "anchor\nmiddle\nremove\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@ anchor",
"+inserted",
"@@ middle",
"-remove",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured insertion failed: %s", result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "example.txt"))
if err != nil {
t.Fatal(err)
}
if got, want := string(content), "anchor\ninserted\nmiddle\n"; got != want {
t.Fatalf("structured insertion content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsAmbiguousMatch(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func a() {\n\treturn nil\n}\n\nfunc b() {\n\treturn nil\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
"-\treturn nil",
"+\treturn errNotFound",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError || !strings.Contains(strings.ToLower(result.Output), "ambiguous") {
t.Fatalf("ambiguous structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("ambiguous structured patch changed the file: %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\r\nbravo\r\ncharlie\r\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-bravo",
"+BRAVO",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "alpha\r\nBRAVO\r\ncharlie\r\n"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMissingFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\nbravo")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-alpha",
"+ALPHA",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "ALPHA\nbravo"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchKeepsInsertedEmptyFileWithoutFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "empty.txt")
writeTestFile(t, path, "")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: empty.txt",
"@@",
"+content",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "content"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsOutOfOrderEndHunk(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\nbravo\ncharlie\ndelta\necho\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-delta",
"+DELTA",
"@@",
"-delta",
"-echo",
"+tail",
"*** End of File",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("out-of-order structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("out-of-order structured patch changed the file: %q", got)
}
}
func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "created.txt")
writeTestFile(t, targetPath, "other writer\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: targetPath, relative: "created.txt"},
after: "patch content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !errors.Is(err, os.ErrExist) {
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
}
if strings.Contains(err.Error(), "exclusive-copy fallback") {
t.Fatalf("existing destination should be reported directly: %v", err)
}
if got := mustReadTestFile(t, targetPath); got != "other writer\n" {
t.Fatalf("raced add overwrote another writer's file: %q", got)
}
}
func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
missingPath := filepath.Join(root, "missing.txt")
change := structuredPatchChange{
kind: structuredPatchDelete,
from: structuredPatchTarget{absolute: missingPath, relative: "missing.txt"},
before: "removed by another writer\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil {
t.Fatal("delete of an already removed file should fail")
}
if _, statErr := os.Stat(missingPath); !os.IsNotExist(statErr) {
t.Fatalf("failed delete recreated the missing file: %v", statErr)
}
}
func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.txt")
destinationPath := filepath.Join(root, "destination.txt")
change := structuredPatchChange{
kind: structuredPatchUpdate,
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
before: "removed source\n", after: "moved content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want partial-application error", err)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("failed move recreated a missing source: %v", statErr)
}
if got := mustReadTestFile(t, destinationPath); got != "moved content\n" {
t.Fatalf("published move destination = %q", got)
}
}
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
if err := workspace.Chmod("source.tmp", 0o444); err != nil {
t.Fatal(err)
}
committed, err := publishStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o444,
func(string, string) error { return errors.New("hard links unsupported") },
)
if err != nil || !committed {
t.Fatalf("fallback publish = committed %v, error %v", committed, err)
}
if got := mustReadTestFile(t, filepath.Join(root, "target.txt")); got != "complete content\n" {
t.Fatalf("fallback target content = %q", got)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("read-only source was not cleaned up: %v", statErr)
}
}
func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
copyErr := errors.New("injected copy failure")
cleanupErr := errors.New("injected target cleanup failure")
committed, err := copyStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o644,
func(destination io.Writer, _ io.Reader) (int64, error) {
written, writeErr := io.WriteString(destination, "partial")
if writeErr != nil {
return int64(written), writeErr
}
return int64(written), copyErr
},
func(root *os.Root, name string) error {
if name == "target.txt" {
return cleanupErr
}
return removeStructuredPatchTemp(root, name)
},
)
if !committed || !errors.Is(err, copyErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("copy failure = committed %v, error %v", committed, err)
}
targetPath := filepath.Join(root, "target.txt")
if got := mustReadTestFile(t, targetPath); got != "partial" {
t.Fatalf("partial target content = %q", got)
}
}
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
root := t.TempDir()
trackedPath := filepath.Join(root, "tracked.txt")
writeTestFile(t, trackedPath, "original\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
trackedPath, err = filepath.EvalSymlinks(trackedPath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(trackedPath)
if err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
tracker.Record(trackedPath, []byte("original\n"), info)
tracker.RecordSeenRange(trackedPath, 1, 1, 1)
trackedTarget := structuredPatchTarget{absolute: trackedPath, relative: "tracked.txt"}
blockedPath := filepath.Join(root, "blocked")
if err := os.Mkdir(blockedPath, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(blockedPath, "sentinel"), "keep\n")
changes := []structuredPatchChange{
{
kind: structuredPatchUpdate, from: trackedTarget, to: trackedTarget,
before: "original\n", after: "updated\n", mode: 0o644,
},
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: blockedPath, relative: "blocked"},
after: "cannot replace a non-empty directory\n", mode: 0o644,
},
}
err = applyStructuredPatchChanges(workspace, changes, tracker)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("second change = %v, want partial-application error", err)
}
if got := mustReadTestFile(t, trackedPath); got != "updated\n" {
t.Fatalf("completed change was unexpectedly altered: %q", got)
}
if _, tracked := tracker.Version(trackedPath); tracked || tracker.SeenWhole(trackedPath) {
t.Fatal("partial failure retained stale file-tracker state")
}
}
func TestApplyPatchToolRejectsMalformedOrEscapingStructuredPatchBeforeWriting(t *testing.T) {
root := t.TempDir()
tool := NewScopedApplyPatchTool(root, nil)
for name, patch := range map[string]string{
"empty update": strings.Join([]string{
"*** Begin Patch",
"*** Update File: target.txt",
"*** End Patch",
}, "\n"),
"escape": strings.Join([]string{
"*** Begin Patch",
"*** Add File: ../outside.txt",
"+nope",
"*** End Patch",
}, "\n"),
} {
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("expected error, got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "No valid patches") {
t.Fatalf("structured patch should fail before git apply, got %q", result.Output)
}
if name == "empty update" {
if _, err := os.Stat(filepath.Join(root, "target.txt")); !os.IsNotExist(err) {
t.Fatalf("empty update must not write target file, stat err = %v", err)
}
}
})
}
if _, err := os.Stat(filepath.Join(filepath.Dir(root), "outside.txt")); !os.IsNotExist(err) {
t.Fatalf("escaping patch must not write outside workspace, stat err = %v", err)
}
}
// A hunk-body line that removes content beginning with "-- " appears in the diff
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
// apply_patch reject a valid patch as targeting an outside path).
+1 -1
View File
@@ -22,7 +22,7 @@ func (m model) handleAddDirCommand(arg string) model {
if err != nil {
return m.appendSystemNotice("add-dir: " + err.Error())
}
return m.appendSystemNotice("write access added: " + root + " (this session only)")
return m.showTransientNoticeInline("Write access added: "+root+" (this session only).", transientNoticeSuccess)
}
// appendSystemNotice appends a system transcript line and returns the updated
+2 -2
View File
@@ -42,8 +42,8 @@ func TestHandleAddDirCommand(t *testing.T) {
if roots := scope.Roots(); len(roots) != initialRootCount+1 {
t.Fatalf("expected one additional write root after grant, got %#v", roots)
}
notice := lastTranscriptText(next)
if !strings.Contains(notice, "write access added") || !strings.Contains(notice, "session only") {
notice := next.transientNotice.text
if !strings.Contains(notice, "Write access added") || !strings.Contains(notice, "session only") {
t.Fatalf("grant notice = %q, want it to mention the grant and its session-only lifetime", notice)
}
+39 -4
View File
@@ -18,6 +18,8 @@ const (
markdownTableRuleBodyRows = 4
markdownBoldStart = "\x1b[1m"
markdownBoldEnd = "\x1b[22m"
markdownCodeStart = "\x1b[7m"
markdownCodeEnd = "\x1b[27m"
)
type markdownDisplayStyle int
@@ -25,6 +27,7 @@ type markdownDisplayStyle int
const (
markdownDisplayNormal markdownDisplayStyle = iota
markdownDisplayBold
markdownDisplayCode
markdownDisplayRule
)
@@ -262,6 +265,8 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
case markdownDisplayBold:
// Emphasis is weight-only (no colour) — dark-mode-friendly and calm.
builder.WriteString(zeroTheme.ink.Bold(true).Render(text))
case markdownDisplayCode:
builder.WriteString(inlineCodeStyle().Render(text))
case markdownDisplayRule:
builder.WriteString(zeroTheme.lineStrong.Render(text))
default:
@@ -282,6 +287,16 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
style = markdownDisplayNormal
index += len(markdownBoldEnd)
continue
case strings.HasPrefix(line[index:], markdownCodeStart):
flush()
style = markdownDisplayCode
index += len(markdownCodeStart)
continue
case strings.HasPrefix(line[index:], markdownCodeEnd):
flush()
style = markdownDisplayNormal
index += len(markdownCodeEnd)
continue
case line[index] == '\x1b':
// Already-styled input (highlighted code, headings, tables) carries real
// ANSI. Emit each escape verbatim instead of treating its bytes as runes
@@ -313,6 +328,13 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
return builder.String()
}
func hasMarkdownDisplayControls(line string) bool {
return strings.Contains(line, markdownBoldStart) ||
strings.Contains(line, markdownBoldEnd) ||
strings.Contains(line, markdownCodeStart) ||
strings.Contains(line, markdownCodeEnd)
}
func hasExternalANSIStyle(line string) bool {
for index := 0; index < len(line); {
if line[index] != '\x1b' {
@@ -325,7 +347,7 @@ func hasExternalANSIStyle(line string) bool {
continue
}
seq := line[index:end]
if seq != markdownBoldStart && seq != markdownBoldEnd {
if seq != markdownBoldStart && seq != markdownBoldEnd && seq != markdownCodeStart && seq != markdownCodeEnd {
return true
}
index = end
@@ -334,6 +356,9 @@ func hasExternalANSIStyle(line string) bool {
}
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
if current == markdownDisplayCode {
return current
}
switch r {
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
return markdownDisplayRule
@@ -980,6 +1005,7 @@ func renderMarkdownStandaloneLine(line string) string {
type markdownInlineSegment struct {
text string
bold bool
code bool
}
func wrapMarkdownInline(text string, measure int) []string {
@@ -1028,7 +1054,7 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
flush()
}
head, tail := splitAtWidth(word.text, available)
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold}))
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold, code: word.code}))
word.text = tail
prefix = continuationPrefix
available = maxInt(1, measure-lipgloss.Width(prefix))
@@ -1063,8 +1089,14 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
func markdownInlineWords(segments []markdownInlineSegment) []markdownInlineSegment {
words := []markdownInlineSegment{}
for _, segment := range segments {
if segment.code {
if segment.text != "" {
words = append(words, segment)
}
continue
}
for _, word := range strings.Fields(segment.text) {
words = append(words, markdownInlineSegment{text: word, bold: segment.bold})
words = append(words, markdownInlineSegment{text: word, bold: segment.bold, code: segment.code})
}
}
return words
@@ -1078,6 +1110,9 @@ func renderMarkdownInlineSegment(segment markdownInlineSegment) string {
if segment.text == "" {
return ""
}
if segment.code {
return markdownCodeStart + segment.text + markdownCodeEnd
}
if segment.bold {
return renderMarkdownBoldText(segment.text)
}
@@ -1107,7 +1142,7 @@ func parseMarkdownInline(text string) []markdownInlineSegment {
if builder.Len() == 0 {
return
}
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code})
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code, code: code})
builder.Reset()
}
+110
View File
@@ -0,0 +1,110 @@
package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/terminalpet"
)
const (
attachmentImageID = uint32(0xC0E0)
attachmentPreviewCols = 10
attachmentPreviewRows = 4
attachmentPreviewHeightPixels = 64
attachmentPreviewMaxImages = 4
)
// attachmentThumbnailVisible deliberately has a conservative layout gate. The
// image is an enhancement to the existing attachment chips, not a reason to
// crowd a narrow or modal surface.
func (m model) attachmentThumbnailVisible(width int) bool {
if len(m.attachmentRenderers) == 0 || !m.altScreen ||
m.height < attachmentPreviewRows+10 || width < attachmentPreviewCols+28 {
return false
}
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
if index < len(m.pendingImageThumbnails) && m.pendingImageThumbnails[index] != nil &&
index < len(m.attachmentRenderers) && m.attachmentRenderers[index].Support().Supported() {
return true
}
}
return false
}
func (m model) attachmentThumbnailSlots(width int) int {
innerWidth := maxInt(1, width-m.petComposerReservedColumns(width)-4)
slots := maxInt(1, innerWidth/(attachmentPreviewCols+1))
return min(attachmentPreviewMaxImages, min(slots, len(m.attachmentRenderers)))
}
func (m model) attachmentThumbnailLines(width int) []string {
left := strings.Repeat(" ", attachmentPreviewCols+1)
lines := make([]string, attachmentPreviewRows)
for index := range lines {
lines[index] = left
}
for index, line := range lines {
lines[index] = fitStyledLine(line, width)
}
return lines
}
func (m model) attachmentThumbnailSupplementalChips() string {
if len(m.pendingImageLabels) <= 1 && len(m.pendingDocuments) == 0 {
return ""
}
return renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments)
}
func (m model) attachmentComposerPrefixRows(width int) int {
if m.attachmentThumbnailVisible(width) {
rows := attachmentPreviewRows
if m.attachmentThumbnailSupplementalChips() != "" {
rows++
}
return rows
}
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
return 1
}
return 0
}
// attachmentImageDraw places one gallery thumbnail inside the reserved top rows
// of the composer. The position comes from the same footer layout used for mouse
// input, so it follows sidebar changes and terminal resizes instead of relying on
// a brittle absolute bottom-right calculation.
func (m model) attachmentImageDraw(index int) *terminalpet.ImageDraw {
width := m.chatColumnWidth()
if !m.attachmentThumbnailVisible(width) || index < 0 || index >= m.attachmentThumbnailSlots(width) ||
index >= len(m.pendingImageThumbnails) || m.pendingImageThumbnails[index] == nil {
return nil
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.composerRect.height < attachmentPreviewRows+2 {
return nil
}
return &terminalpet.ImageDraw{
ID: attachmentImageID + uint32(index),
Animation: m.pendingImageThumbnails[index],
State: terminalpet.Idle,
X: frame.composerRect.x + 2 + index*(attachmentPreviewCols+1),
Y: frame.composerRect.y + 1,
Columns: attachmentPreviewCols,
Rows: attachmentPreviewRows,
HeightPixels: attachmentPreviewHeightPixels,
}
}
func attachmentPreviewImageIDs() []uint32 {
ids := make([]uint32, attachmentPreviewMaxImages)
for index := range ids {
ids[index] = attachmentImageID + uint32(index)
}
return ids
}
+168
View File
@@ -0,0 +1,168 @@
package tui
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func previewImageBlock(t *testing.T) zeroruntime.ImageBlock {
t.Helper()
frame := image.NewNRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
frame.Set(x, y, color.NRGBA{R: 60, G: 170, B: 220, A: 255})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, frame); err != nil {
t.Fatal(err)
}
return zeroruntime.ImageBlock{MediaType: "image/png", Data: encoded.Bytes()}
}
func TestAttachmentThumbnailRendersInsideComposerWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
if len(m.pendingImageThumbnails) != 1 || m.pendingImageThumbnails[0] == nil {
t.Fatal("expected staged image thumbnail")
}
view := m.View()
plain := plainRender(t, view.Content)
if strings.Contains(plain, "[Image #1]") {
t.Fatalf("supported terminal should use thumbnail strip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("thumbnail strip should not duplicate a text attachment label, got:\n%s", plain)
}
var output bytes.Buffer
if err := m.attachmentRenderers[0].Render(&output); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=10,r=4") {
t.Fatalf("thumbnail did not schedule a compact Kitty image: %q", got)
}
}
func TestAttachmentThumbnailKeepsAdditionalImagesVisibleWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t), previewImageBlock(t)}
m.pendingImageLabels = []string{"first.png", "second.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #2]") {
t.Fatalf("a supported terminal must keep a second staged image visible, got:\n%s", plain)
}
_ = m.View()
for index, renderer := range m.attachmentRenderers {
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
id := "i=" + strconv.FormatUint(uint64(attachmentImageID+uint32(index)), 10)
if !strings.Contains(output.String(), id) {
t.Fatalf("thumbnail %d was not rendered, got %q", index+1, output.String())
}
}
}
func TestAttachmentThumbnailFallsBackToChipsWithoutTerminalGraphics(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #1]") {
t.Fatalf("unsupported terminal should retain text attachment chip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("unsupported terminal should not reserve thumbnail rows, got:\n%s", plain)
}
}
func TestAttachmentThumbnailClearsWithPendingImage(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
_ = m.View()
var first bytes.Buffer
if err := m.attachmentRenderers[0].Render(&first); err != nil {
t.Fatal(err)
}
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
_ = m.View()
var cleared bytes.Buffer
if err := m.attachmentRenderers[0].Render(&cleared); err != nil {
t.Fatal(err)
}
if !strings.Contains(cleared.String(), "a=d,d=I,i=49376") {
t.Fatalf("clearing pending image should delete its Kitty placement, got %q", cleared.String())
}
}
func TestPetOutputCompositesAttachmentAndPetInOneFrame(t *testing.T) {
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 2, 2)))
if err != nil {
t.Fatal(err)
}
pet := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
attachment := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
pet.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
attachment.Set(&terminalpet.ImageDraw{ID: attachmentImageID, Animation: animation, State: terminalpet.Idle, Columns: 10, Rows: 4})
sink, err := os.CreateTemp(t.TempDir(), "image-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sink.Close() })
if _, err := newPetImageOutput(sink, pet, attachment).Write([]byte(terminalSyncStart + "frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(sink.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, terminalSyncStart) != 1 || strings.Count(got, terminalSyncEnd) != 1 {
t.Fatalf("graphics should share Bubble Tea's synchronized frame: %q", got)
}
for _, id := range []string{"i=49374", "i=49376"} {
if !strings.Contains(got, id) {
t.Fatalf("missing image placement %s: %q", id, got)
}
}
}
+1
View File
@@ -136,6 +136,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
if question == "" {
side.pendingImages = nil
side.pendingImageLabels = nil
side.pendingImageThumbnails = nil
side.pendingDocuments = nil
}
side.loops = nil
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more