Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 5e93b5dbf7 test(update): give the Homebrew fixtures a receipt, and stop gating the receipt rule on the host
The previous commit made a receipt the thing that distinguishes a keg from any
directory sitting under a path containing "Cellar". Two fixtures still built a
keg without one, so detection correctly answered standalone and both failed:

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

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

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

Verified the receipt path arithmetic on both non-Windows targets rather than
trusting the host-gated tests: isHomebrewPath is false before the receipt is
written and true after, with the receipt at the keg directory hasHomebrewReceipt
actually reads.
2026-08-20 18:46:00 +05:30
Vasanthdev2004 833573681a fix(update): require a Homebrew receipt before refusing self-update
Review found the keg check was shape-only, and it was right: a directory
tree a user built themselves can have exactly a keg's shape. Classifying
one as Homebrew refuses a `zero upgrade` that would have worked
perfectly, on a machine Homebrew has never touched.

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

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

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

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

The install docs claimed detection was the Cellar path alone, which
undersold it, and stated the self-update rule absolutely in one paragraph
before excepting mise in the next. Both say the same thing now.
2026-08-19 17:04:24 +05:30
Vasanthdev2004 6bc820aa7b chore: merge main 2026-08-19 12:50:17 +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
Vasanthdev2004 a9e1aa81e1 chore: merge main for the Go 1.26.6 and x/image vulnerability fixes 2026-08-18 11:29:50 +05:30
d065467cd3 feat(zerogit): auto-create a conventional branch before push/pr on default branch (#671)
* feat(zerogit): auto-create a conventional branch before push/pr on default branch

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three review findings:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #671

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

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

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

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

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

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

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

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

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

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

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

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

Refs #671

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

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

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

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

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

Refs #671

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

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

Refs #671

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

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

Refs #671

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

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

Refs #671

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

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

Refs #671

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

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

Refs #671

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

* Protect auto-branch publication and default ref restoration

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

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

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

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

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

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

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

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

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

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

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

Refs #671

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

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

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

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

Refs #671

* Address review feedback for auto-branch workflow.

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

Refs #671

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

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

Refs #671

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

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

Refs #671

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

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

Refs #671

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

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

Refs #671

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

Refs #671

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-16 16:01:40 +08:00
Vasanthdev2004 f12911d4bc test(update): build the release fixture from the running platform 2026-08-15 21:37:41 +05:30
Vasanthdev2004 5763eb2d80 fix(update): match the keg shape, correct the docs, cover Apply and cross-target 2026-08-15 21:32:51 +05:30
Vasanthdev2004 6dcc51e591 feat(update): recognise Homebrew installs and add mise, go install docs 2026-08-15 21:21:55 +05:30
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
139 changed files with 20757 additions and 600 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
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.26.5+.
Source builds require Go 1.26.6+.
```bash
git clone https://github.com/Gitlawb/zero.git
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.26.5+。
源码构建需要 Go 1.26.6+。
```bash
git clone https://github.com/Gitlawb/zero.git
+83 -8
View File
@@ -1,14 +1,30 @@
# Installing Zero
Zero is distributed as:
Pick whichever fits how you already install things. None of these is the
blessed one.
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.26.5+
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
`zero upgrade` follows whatever owns the binary, with one exception it cannot
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
keg and names `brew upgrade zero`, and replaces the binary in place for
everything else — including a mise-managed one, which is the exception.
mise is the one case it cannot detect: a mise-managed binary is an ordinary
standalone install on disk, so `zero upgrade` will replace it and leave mise
describing a version that is no longer there. Use `mise upgrade` instead.
Release archives are the substrate for most of the above: the install scripts
and the npm fallback both download a platform archive from a published GitHub
Release and verify its checksum.
## npm
@@ -111,6 +127,45 @@ Defaults:
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
## mise
[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release
archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
mise-managed binary is an ordinary standalone install, but then mise's records
describe a version that is no longer on disk. Prefer `mise upgrade`.
## go install
```bash
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.6+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
macOS and Windows need no extra helper.
## From Source
```bash
@@ -125,7 +180,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.5+.
Source builds require Go 1.26.6+.
### Sandbox Helpers For Source Builds
@@ -242,3 +297,23 @@ zero upgrade
```
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
`zero upgrade` behaves differently depending on how Zero was installed, because
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
- **everything else**: downloads the verified release archive and replaces the
binary in place.
Homebrew is detected by the binary living inside a Cellar keg that carries
Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a
directory tree you built yourself can have exactly that shape, and refusing to
update an install Homebrew never touched would be a real cost for a made-up
reason. The receipt is the thing only Homebrew writes.
An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac
where that is also the Homebrew prefix.
+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)
}
+6
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
+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) {
+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
}
+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.
+3
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).
+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)
}
}
+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 {
+54 -11
View File
@@ -230,6 +230,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 +314,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 +326,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" {
+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,90 @@ 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 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 +290,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)
}
+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)
}
}
}
-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))
}
+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)
}
}
+16 -4
View File
@@ -57,9 +57,10 @@ func pasteFromClipboardCmd() tea.Cmd {
// shared by the terminal bracketed-paste handler (tea.PasteMsg) and the
// right-click paste (clipboardReadMsg) so a bracketed paste and a right-click
// paste behave identically. Surfaces with no editable text field (a permission/
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste. The session rename editor accepts text only; empty content is a
// no-op there rather than an image probe.
// spec prompt, the MCP manager, or the detailed transcript) swallow the paste.
// Searchable pickers consume pasted text through the same filtering path as
// typed characters. The session rename editor accepts text only; empty content
// is a no-op there rather than an image probe.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
@@ -101,7 +102,18 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
if m.providerWizard != nil {
return m.handleProviderWizardPaste(content)
}
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil {
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
return m, nil
}
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil {
return m, nil
}
// A drag-dropped image/PDF arrives as a (backslash-escaped) file path. Attach
+56
View File
@@ -121,3 +121,59 @@ func TestToolResultRowExposesClickToggle(t *testing.T) {
t.Fatalf("tool result visible body/footer must stay selectable, got %#v", selectable)
}
}
func TestAlwaysExpandedToolResultHeaderIsNotToggleable(t *testing.T) {
m := transcriptViewTestModel()
for _, tool := range []string{"edit_file", "apply_patch", "write_file"} {
row := transcriptRow{
kind: rowToolResult,
id: "diff",
tool: tool,
status: tools.StatusOK,
detail: "--- a/example.go\n+++ b/example.go\n@@ -1 +1 @@\n-old\n+new",
}
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
if len(selectable) == 0 {
t.Fatalf("%s: expected a visible diff result row", tool)
}
if selectable[0].toggle {
t.Fatalf("%s header must not advertise a collapse toggle: %#v", tool, selectable[0])
}
}
}
func TestToolResultOutputCannotCreateToggle(t *testing.T) {
m := transcriptViewTestModel()
row := transcriptRow{
kind: rowToolResult,
id: "custom",
tool: "custom_tool",
status: tools.StatusOK,
detail: "the file literally says: click to expand",
}
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
if len(selectable) == 0 {
t.Fatal("expected a visible tool result row")
}
if selectable[0].toggle {
t.Fatalf("tool-output text must not create a header toggle: %#v", selectable[0])
}
}
func TestCollapsedExploreToolResultHeaderIsToggleable(t *testing.T) {
m := transcriptViewTestModel()
row := transcriptRow{
kind: rowToolResult,
id: "read",
tool: "read_file",
status: tools.StatusOK,
detail: numberedLines(cardBodyMaxLines + 5),
}
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
if len(selectable) == 0 {
t.Fatal("expected a visible explore result row")
}
if !selectable[0].toggle {
t.Fatalf("collapsed explore card header must remain toggleable: %#v", selectable[0])
}
}
+2
View File
@@ -456,6 +456,7 @@ func (m model) handleModelCommand(args string) (model, string) {
// Keep sub-agent child processes on the same provider we just switched to.
config.SetActiveProviderEnv(nextProfile.Name)
m.modelName = target.modelID
m.serviceTier = ""
// Record the outgoing pair too, not just the destination: otherwise the
// model a session started on (never itself the target of a recordRecentModel
// call) would silently drop out of "Recent" the moment you switch away from
@@ -559,6 +560,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
m.providerProfile = target
m.providerName = target.Name
m.modelName = target.Model
m.serviceTier = ""
// An active profile's effort fill is per-model: re-derive it for the
// destination, exactly like handleModelCommand does. No generic
// unsupported-drop here: cross-provider targets are often custom models
+18 -1
View File
@@ -33,6 +33,7 @@ const (
commandCompact
commandRewind
commandEffort
commandFast
commandStyle
commandTheme
commandTranscript
@@ -53,6 +54,7 @@ const (
commandGoal
commandVoice
commandSTTModel
commandPets
commandUnknown
)
@@ -270,11 +272,18 @@ var commandDefinitions = []commandDefinition{
},
{
name: "/effort",
usage: "/effort [list|low|medium|high|auto]",
usage: "/effort [list|level|auto]",
group: commandGroupModel,
description: "Show or set reasoning effort for supported models.",
kind: commandEffort,
},
{
name: "/fast",
usage: "/fast",
group: commandGroupModel,
description: "Toggle fast mode for supported ChatGPT subscription models.",
kind: commandFast,
},
{
name: "/style",
usage: "/style [list|balanced|concise|explanatory|review]",
@@ -353,6 +362,14 @@ var commandDefinitions = []commandDefinition{
description: "Show available commands.",
kind: commandHelp,
},
{
name: "/pets",
aliases: []string{"/pet"},
usage: "/pets [name|off]",
group: commandGroupMeta,
description: "Choose, preview, or hide a terminal companion.",
kind: commandPets,
},
{
name: "/doctor",
aliases: []string{"/health"},
+2 -1
View File
@@ -47,7 +47,8 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
"model:",
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
" /model [list|id] - Show or switch the active model.",
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
" /effort [list|level|auto] - Show or set reasoning effort for supported models.",
" /fast - Toggle fast mode for supported ChatGPT subscription models.",
"session:",
" /plan [status|on|off] - Show plan status, or enter/exit read-only planning mode.",
"runtime:",
+34 -9
View File
@@ -61,6 +61,7 @@ type dictationController struct {
browseVariants []dictation.ModelVariant
// in-flight session state
sessionID int64
streaming bool
recorder Recorder
transcriber Transcriber
@@ -101,13 +102,17 @@ type dictationController struct {
}
// dictationStartedMsg reports the outcome of Recorder.Start().
// sessionID must match the dictation controller's current session so a late
// start result after cancel/reset cannot reset a newer recording.
type dictationStartedMsg struct {
err error
sessionID int64
err error
}
// dictationTranscribedMsg carries the final transcript of a batch (or a
// completed streaming) recording.
type dictationTranscribedMsg struct {
sessionID int64
text string
err error
submit bool
@@ -125,6 +130,7 @@ func newDictationController(opts Options) dictationController {
platform: dictation.DetectPlatform(),
downloadRoot: opts.STTDownloadRoot,
userConfigPath: opts.UserConfigPath,
sessionID: 1,
}
}
@@ -262,7 +268,7 @@ func (m model) startDictation() (model, tea.Cmd) {
if streaming {
return m.startStreamingDictation()
}
return m, startBatchRecordingCmd(rec)
return m, startBatchRecordingCmd(m.dictation.sessionID, rec)
}
// stopDictation ends a recording. For batch it triggers Stop()+Transcribe(); for
@@ -276,7 +282,7 @@ func (m model) stopDictation() (model, tea.Cmd) {
}
return m, nil // final text arrives via the streaming command already running
}
return m, transcribeBatchCmd(m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
return m, transcribeBatchCmd(m.dictation.sessionID, m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
}
// cancelDictation aborts an in-flight recording without transcribing. Bound to
@@ -311,6 +317,7 @@ func (d dictationController) maxDuration() time.Duration {
}
func (d *dictationController) reset() {
d.sessionID++
d.phase = dictIdle
d.recorder = nil
d.transcriber = nil
@@ -326,31 +333,36 @@ func (d *dictationController) reset() {
// startBatchRecordingCmd starts the record-to-file capture off the UI goroutine
// (Start spawns a subprocess and may briefly block or fail on a missing tool).
func startBatchRecordingCmd(rec Recorder) tea.Cmd {
func startBatchRecordingCmd(sessionID int64, rec Recorder) tea.Cmd {
return func() tea.Msg {
return dictationStartedMsg{err: rec.Start()}
return dictationStartedMsg{sessionID: sessionID, err: rec.Start()}
}
}
// transcribeBatchCmd stops the recording, transcribes the audio, and reports the
// final text. Runs off the UI goroutine — Stop waits on the capture tool and
// Transcribe does a network round-trip or a local exec.
func transcribeBatchCmd(ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
func transcribeBatchCmd(sessionID int64, ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
if ctx == nil {
ctx = context.Background()
}
return func() tea.Msg {
audio, err := rec.Stop()
if err != nil {
return dictationTranscribedMsg{err: err}
return dictationTranscribedMsg{sessionID: sessionID, err: err}
}
text, err := transcriber.Transcribe(ctx, audio)
return dictationTranscribedMsg{text: text, err: err, submit: submit}
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit}
}
}
// handleDictationStarted transitions to recording (or reports a start failure).
func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) {
if msg.sessionID != m.dictation.sessionID {
// Stale startup from a cancelled session must not reset or re-arm a
// newer recording that already owns the controller.
return m, nil
}
if msg.err != nil {
m = m.discardDictationRegion()
m.dictation.reset()
@@ -375,6 +387,9 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd)
// handleDictationTranscribed inserts the final transcript into the composer (or
// submits it when stt.autoSubmit is on), then returns to idle.
func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Model, tea.Cmd) {
if msg.sessionID != m.dictation.sessionID {
return m, nil
}
streaming := m.dictation.streaming || msg.streaming
m = m.commitDictationRegion()
// A streaming session can end via a transcriber error (not just a user stop), so
@@ -392,7 +407,17 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
}
m.dictation.reset()
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
if errors.Is(msg.err, context.Canceled) {
// cancelDictation already discarded the live region, reset the
// session, and posted "Dictation cancelled." A streaming transcriber
// can still race a buffered event past that cancel and report back a
// nonempty compose()+context.Canceled; treat that as terminal here,
// before the auto-submit branch below, so Esc can never fall through
// to msg.submit and fire the composer's restored pre-existing text.
return m, nil
}
if msg.err != nil {
// A cloud auth failure (missing/invalid key) is fixable in place: reopen the
// API-key prompt for the current provider so the user can paste a key and
// retry, instead of hitting a dead-end "run zero auth" line.
+9 -4
View File
@@ -14,8 +14,9 @@ import (
// mechanism agent text deltas use (§6). text is the cumulative best transcript
// so far (not a delta); final marks a settled segment.
type sttPartialMsg struct {
text string
final bool
sessionID int64
text string
final bool
}
// startStreamingDictation begins continuous capture and launches the streaming
@@ -38,6 +39,7 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
}
transcriber := m.dictation.transcriber
submit := m.dictation.cfg.AutoSubmitEnabled()
sessionID := m.dictation.sessionID
// Tap the audio: compute a mic level per chunk for the live waveform, then
// forward the chunk to the transcriber. A small buffer keeps the tap from
@@ -63,11 +65,11 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
streamCmd := func() tea.Msg {
onPartial := func(text string, final bool) {
if sink != nil {
sink(sttPartialMsg{text: text, final: final})
sink(sttPartialMsg{sessionID: sessionID, text: text, final: final})
}
}
text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial)
return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true}
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit, streaming: true}
}
// Streaming drives the waveform from real levels (no synthetic tick needed).
return m, streamCmd
@@ -77,6 +79,9 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
// composer, replacing the previously-rendered live region wholesale so the text
// builds up in place as the user keeps talking.
func (m model) handleDictationPartial(msg sttPartialMsg) model {
if msg.sessionID != m.dictation.sessionID {
return m
}
// Ignore stragglers that arrive after the session ended (cancel/final).
if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing {
return m
+181 -2
View File
@@ -86,9 +86,10 @@ func TestDictationNonAuthErrorDoesNotPrompt(t *testing.T) {
func TestDictationStartedFailureResets(t *testing.T) {
m := model{}
m.dictation.sessionID = 1
m.dictation.phase = dictStarting
m.dictation.streaming = false
got, _ := m.handleDictationStarted(dictationStartedMsg{err: errors.New("mic busy")})
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1, err: errors.New("mic busy")})
if got.dictation.active() {
t.Error("a start failure should reset to idle")
}
@@ -99,13 +100,48 @@ func TestDictationStartedFailureResets(t *testing.T) {
func TestDictationStartedArmsRecording(t *testing.T) {
m := model{}
m.dictation.sessionID = 1
m.dictation.phase = dictStarting
got, _ := m.handleDictationStarted(dictationStartedMsg{})
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1})
if got.dictation.phase != dictRecording {
t.Error("a successful start should advance to recording")
}
}
// TestStaleDictationStartedIgnoredWhileLive pins the session gate on batch
// startup messages. A late Start() result after cancel must not reset() a
// newer recording (success would leave dictStarting with no recorder; failure
// would kill the live session).
func TestStaleDictationStartedIgnoredWhileLive(t *testing.T) {
// Active recording on session 2.
m := model{}
m.dictation.sessionID = 2
m.dictation.phase = dictRecording
m.dictation.streaming = true
m.setComposerState(composerState{text: "live session text", cursor: len("live session text")})
// Stale failure from session 1 must not reset the live controller.
afterFail, _ := m.handleDictationStarted(dictationStartedMsg{
sessionID: 1,
err: errors.New("mic busy from previous start"),
})
if afterFail.dictation.phase != dictRecording || afterFail.dictation.sessionID != 2 {
t.Fatalf("stale start failure: phase=%v session=%d, want recording/2", afterFail.dictation.phase, afterFail.dictation.sessionID)
}
if transcriptHasText(afterFail, "mic busy from previous start") {
t.Fatal("stale start failure must not post an error notice")
}
// Stale success from session 1 must not leave the live session stuck mid-start.
m2 := model{}
m2.dictation.sessionID = 2
m2.dictation.phase = dictRecording
afterOK, _ := m2.handleDictationStarted(dictationStartedMsg{sessionID: 1})
if afterOK.dictation.phase != dictRecording || afterOK.dictation.sessionID != 2 {
t.Fatalf("stale start success: phase=%v session=%d, want recording/2", afterOK.dictation.phase, afterOK.dictation.sessionID)
}
}
func TestDictationUnavailableShowsHint(t *testing.T) {
m := model{} // no build factory
next, _ := m.toggleDictation()
@@ -136,6 +172,69 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
}
}
func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) {
// Esc can race an already-buffered realtime event: cancelDictation discards
// the live region and resets state synchronously, but the streaming
// goroutine's dictationTranscribedMsg (a nonempty compose() alongside
// context.Canceled) can still arrive afterward. With stt.autoSubmit on,
// that must never fall through to msg.submit and fire the composer's
// restored pre-existing text.
// Matching sessionID is required so this hits the context.Canceled branch
// rather than only the stale-session gate (session 0 after cancel would be
// dropped before that branch runs).
m := model{}
m.dictation.sessionID = 1
m.setComposerState(composerState{text: "existing prompt", cursor: len("existing prompt")})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{sessionID: 1, text: "half-formed transcript"})
if m.composer.text != "existing prompt half-formed transcript" {
t.Fatalf("partial did not render into composer: %q", m.composer.text)
}
m, _ = m.cancelDictation()
if m.composer.text != "existing prompt" {
t.Fatalf("cancel should restore the pre-existing composer text, got %q", m.composer.text)
}
// The already-buffered event's message arrives after the cancel above.
next, cmd := m.handleDictationTranscribed(dictationTranscribedMsg{
sessionID: m.dictation.sessionID,
text: "half-formed transcript",
err: context.Canceled,
submit: true,
streaming: true,
})
got := next.(model)
if got.composer.text != "existing prompt" {
t.Errorf("a raced cancellation must not auto-submit the restored composer text, got %q", got.composer.text)
}
if cmd != nil {
t.Error("a raced cancellation must not return a submit command")
}
}
// TestStaleDictationPartialIgnoredWhileLive pins the session-ID gate on
// handleDictationPartial while a new session is still recording. Cancel-then-
// stale cases are already covered by the phase guard; this case needs a live
// phase so only the session check drops the ghost partial.
func TestStaleDictationPartialIgnoredWhileLive(t *testing.T) {
m := model{}
m.dictation.sessionID = 2
m.dictation.phase = dictRecording
m.dictation.streaming = true
m.setComposerState(composerState{text: "user typed this", cursor: len("user typed this")})
got := m.handleDictationPartial(sttPartialMsg{
sessionID: 1,
text: "ghost text from session 1",
})
if got.composer.text != "user typed this" {
t.Fatalf("stale partial while live: got %q, want %q", got.composer.text, "user typed this")
}
}
func TestDictationCommitKeepsStreamedText(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "", cursor: 0})
@@ -302,3 +401,83 @@ func TestCurrentModelLabel(t *testing.T) {
t.Errorf("no-model label = %q", got)
}
}
func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "original composer text", cursor: 22})
m.dictation.sessionID = 1
m.dictation.phase = dictRecording
m, _ = m.cancelDictation()
if m.composer.text != "original composer text" {
t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text)
}
// Stale partial message from session 1 must be ignored
stalePartial := sttPartialMsg{
sessionID: 1,
text: "stale partial that should be ignored",
}
mPartial := m.handleDictationPartial(stalePartial)
if mPartial.composer.text != "original composer text" {
t.Fatalf("composer text changed to %q after stale partial", mPartial.composer.text)
}
staleMsg := dictationTranscribedMsg{
sessionID: 1,
text: "stale text that should be ignored",
submit: true,
streaming: true,
}
afterStale, cmd := m.handleDictationTranscribed(staleMsg)
got := afterStale.(model)
if got.composer.text != "original composer text" {
t.Fatalf("composer text changed to %q after stale completion", got.composer.text)
}
if cmd != nil {
t.Fatalf("expected no command for stale completion, got %v", cmd)
}
}
func TestStaleDictationSessionZeroIgnoredAfterCancel(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "initial composer text", cursor: 21})
m.dictation.sessionID = 0
m.dictation.phase = dictRecording
m, _ = m.cancelDictation()
// Session 0 was reset to session 1
if m.dictation.sessionID != 1 {
t.Fatalf("dictation.sessionID = %d after cancel, want 1", m.dictation.sessionID)
}
// Stale partial from session 0 must be ignored
stalePartial := sttPartialMsg{
sessionID: 0,
text: "stale partial from session 0",
}
mPartial := m.handleDictationPartial(stalePartial)
if mPartial.composer.text != "initial composer text" {
t.Fatalf("composer text changed to %q after stale session 0 partial", mPartial.composer.text)
}
// Stale completion from session 0 must be ignored
staleMsg := dictationTranscribedMsg{
sessionID: 0,
text: "stale completion from session 0",
submit: true,
streaming: true,
}
afterStale, cmd := m.handleDictationTranscribed(staleMsg)
got := afterStale.(model)
if got.composer.text != "initial composer text" {
t.Fatalf("composer text changed to %q after stale session 0 completion", got.composer.text)
}
if cmd != nil {
t.Fatalf("expected no command for stale session 0 completion, got %v", cmd)
}
}
+64
View File
@@ -0,0 +1,64 @@
package tui
import "strings"
// Visible unchanged lines kept at each edge of a collapsed diff section.
const diffViewerContextLines = 3
// diffViewerLine is a source diff line or a synthetic collapsed-context row.
// rawIndex keeps rendering anchored to the original diff so line-pair and
// syntax-highlighting logic remain correct after context compaction.
type diffViewerLine struct {
text string
rawIndex int
hiddenContext int
}
// compactDiffViewerContext returns the display sequence for a unified diff.
// Long contiguous unchanged runs keep their leading and trailing context while
// one synthetic line accounts for the elided middle. Structural and changed
// rows always remain intact.
func compactDiffViewerContext(raw []string) []diffViewerLine {
lines := make([]diffViewerLine, 0, len(raw))
hunk := diffHunkState{}
for index := 0; index < len(raw); {
if parsed, ok := parseDiffHunkHeader(raw[index]); ok {
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
hunk = parsed
index++
continue
}
if !hunk.active() || !strings.HasPrefix(raw[index], " ") {
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
if hunk.active() && isDiffHunkBodyLine(raw[index]) {
hunk.consume(raw[index])
}
index++
continue
}
start := index
for index < len(raw) && hunk.active() && strings.HasPrefix(raw[index], " ") {
hunk.consume(raw[index])
index++
}
count := index - start
// Collapsing seven lines would replace one readable line with one marker,
// leaving the display equally tall. Only collapse when it removes space.
if count <= diffViewerContextLines*2+1 {
for i := start; i < index; i++ {
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
}
continue
}
for i := start; i < start+diffViewerContextLines; i++ {
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
}
lines = append(lines, diffViewerLine{rawIndex: -1, hiddenContext: count - diffViewerContextLines*2})
for i := index - diffViewerContextLines; i < index; i++ {
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
}
}
return lines
}
+402
View File
@@ -0,0 +1,402 @@
package tui
import (
"fmt"
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
)
func requireDiffViewerTrueColor(t *testing.T) {
t.Helper()
oldProfile := lipgloss.Writer.Profile
lipgloss.Writer.Profile = colorprofile.TrueColor
t.Cleanup(func() {
lipgloss.Writer.Profile = oldProfile
})
}
func TestDiffViewerUsesHunkHeaderForLineNumbers(t *testing.T) {
diff := strings.Join([]string{
"--- a/internal/tui/view.go",
"+++ b/internal/tui/view.go",
"@@ -40,3 +40,4 @@ func renderView() {",
" context",
"-old line",
"+new line",
}, "\n")
body := diffCardBody(diff, 90, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if strings.Contains(got, "@@") || !strings.Contains(got, " 41 − old line") || !strings.Contains(got, " 41 + new line") {
t.Fatalf("diff viewer did not apply the hunk range correctly:\n%s", got)
}
}
func TestDiffViewerCollapsesLongUnchangedContext(t *testing.T) {
lines := []string{
"--- a/x.go",
"+++ b/x.go",
"@@ -1,12 +1,12 @@",
}
for i := 1; i <= 12; i++ {
lines = append(lines, " context "+string(rune('a'+i-1)))
}
diff := strings.Join(lines, "\n")
body := diffCardBody(diff, 80, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "… 6 unchanged lines") {
t.Fatalf("diff viewer did not collapse long unchanged context:\n%s", got)
}
for _, want := range []string{"context a", "context b", "context c", "context j", "context k", "context l"} {
if !strings.Contains(got, want) {
t.Errorf("diff viewer should preserve contextual edge %q:\n%s", want, got)
}
}
}
func TestDiffViewerKeepsSevenUnchangedContextLines(t *testing.T) {
lines := []string{
"--- a/x.go",
"+++ b/x.go",
"@@ -1,7 +1,7 @@",
}
for i := 1; i <= diffViewerContextLines*2+1; i++ {
lines = append(lines, " context "+string(rune('a'+i-1)))
}
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if strings.Contains(got, "unchanged lines") {
t.Fatalf("seven unchanged context lines should remain visible:\n%s", got)
}
for _, want := range []string{"context a", "context d", "context g"} {
if !strings.Contains(got, want) {
t.Errorf("diff viewer omitted %q:\n%s", want, got)
}
}
}
func TestDiffViewerKeepsLineNumbersAfterCollapsedContext(t *testing.T) {
lines := []string{
"--- a/x.go",
"+++ b/x.go",
"@@ -1,13 +1,13 @@",
}
for i := 1; i <= 12; i++ {
lines = append(lines, " context "+string(rune('a'+i-1)))
}
lines = append(lines, "-old value", "+new value")
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, " 13 − old value") || !strings.Contains(got, " 13 + new value") {
t.Fatalf("line numbers drifted after collapsed context:\n%s", got)
}
}
func TestDiffViewerSyntheticContextHasNoSourceIndex(t *testing.T) {
raw := make([]string, 0, diffViewerContextLines*2+2)
for index := 0; index < diffViewerContextLines*2+2; index++ {
raw = append(raw, " context")
}
for _, line := range compactDiffViewerContext(raw) {
if line.hiddenContext > 0 && line.rawIndex != -1 {
t.Fatalf("synthetic collapsed context rawIndex = %d, want -1", line.rawIndex)
}
}
}
func TestDiffViewerNearRewriteDeletionSurvives(t *testing.T) {
diff := strings.Join([]string{
"--- a/notes.log",
"+++ b/notes.log",
"@@ -1 +1 @@",
"-alpha bravo charlie delta",
"+zulu yankee xray whiskey",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "alpha bravo charlie delta") {
t.Fatalf("near-rewrite deletion disappeared:\n%s", got)
}
}
func TestDiffViewerRendersStyledDeletionOnce(t *testing.T) {
requireDiffViewerTrueColor(t)
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"-func value() string { return \"old\" }",
"+func value() string { return \"new\" }",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if count := strings.Count(got, "func value() string { return \"old\" }"); count != 1 {
t.Fatalf("styled deleted row count = %d, want 1:\n%s", count, got)
}
}
func TestDiffViewerDoesNotFoldPreambleContext(t *testing.T) {
raw := []string{
"commit deadbeef",
"Author: Example",
"",
}
for index := 1; index <= 10; index++ {
raw = append(raw, fmt.Sprintf(" commit message line %d", index))
}
raw = append(raw,
"--- a/example.go",
"+++ b/example.go",
"@@ -1,1 +1,1 @@",
"-old value",
"+new value",
)
compacted := compactDiffViewerContext(raw)
for _, line := range compacted {
if line.hiddenContext > 0 {
t.Fatalf("preamble context was folded: %#v", compacted)
}
}
if len(compacted) != len(raw) {
t.Fatalf("compacted preamble length = %d, want %d", len(compacted), len(raw))
}
}
func TestDiffViewerKeepsHeaderLikeHunkContent(t *testing.T) {
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"--- removed",
"+++ added",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
for _, want := range []string{"-- removed", "++ added"} {
if !strings.Contains(got, want) {
t.Fatalf("diff viewer lost header-like hunk content %q:\n%s", want, got)
}
}
meta := diffCardMetadata(diff)
if meta.adds != 1 || meta.dels != 1 {
t.Fatalf("diff metadata counts = +%d -%d, want +1 -1", meta.adds, meta.dels)
}
if _, ok := highlightedDiffLines(strings.Split(diff, "\n"), meta)[3]; !ok {
t.Fatal("deleted header-like hunk content was not syntax highlighted")
}
}
func TestDiffViewerSourcePathStripsOnlyOneDiffPrefix(t *testing.T) {
if got, want := diffViewerSourcePath("--- a/b/c.go"), "b/c.go"; got != want {
t.Fatalf("diffViewerSourcePath = %q, want %q", got, want)
}
}
func TestDiffViewerSyntaxHighlightsBothChangedSides(t *testing.T) {
requireDiffViewerTrueColor(t)
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"-func greet(name string) string { return \"old\" }",
"+func greet(name string) string { return \"new\" }",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
joined := strings.Join(body.lines, "\n")
// `func` uses Zero's accent and string literals use its green token style.
if !strings.Contains(joined, "202;255;63") || !strings.Contains(joined, "93;209;164") {
t.Fatalf("diff viewer did not retain Go syntax colors in changed rows:\n%s", joined)
}
// The exact changed string literal keeps the brighter word-diff background.
if !strings.Contains(joined, "46;101;77") || !strings.Contains(joined, "80;45;48") {
t.Fatalf("diff viewer lost word-level changed-span emphasis:\n%s", joined)
}
}
func TestDiffViewerSyntaxHighlightsUnchangedHunkContext(t *testing.T) {
requireDiffViewerTrueColor(t)
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1,3 +1,3 @@",
" func greet() string {",
"-\treturn \"old\"",
"+\treturn \"new\"",
" }",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
for _, line := range body.lines {
if strings.Contains(plainRender(t, line), "func greet() string {") {
if !strings.Contains(line, "202;255;63") {
t.Fatalf("unchanged source context was not syntax-highlighted: %q", line)
}
return
}
}
t.Fatal("diff viewer omitted unchanged source context")
}
func TestDiffViewerPreservesSyntaxStateAcrossHunkLines(t *testing.T) {
requireDiffViewerTrueColor(t)
diff := strings.Join([]string{
"--- a/example.py",
"+++ b/example.py",
"@@ -1,3 +1,4 @@",
" def message():",
"- return \"\"\"old\"\"\"",
"+ return \"\"\"new",
"+continued\"\"\"",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
for _, line := range body.lines {
if strings.Contains(plainRender(t, line), "continued") {
if !strings.Contains(line, "93;209;164") {
t.Fatalf("multiline string lost syntax state across hunk rows: %q", line)
}
return
}
}
t.Fatal("diff viewer omitted multiline source row")
}
func TestDiffViewerKeepsNonHunkDiffFallback(t *testing.T) {
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"+func greet() string { return \"new\" }",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "func greet() string { return \"new\" }") {
t.Fatalf("diff viewer lost content without a hunk header:\n%s", got)
}
}
func TestDiffViewerKeepsRenameMetadataWithoutHunks(t *testing.T) {
diff := strings.Join([]string{
"diff --git a/old-name.go b/new-name.go",
"similarity index 100%",
"rename from old-name.go",
"rename to new-name.go",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "rename from old-name.go") || !strings.Contains(got, "rename to new-name.go") {
t.Fatalf("rename-only diff lost its meaningful metadata:\n%s", got)
}
}
func TestDiffViewerKeepsModeMetadataWithoutHunks(t *testing.T) {
diff := strings.Join([]string{
"diff --git a/tool.sh b/tool.sh",
"old mode 100644",
"new mode 100755",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "old mode 100644") || !strings.Contains(got, "new mode 100755") {
t.Fatalf("mode-only diff lost its meaningful metadata:\n%s", got)
}
}
func TestDiffViewerFallsBackForOversizedSourceLine(t *testing.T) {
requireDiffViewerTrueColor(t)
longLine := strings.Repeat("x", diffViewerHighlightMaxLineBytes)
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"+" + longLine,
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
joined := strings.Join(body.lines, "\n")
if strings.Contains(joined, "202;255;63") {
t.Fatalf("oversized diff line should use the plain diff fallback: %q", joined)
}
if !strings.Contains(plainRender(t, joined), strings.Repeat("x", 10)) {
t.Fatalf("plain fallback lost oversized source line: %q", joined)
}
}
func TestDiffViewerSyntaxHighlighterKeepsSpans(t *testing.T) {
requireDiffViewerTrueColor(t)
styled, ok := highlightCodeForPathWithSpans(
[]string{"func greet(name string) string { return \"new\" }"},
"example.go",
200,
zeroTheme.addLine.GetBackground(),
[]highlightSpan{{line: 0, start: 39, end: 44, background: zeroTheme.addLineWord.GetBackground()}},
)
if !ok || len(styled) != 1 {
t.Fatalf("highlightCodeForPathWithSpans = %#v, %v", styled, ok)
}
if !strings.Contains(styled[0], "46;101;77") {
t.Fatalf("highlighted span lost its word-diff background: %q", styled[0])
}
}
func TestDiffViewerHighlightsEachFileWithItsOwnPath(t *testing.T) {
requireDiffViewerTrueColor(t)
rawLines := []string{
"diff --git a/example.go b/example.go",
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"-func old() {}",
"+func new() {}",
"diff --git a/example.py b/example.py",
"--- a/example.py",
"+++ b/example.py",
"@@ -1 +1 @@",
"-def old(): pass",
"+def new(): pass",
}
highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n")))
if len(highlighted) != 4 {
t.Fatalf("highlighted line count = %d, want 4: %#v", len(highlighted), highlighted)
}
for _, index := range []int{4, 5, 10, 11} {
if !strings.Contains(highlighted[index], "202;255;63") {
t.Fatalf("source row %d lost syntax highlighting: %q", index, highlighted[index])
}
}
for _, header := range []int{2, 3, 8, 9} {
if _, ok := highlighted[header]; ok {
t.Fatalf("file header at row %d entered syntax highlighter: %q", header, highlighted[header])
}
}
}
func TestDiffViewerHighlightBudgetSpansHunks(t *testing.T) {
hunkLines := diffViewerHighlightMaxLines/2 + 1
rawLines := []string{
"--- a/example.go",
"+++ b/example.go",
fmt.Sprintf("@@ -1,%d +1,%d @@", hunkLines, hunkLines),
}
for i := 0; i < hunkLines; i++ {
rawLines = append(rawLines, " context")
}
rawLines = append(rawLines, fmt.Sprintf("@@ -6000,%d +6000,%d @@", hunkLines, hunkLines))
for i := 0; i < hunkLines; i++ {
rawLines = append(rawLines, " context")
}
if highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n"))); highlighted != nil {
t.Fatalf("diff exceeding the aggregate highlight budget should use the plain fallback: %d highlighted rows", len(highlighted))
}
}
+2 -17
View File
@@ -2,9 +2,7 @@
package tui
import (
"context"
"fmt"
"os"
"strings"
"time"
@@ -104,20 +102,6 @@ func (s planPanelState) height(width int, now time.Time) int {
return 2
}
func GetLocalDiffStats(baseBranch string) (additions int, deletions int, err error) {
cwd, err := os.Getwd()
if err != nil {
return 0, 0, err
}
ctx, cancel := context.WithTimeout(context.Background(), prCommandTimeout)
defer cancel()
return getLocalDiffStats(ctx, cwd, baseBranch, defaultPRCommandRunner)
}
func WatchPRState(service *PrService, onChange func(PrState)) func() {
return WatchPRStateContext(context.Background(), service, onChange)
}
func (c *staticRenderCache) retainedCharacters() int {
if c == nil {
return 0
@@ -244,7 +228,8 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
}
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow, opts)
}
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
+97
View File
@@ -0,0 +1,97 @@
package tui
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
func TestFastCommandTogglesOnlyAdvertisedTier(t *testing.T) {
m := model{
modelName: "gpt-fast",
providerName: "ChatGPT",
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
},
}
m, _ = m.handleFastCommand("")
if got := m.activeServiceTier(); got != "priority" {
t.Fatalf("active service tier = %q, want priority", got)
}
m, _ = m.handleFastCommand("")
if got := m.activeServiceTier(); got != "" {
t.Fatalf("active service tier = %q after second toggle, want off", got)
}
}
func TestFastCommandRejectsUnsupportedModel(t *testing.T) {
m := model{
modelName: "gpt-standard",
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-standard"}},
},
serviceTier: "priority",
}
next, text := m.handleFastCommand("")
if next.activeServiceTier() != "" || next.serviceTier != "" {
t.Fatalf("unsupported model retained fast tier: %#v", next.serviceTier)
}
if !strings.Contains(text, "ChatGPT subscription") {
t.Fatalf("unsupported response = %q", text)
}
}
func TestFastCommandRejectsMeteredPriorityTier(t *testing.T) {
m := model{
modelName: "gpt-metered",
providerProfile: config.ProviderProfile{CatalogID: "openai"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"openai": {{ID: "gpt-metered", ServiceTiers: []string{"priority"}}},
},
serviceTier: "priority",
}
next, text := m.handleFastCommand("")
if got := next.activeServiceTier(); got != "" {
t.Fatalf("metered profile forwarded service tier %q", got)
}
if next.serviceTier != "" {
t.Fatalf("metered profile retained service tier %q", next.serviceTier)
}
if !strings.Contains(text, "ChatGPT subscription") {
t.Fatalf("metered response = %q", text)
}
}
func TestFastCommandDoesNotChangeDuringRun(t *testing.T) {
m := model{
pending: true,
modelName: "gpt-fast",
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
},
}
next, text := m.handleFastCommand("")
if next.serviceTier != "" || !strings.Contains(text, "Finish or stop") {
t.Fatalf("pending fast mutation = tier %q text %q", next.serviceTier, text)
}
}
func TestFastCommandDoesNotInterpretArguments(t *testing.T) {
m := model{
modelName: "gpt-fast",
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
},
}
next, text := m.handleFastCommand("status")
if next.serviceTier != "" || !strings.Contains(text, "Use /fast to toggle") {
t.Fatalf("argument changed fast state: tier %q text %q", next.serviceTier, text)
}
}
+2 -2
View File
@@ -40,8 +40,8 @@ func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) {
if m.modelPickerIsLoading() {
return m, nil, true
}
m.pickerMoved(delta)
return m, nil, true
m, cmd := m.pickerMoved(delta)
return m, cmd, true
}
if m.suggestionsActive() {
m.moveSuggestion(delta)
+176 -10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
@@ -15,6 +16,7 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/agent"
@@ -33,6 +35,7 @@ import (
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/usercommands"
@@ -138,6 +141,7 @@ type model struct {
permissionModeBeforePlan agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
serviceTier string
// Active execution profile (set by /profile; applies to the NEXT run).
// The displaced/applied pairs let a switch or /profile balanced restore
// exactly what the profile replaced while leaving later manual overrides
@@ -155,6 +159,48 @@ type model struct {
execProfileEffortTouched bool
execProfileSelfCorrectTouched bool
responseStyle string
petClient *terminalpet.Client
petRenderer *terminalpet.ImageRenderer
petEntries map[string]terminalpet.Entry
petID string
petName string
petAnimation *terminalpet.Animation
petPreview *terminalpet.Animation
petPreviewSlug string
petPreviewError string
petPreviewLoading bool
petPreviewSeq uint64
petPreviewCancel context.CancelFunc
petRequestedSlug string
petPhase int
petTickSeq uint64
petPlaybackState terminalpet.State
petClickAnimationIndex int
petOutcome terminalpet.State
petOutcomeAt time.Time
petLayoutRendering bool
petPositionSet bool
petPositionX int
petPositionY int
petDragActive bool
petDragMoved bool
petDragStartedDocked bool
petDragOffsetX int
petDragOffsetY int
petDragTargetX int
petDragTargetY int
petDragTargetOffsetX int
petDragTargetOffsetY int
petPositionOffsetX int
petPositionOffsetY int
petCellPixelWidth int
petCellPixelHeight int
petPixelDrag bool
petPixelAnchorSet bool
petDragOffsetPixelX int
petDragOffsetPixelY int
petDragState terminalpet.State
petLastClickAt time.Time
keyBindings keyBindings
themeMode themeMode // palette preference: auto (default), dark, light
hasDarkBg bool // last terminal background-detection result (auto mode)
@@ -928,6 +974,8 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
petEntries: map[string]terminalpet.Entry{},
petID: strings.TrimSpace(options.SavedPet),
keyBindings: resolvedKeyBindings,
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
@@ -951,6 +999,22 @@ func newModel(ctx context.Context, options Options) model {
setupSave: options.Setup.Save,
dictation: newDictationController(options),
}
petRoot := userConfigDir
if strings.TrimSpace(options.UserConfigPath) != "" {
petRoot = filepath.Dir(options.UserConfigPath)
}
if strings.TrimSpace(petRoot) != "" {
m.petClient = terminalpet.NewClient(petRoot)
if m.petID != "" && m.petID != terminalpet.DisabledID {
if animation, loadErr := m.petClient.LoadInstalled(m.petID); loadErr == nil {
m.petAnimation = animation
m.petName = m.petID
if entry, entryErr := m.petClient.InstalledEntry(m.petID); entryErr == nil {
m.petName = entry.Label()
}
}
}
}
// Apply an explicit theme immediately; auto stays on the dark default until
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
if m.themeMode != themeAuto {
@@ -1033,6 +1097,17 @@ func composerBlinkCmd() tea.Cmd {
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
if m.petAnimation != nil && !m.reducedMotion {
cmds = append(cmds, petTickCmd(m.petTickSeq, m.petFrameDelay()))
}
// Every image protocol wants this, not just the ones that support pixel
// dragging. Sixel needs it most: it erases itself by writing over cells, so
// without the pixels-per-cell figure it cannot know how many cells it
// covered, and falls back to constants describing the reserved area rather
// than the image.
if m.petCellMetricsWanted() {
cmds = append(cmds, tea.Raw(ansi.WindowOp(16)))
}
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
// on program start, so m.height/m.width are normally set before the first
// render. But that's the terminal proactively pushing a size — if it's
@@ -1234,6 +1309,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return next, cmd
}
switch msg := msg.(type) {
case uv.CellSizeEvent:
if msg.Width > 0 && msg.Height > 0 {
m.petCellPixelWidth = msg.Width
m.petCellPixelHeight = msg.Height
}
return m, nil
case peerMessageMsg:
admitted := m.canAcceptPeerMessage(msg.message)
if msg.admit != nil {
@@ -1396,6 +1477,19 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.KeyPressMsg:
if m.petDragActive {
pixelDrag := m.petPixelDrag
if !keyIs(msg, tea.KeyEsc) && !keyCtrl(msg, 'c') {
return m, nil
}
m.cancelPetDrag()
m.lastKeyTime = time.Time{}
m.burstCount = 0
if pixelDrag {
return m, petPixelMouseDisableCmd()
}
return m, nil
}
// Paste-detection timing trackers. MUST run before any early return
// so burst counting stays accurate regardless of which branch fires.
now := m.now()
@@ -1607,6 +1701,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// committed palette since Esc dismisses without choosing.
m.restoreCommittedTheme()
}
if m.picker.kind == pickerPet {
m.cancelPetPreview()
}
m.picker = nil
return m, nil
}
@@ -1835,6 +1932,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// Editing the filter changes which row is highlighted; keep the
// theme preview in sync with it (no-op for other pickers).
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
return m, nil
}
// On an empty composer, Backspace removes the last attachment chip
@@ -2057,6 +2157,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// Filtering changes the highlighted row; keep the theme preview in
// sync with it (no-op for other pickers).
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
}
return m, nil
}
@@ -2085,12 +2188,22 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.BlurMsg:
var petMouseCmd tea.Cmd
if m.petDragActive {
pixelDrag := m.petPixelDrag
m.cancelPetDrag()
m.lastKeyTime = time.Time{}
m.burstCount = 0
if pixelDrag {
petMouseCmd = petPixelMouseDisableCmd()
}
}
m.terminalFocused = false
m.composerCursorVisible = false
if m.notifier != nil {
m.notifier.SetFocused(false)
}
return m, nil
return m, petMouseCmd
case toolCallStreamStartMsg:
if msg.runID != m.activeRunID {
return m, nil
@@ -2261,6 +2374,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.WindowSizeMsg:
m.resizeFreePetPosition(m.width, m.height, msg.Width, msg.Height)
m.width = msg.Width
m.height = msg.Height
// A resize re-wraps content at a new width, shifting every row's bodyY;
@@ -2419,6 +2533,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
if msg.err != nil {
m.petOutcome = terminalpet.Failed
} else {
m.petOutcome = terminalpet.Review
}
m.petOutcomeAt = m.now()
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// A newline-triggered redraw deferred by the stream-clear throttle
@@ -2807,6 +2927,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.applySetupOAuthDeviceCode(msg)
case modelPickerModelsDiscoveredMsg:
return m.applyModelPickerModelsDiscovered(msg), nil
case petCatalogLoadedMsg:
return m.applyPetCatalog(msg)
case petPreviewDebounceMsg:
return m.startPetPreview(msg)
case petPreviewLoadedMsg:
m = m.applyPetPreview(msg)
if m.petPreview != nil && m.petAnimation == nil && !m.reducedMotion {
m.petTickSeq++
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
}
return m, nil
case petInstalledMsg:
return m.applyPetInstall(msg)
case petTickMsg:
if msg.seq != m.petTickSeq {
return m, nil
}
if (m.petAnimation == nil && (m.picker == nil || m.petPreview == nil)) || m.reducedMotion {
return m, nil
}
_, state := m.petPlayback()
if state != m.petPlaybackState {
m.petPlaybackState = state
m.petPhase = 0
} else {
m.petPhase++
}
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
case ollamaContextWindowDiscoveredMsg:
if msg.err == nil && msg.contextWindow > 0 {
if m.ollamaContextWindowByModel == nil {
@@ -2836,6 +2984,9 @@ func (m model) View() tea.View {
} else {
content = m.detailedTranscriptView()
}
if m.petRenderer != nil {
m.petRenderer.Set(m.petImageDraw(content))
}
view := tea.NewView(content)
view.AltScreen = m.altScreen
@@ -2908,6 +3059,9 @@ func (m model) transcriptView() string {
if m.sidebarActive() && !m.subchat.active {
return m.twoColumnTranscriptView()
}
if m.petLayoutActive() {
return m.floatingPetTranscriptView()
}
width := chatWidth(m.width)
@@ -3012,7 +3166,6 @@ func (m model) twoColumnTranscriptView() string {
header := m.pinnedTitleBar(width)
chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport))
sidebar := m.renderContextSidebar(sidebarW, len(chatBlock))
rows := joinColumns(chatBlock, sidebar, chatW, sidebarW)
return strings.Join(rows, "\n")
@@ -3040,7 +3193,7 @@ func (m model) footerView(width int) string {
if m.renamePrompt != nil {
footer.WriteString(m.sessionRenamePromptView(width))
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
footer.WriteString(m.footerStatusLine(width))
return footer.String()
}
// While an ask-user questionnaire is active it REPLACES the composer box (the
@@ -3049,7 +3202,7 @@ func (m model) footerView(width int) string {
if m.pendingAskUser != nil {
footer.WriteString(renderAskUserQuestionnaire(*m.pendingAskUser, m.input.Value(), width))
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
footer.WriteString(m.footerStatusLine(width))
return footer.String()
}
// A focused permission prompt owns the keyboard: its options (and the feedback
@@ -3059,7 +3212,7 @@ func (m model) footerView(width int) string {
// input from echoing in two places once "tell Zero what to do differently"
// opens the on-card feedback field.
if m.pendingPermission != nil {
footer.WriteString(m.statusLine(width))
footer.WriteString(m.footerStatusLine(width))
return footer.String()
}
// Pinned plan panel: sits directly above the composer so it stays visible
@@ -3100,7 +3253,7 @@ func (m model) footerView(width int) string {
footer.WriteString(hint)
}
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
footer.WriteString(m.footerStatusLine(width))
return footer.String()
}
@@ -4124,23 +4277,26 @@ func (m model) composerBox(width int) string {
if width < 8 {
return fitStyledLine(m.composerLine(width), width)
}
innerWidth := maxInt(1, width-4)
reserved := m.petComposerReservedColumns(width)
boxWidth := width - reserved
innerWidth := maxInt(1, boxWidth-4)
content := m.composerLine(innerWidth)
lines := strings.Split(content, "\n")
rightPad := strings.Repeat(" ", reserved)
rendered := make([]string, 0, len(lines)+3)
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", width-2)+"╮"))
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", boxWidth-2)+"╮")+rightPad)
// Attachment chips ([Image #1] …) render INSIDE the box, above the input line,
// instead of as a separate row above the box.
if chips := renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments); chips != "" {
fitted := fitStyledLine(zeroTheme.muted.Render(chips), innerWidth)
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
}
for _, line := range lines {
fitted := fitStyledLine(line, innerWidth)
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
}
rendered = append(rendered, m.composerDividerLine(width))
return strings.Join(rendered, "\n")
@@ -4347,6 +4503,8 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
}
case pickerSTTDownload:
return m.handleSTTDownloadSelection(item.Value)
case pickerPet:
return m.installPet(item.Value)
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
@@ -4503,6 +4661,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
return m.handleLoopCommand(command.text)
case commandGoal:
return m.handleGoalCommand(command.text)
case commandPets:
return m.handlePetsCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
@@ -4681,6 +4841,11 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
m, text = m.handleEffortCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandFast:
text := ""
m, text = m.handleFastCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandStyle:
text := ""
m, text = m.handleStyleCommand(command.text)
@@ -5249,6 +5414,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
options.ProviderName = m.providerName
options.Model = m.modelName
options.ReasoningEffort = string(m.reasoningEffort)
options.ServiceTier = m.activeServiceTier()
options.ResponseStyle = m.responseStyle
options.Cwd = m.cwd
options.Images = images
+1
View File
@@ -198,6 +198,7 @@ func TestParseCommand(t *testing.T) {
{input: "/plan off", kind: commandPlan, text: "off"},
{input: "/compact", kind: commandCompact},
{input: "/effort high", kind: commandEffort, text: "high"},
{input: "/fast", kind: commandFast},
{input: "/style concise", kind: commandStyle, text: "concise"},
{input: "/debug-mode", kind: commandDebug},
{input: "hello zero", kind: commandPrompt, text: "hello zero"},
+8 -4
View File
@@ -151,6 +151,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if mouseRightPress(msg) {
return m, pasteFromClipboardCmd()
}
if next, cmd, handled := m.handlePetMouse(msg); handled {
return next, cmd
}
if mouseLeftPress(msg) {
switch {
case m.providerWizard != nil:
@@ -187,6 +190,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m.choosePicker()
}
m.lastMouseSelection = target
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
return m, nil
}
case m.suggestionsActive():
@@ -233,8 +239,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
return m, nil
return m.pickerMoved(-1)
}
if m.suggestionsActive() {
m.moveSuggestion(-1)
@@ -265,8 +270,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
return m, nil
return m.pickerMoved(1)
}
if m.suggestionsActive() {
m.moveSuggestion(1)
+18 -4
View File
@@ -14,15 +14,29 @@ func mouseEventFilter() func(tea.Model, tea.Msg) tea.Msg {
func newMouseEventFilter(now func() time.Time, minInterval time.Duration) func(tea.Model, tea.Msg) tea.Msg {
var last time.Time
return func(_ tea.Model, msg tea.Msg) tea.Msg {
return func(current tea.Model, msg tea.Msg) tea.Msg {
switch msg.(type) {
case tea.MouseWheelMsg, tea.MouseMotionMsg:
current := now()
if !last.IsZero() && current.Sub(last) < minInterval {
if _, motion := msg.(tea.MouseMotionMsg); motion && petDragActive(current) {
return msg
}
timestamp := now()
if !last.IsZero() && timestamp.Sub(last) < minInterval {
return nil
}
last = current
last = timestamp
}
return msg
}
}
func petDragActive(current tea.Model) bool {
switch current := current.(type) {
case model:
return current.petDragActive
case *model:
return current != nil && current.petDragActive
default:
return false
}
}
+19
View File
@@ -51,3 +51,22 @@ func TestMouseEventFilterDoesNotThrottleKeyboard(t *testing.T) {
t.Fatal("keyboard events should not touch the mouse throttle clock")
}
}
func TestMouseEventFilterDoesNotThrottlePetDrag(t *testing.T) {
clockReads := 0
filter := newMouseEventFilter(func() time.Time {
clockReads++
return time.Unix(0, 0)
}, mouseEventThrottleInterval)
m := model{petDragActive: true}
if got := filter(m, tea.MouseMotionMsg(tea.Mouse{Button: tea.MouseLeft, X: 1, Y: 1})); got == nil {
t.Fatal("first drag event should pass through")
}
if got := filter(m, tea.MouseMotionMsg(tea.Mouse{Button: tea.MouseLeft, X: 2, Y: 1})); got == nil {
t.Fatal("every pet-drag event should pass through for zero-gap tracking")
}
if clockReads != 0 {
t.Fatalf("pet drag should bypass the throttle clock, read it %d times", clockReads)
}
}
+4 -1
View File
@@ -72,7 +72,10 @@ type Options struct {
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
SavedTheme string
UserAgent string
// SavedPet is the persisted terminal companion id. Empty means no pet has
// been selected yet; "disabled" records an explicit opt-out.
SavedPet string
UserAgent string
// Notify configures completion / awaiting-input notifications.
Notify config.NotifyConfig
+188
View File
@@ -0,0 +1,188 @@
package tui
import (
"bytes"
"io"
"sync"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/terminalpet"
)
const (
terminalSyncStart = "\x1b[?2026h"
terminalSyncEnd = "\x1b[?2026l"
)
type terminalOutputFile interface {
io.ReadWriteCloser
Fd() uintptr
}
type petImageOutput struct {
output terminalOutputFile
renderer *terminalpet.ImageRenderer
renderDisabled bool
mu sync.Mutex
}
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
return &petImageOutput{output: output, renderer: renderer}
}
func (o *petImageOutput) Read(value []byte) (int, error) {
return o.output.Read(value)
}
func (o *petImageOutput) Write(value []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
if leavingAltScreen && !kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return 0, err
}
}
if leavingAltScreen {
written, err := o.output.Write(value)
if err != nil {
return written, err
}
if kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return written, err
}
}
return written, nil
}
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
o.renderer.Invalidate()
}
var imageUpdate bytes.Buffer
if !o.renderDisabled {
if err := o.renderer.Render(&imageUpdate); err != nil {
// Companions are decorative. Disable them for this output session after
// a renderer failure instead of terminating the interactive shell.
o.renderDisabled = true
imageUpdate.Reset()
}
}
if imageUpdate.Len() == 0 {
return writeChecked(o.output, value)
}
// Bubble Tea encloses supported terminal frames in synchronized-output
// markers. Keep the pet placement inside that same transaction so the
// terminal never presents the text frame and image movement separately.
syncStart := bytes.LastIndex(value, []byte(terminalSyncStart))
syncEnd := bytes.LastIndex(value, []byte(terminalSyncEnd))
if syncEnd >= 0 && syncEnd > syncStart {
var frame bytes.Buffer
frame.Grow(len(value) + imageUpdate.Len())
frame.Write(value[:syncEnd])
frame.Write(imageUpdate.Bytes())
frame.Write(value[syncEnd:])
written, err := o.output.Write(frame.Bytes())
consumed := originalBytesWritten(written, syncEnd, imageUpdate.Len(), len(value))
if err == nil && written != frame.Len() {
err = io.ErrShortWrite
}
if err != nil {
return consumed, err
}
return len(value), nil
}
if syncStart > syncEnd {
written, writeErr := writeChecked(o.output, value)
if writeErr != nil {
return written, writeErr
}
_, imageErr := writeChecked(o.output, imageUpdate.Bytes())
return written, imageErr
}
if _, err := io.WriteString(o.output, terminalSyncStart); err != nil {
return 0, err
}
written, writeErr := writeChecked(o.output, value)
if writeErr == nil {
_, writeErr = writeChecked(o.output, imageUpdate.Bytes())
}
_, endErr := io.WriteString(o.output, terminalSyncEnd)
if writeErr != nil {
return written, writeErr
}
return written, endErr
}
func writeChecked(writer io.Writer, value []byte) (int, error) {
written, err := writer.Write(value)
if err == nil && written != len(value) {
err = io.ErrShortWrite
}
return written, err
}
// originalBytesWritten maps progress through an expanded synchronized frame
// back to bytes consumed from the caller's original buffer. Bytes belonging
// to the injected image update must never be reported as caller bytes.
func originalBytesWritten(written, prefixLength, injectedLength, originalLength int) int {
if written <= 0 {
return 0
}
if written <= prefixLength {
return written
}
consumed := prefixLength
suffixWritten := written - prefixLength - injectedLength
if suffixWritten > 0 {
consumed += min(suffixWritten, originalLength-prefixLength)
}
return min(consumed, originalLength)
}
func (o *petImageOutput) writeImageUpdate(render func(io.Writer) error) error {
var update bytes.Buffer
if err := render(&update); err != nil {
return err
}
if update.Len() == 0 {
return nil
}
if _, err := io.WriteString(o.output, terminalSyncStart); err != nil {
return err
}
_, writeErr := o.output.Write(update.Bytes())
_, endErr := io.WriteString(o.output, terminalSyncEnd)
if writeErr != nil {
return writeErr
}
return endErr
}
func (o *petImageOutput) Close() error {
return nil
}
func (o *petImageOutput) Fd() uintptr {
return o.output.Fd()
}
func (o *petImageOutput) clearImage() error {
o.mu.Lock()
defer o.mu.Unlock()
return o.writeImageUpdate(func(writer io.Writer) error {
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
return err
}
if err := o.renderer.Clear(writer); err != nil {
return err
}
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
})
}
+492
View File
@@ -0,0 +1,492 @@
package tui
import (
"errors"
"fmt"
"image"
"image/color"
"io"
"os"
"strings"
"sync"
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/charmbracelet/x/ansi"
)
type shortTerminalOutput struct {
limit int
err error
}
func (o *shortTerminalOutput) Read([]byte) (int, error) { return 0, io.EOF }
func (o *shortTerminalOutput) Close() error { return nil }
func (o *shortTerminalOutput) Fd() uintptr { return 0 }
func (o *shortTerminalOutput) Write(value []byte) (int, error) {
return min(o.limit, len(value)), o.err
}
func TestPetImageOutputAppendsScheduledImageAfterFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
frame.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte("bubbletea-frame")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if !strings.HasPrefix(got, terminalSyncStart+"bubbletea-frame") || !strings.Contains(got, "_Ga=T,t=d,f=100,c=4,r=3") {
t.Fatalf("output did not append image after frame: %q", got)
}
syncStart := strings.Index(got, "\x1b[?2026h")
frameAt := strings.Index(got, "bubbletea-frame")
imageAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
syncEnd := strings.LastIndex(got, "\x1b[?2026l")
if syncStart < 0 || frameAt < syncStart || imageAt < frameAt || syncEnd < imageAt {
t.Fatalf("frame and image update were not synchronized together: %q", got)
}
}
func TestPetImageOutputKeepsImageInsideBubbleTeaSynchronizedFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
if _, err := output.Write([]byte(bubbleTeaFrame)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if count := strings.Count(got, terminalSyncStart); count != 1 {
t.Fatalf("got %d synchronized-output starts, want 1: %q", count, got)
}
if count := strings.Count(got, terminalSyncEnd); count != 1 {
t.Fatalf("got %d synchronized-output ends, want 1: %q", count, got)
}
frameAt := strings.Index(got, "streamed-text-frame")
imageAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
syncEnd := strings.Index(got, terminalSyncEnd)
if frameAt < 0 || imageAt < frameAt || syncEnd < imageAt {
t.Fatalf("pet update was presented outside the Bubble Tea frame: %q", got)
}
}
func TestPetImageOutputMapsPartialSynchronizedWriteToOriginalBytes(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
prefixLength := strings.Index(bubbleTeaFrame, terminalSyncEnd)
wantErr := errors.New("partial terminal write")
sink := &shortTerminalOutput{limit: prefixLength + 3, err: wantErr}
written, err := newPetImageOutput(sink, renderer).Write([]byte(bubbleTeaFrame))
if !errors.Is(err, wantErr) {
t.Fatalf("Write error = %v, want %v", err, wantErr)
}
if written != prefixLength {
t.Fatalf("Write reported %d original bytes, want %d", written, prefixLength)
}
}
func TestPetImageOutputReportsNilErrorShortWrite(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
prefixLength := strings.Index(bubbleTeaFrame, terminalSyncEnd)
sink := &shortTerminalOutput{limit: prefixLength + 3}
written, err := newPetImageOutput(sink, renderer).Write([]byte(bubbleTeaFrame))
if !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("Write error = %v, want %v", err, io.ErrShortWrite)
}
if written != prefixLength {
t.Fatalf("Write reported %d original bytes, want %d", written, prefixLength)
}
}
func TestPetImageOutputKeepsDraggedPlacementInsideStreamingFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
draw := terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3}
renderer.Set(&draw)
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
start, err := file.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatal(err)
}
draw.X = 8
draw.Y = 6
renderer.Set(&draw)
if _, err := output.Write([]byte(terminalSyncStart + "streamed-delta" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data[start:])
if count := strings.Count(got, terminalSyncStart); count != 1 {
t.Fatalf("got %d synchronized-output starts, want 1: %q", count, got)
}
if count := strings.Count(got, terminalSyncEnd); count != 1 {
t.Fatalf("got %d synchronized-output ends, want 1: %q", count, got)
}
textAt := strings.Index(got, "streamed-delta")
moveAt := strings.Index(got, "\x1b[7;9H\x1b_Ga=p")
syncEnd := strings.Index(got, terminalSyncEnd)
if textAt < 0 || moveAt < textAt || syncEnd < moveAt {
t.Fatalf("dragged pet placement was presented outside the streaming frame: %q", got)
}
if strings.Contains(got, "_Ga=T") {
t.Fatalf("dragging retransmitted image data instead of moving its placement: %q", got)
}
}
func TestPetImageOutputFlushesMovedPlacementWithoutTextChange(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
draw := terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3}
renderer.Set(&draw)
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
start, err := file.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatal(err)
}
draw.X = 8
draw.Y = 6
renderer.Set(&draw)
if _, err := output.Write([]byte(terminalSyncStart + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data[start:])
moveAt := strings.Index(got, "\x1b[7;9H\x1b_Ga=p")
syncEnd := strings.Index(got, terminalSyncEnd)
if moveAt < 0 || syncEnd < moveAt {
t.Fatalf("idle drag flush did not place the pet inside its empty frame: %q", got)
}
if strings.Contains(got, "_Ga=T") {
t.Fatalf("idle drag flush retransmitted image data: %q", got)
}
}
func TestPetImageOutputRetransmitsAfterFullScreenClear(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
start, err := file.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatal(err)
}
clearFrame := terminalSyncStart + ansi.EraseEntireScreen + "resized-frame" + terminalSyncEnd
if _, err := output.Write([]byte(clearFrame)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data[start:])
clearAt := strings.Index(got, ansi.EraseEntireScreen)
transmitAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
syncEnd := strings.Index(got, terminalSyncEnd)
if clearAt < 0 || transmitAt < clearAt || syncEnd < transmitAt {
t.Fatalf("pet was not restored inside the full-screen redraw: %q", got)
}
}
func TestPetImageOutputClearsKittyImageAfterLeavingAltScreen(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte("frame")); err != nil {
t.Fatal(err)
}
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
deleteAt := strings.LastIndex(got, "_Ga=d,d=I,i=7,q=2")
exitAt := strings.LastIndex(got, "\x1b[?1049l")
if deleteAt < 0 || exitAt < 0 || deleteAt < exitAt {
t.Fatalf("Kitty image was not cleared after alt-screen exit: %q", got)
}
syncStart := strings.LastIndex(got[:deleteAt], "\x1b[?2026h")
syncEnd := strings.Index(got[deleteAt:], "\x1b[?2026l")
if syncStart < exitAt || syncEnd < 0 {
t.Fatalf("Kitty image cleanup was not synchronized after alt-screen exit: %q", got)
}
}
func TestPetImageOutputFinalCleanupRepeatsAllKittyDeletes(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte("frame")); err != nil {
t.Fatal(err)
}
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
t.Fatal(err)
}
cleanupStart, err := file.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatal(err)
}
if err := output.clearImage(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
cleanup := string(data[cleanupStart:])
if !strings.Contains(cleanup, ansi.ResetModeMouseExtSgrPixel) {
t.Fatalf("final cleanup did not reset pixel mouse mode: %q", cleanup)
}
for _, id := range []uint32{petAmbientImageID, petPreviewImageID} {
want := fmt.Sprintf("_Ga=d,d=I,i=%d,q=2", id)
if !strings.Contains(cleanup, want) {
t.Errorf("final cleanup did not repeat Kitty delete for image %d: %q", id, cleanup)
}
}
}
func TestPetImageOutputClearsSixelBeforeLeavingAltScreen(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3, HeightPixels: 12})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte("frame")); err != nil {
t.Fatal(err)
}
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
clearAt := strings.LastIndex(got, "\x1b[4;3H ")
exitAt := strings.LastIndex(got, "\x1b[?1049l")
if clearAt < 0 || exitAt < 0 || clearAt > exitAt {
t.Fatalf("Sixel cell area was not cleared before alt-screen exit: %q", got)
}
}
func TestPetImageOutputDoesNotCloseAnExistingSynchronizedFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
if _, err := newPetImageOutput(file, renderer).Write([]byte(terminalSyncStart + "partial-frame")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, terminalSyncStart) != 1 || strings.Contains(got, terminalSyncEnd) {
t.Fatalf("pet output changed caller-owned synchronized frame boundaries: %q", got)
}
if strings.Index(got, "_Ga=T") < strings.Index(got, "partial-frame") {
t.Fatalf("pet image was not appended to the open frame: %q", got)
}
}
func TestPetImageOutputReportsUnsynchronizedShortWrite(t *testing.T) {
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
sink := &shortTerminalOutput{limit: len(terminalSyncStart)}
written, err := newPetImageOutput(sink, renderer).Write([]byte("long-frame-payload"))
if !errors.Is(err, io.ErrShortWrite) || written != len(terminalSyncStart) {
t.Fatalf("Write() = %d, %v; want %d, %v", written, err, len(terminalSyncStart), io.ErrShortWrite)
}
}
func TestPetImageOutputKeepsTextAliveAfterRendererFailure(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKittyLocalFile})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
for _, frame := range []string{"first-frame", "second-frame"} {
if written, err := output.Write([]byte(frame)); err != nil || written != len(frame) {
t.Fatalf("Write(%q) = %d, %v", frame, written, err)
}
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
if got := string(data); got != "first-framesecond-frame" {
t.Fatalf("text output after renderer failure = %q", got)
}
}
func TestPetImageOutputSerializesWriteAndCleanup(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
var wait sync.WaitGroup
errorsSeen := make(chan error, 9)
for range 8 {
wait.Add(1)
go func() {
defer wait.Done()
_, err := output.Write([]byte("frame"))
errorsSeen <- err
}()
}
wait.Add(1)
go func() {
defer wait.Done()
errorsSeen <- output.clearImage()
}()
wait.Wait()
close(errorsSeen)
for err := range errorsSeen {
if err != nil {
t.Fatal(err)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package tui
import (
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
)
// The erase rectangle has to describe what was PAINTED, not the reserved area.
//
// Measured on Windows Terminal 1.24, which answers CSI 16 t with a 20x10 cell.
// A companion is rendered at preferredHeight (75px), so it covers
// ceil(75/20) = 4 rows, while petImageRows is 5. The erase used the constant and
// blanked a fifth row of live interface every time the companion moved, which is
// the broken border and the eaten characters reported on Windows.
//
// Sixel is the only protocol that erases by writing over cells, so it is the
// only one that needs this; the Kitty case below pins that it is left alone.
func TestSixelEraseFootprintMatchesWhatWasPainted(t *testing.T) {
m := model{
petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel}),
petCellPixelWidth: 10,
petCellPixelHeight: 20,
}
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
if rows != 4 {
t.Errorf("rows = %d, want 4 for a 75px sprite in a 20px cell; erasing %d would blank live interface", rows, petImageRows)
}
if rows > petImageRows || columns > petImageColumns {
t.Errorf("footprint %dx%d exceeds the reserved %dx%d area", columns, rows, petImageColumns, petImageRows)
}
}
// Kitty passes Columns/Rows to the terminal as the placement REQUEST and the
// terminal owns the region, so the constants are correct there and must not be
// recomputed.
func TestKittyKeepsTheRequestedPlacementSize(t *testing.T) {
m := model{
petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
petCellPixelWidth: 10,
petCellPixelHeight: 20,
}
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
if columns != petImageColumns || rows != petImageRows {
t.Errorf("Kitty placement = %dx%d, want the requested %dx%d", columns, rows, petImageColumns, petImageRows)
}
}
// Without metrics there is nothing to compute from, so it must fall back to the
// constants rather than to zero, which would erase nothing and leave the
// companion smeared across the screen.
func TestFootprintFallsBackToTheReservedAreaWithoutMetrics(t *testing.T) {
m := model{petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel})}
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
if columns != petImageColumns || rows != petImageRows {
t.Errorf("fallback = %dx%d, want the reserved %dx%d", columns, rows, petImageColumns, petImageRows)
}
}
// The request has to be sent for a sixel terminal, or the metrics never arrive
// and every computation above silently uses the fallback. This is the actual
// defect: the reply was never asked for, not unsupported.
func TestCellMetricsAreRequestedForEveryImageProtocol(t *testing.T) {
for _, protocol := range []terminalpet.ImageProtocol{
terminalpet.ImageProtocolSixel,
terminalpet.ImageProtocolKitty,
terminalpet.ImageProtocolKittyLocalFile,
} {
m := model{petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: protocol})}
if !m.petCellMetricsWanted() {
t.Errorf("protocol %v does not ask the terminal for its cell size, so the reply never arrives", protocol)
}
}
}
+1065
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+19 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
)
// pickerKind identifies which command a picker selection feeds back into.
@@ -27,6 +28,7 @@ const (
pickerSkill
pickerSTTModel
pickerSTTDownload
pickerPet
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -406,8 +408,9 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
needOAuth := key == "" && descriptor.OAuth && !descriptor.OAuthMintsKey
discover := m.discoverProviderModels
if discover == nil {
discoveryOptions := m.modelPickerDiscoveryOptions(authed)
discover = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, providermodeldiscovery.Options{})
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, discoveryOptions)
}
}
providerID := descriptor.ID
@@ -425,6 +428,15 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
}
}
func (m model) modelPickerDiscoveryOptions(profile config.ProviderProfile) providermodeldiscovery.Options {
oauthResolver, loginKey := providers.OAuthLoginForProfile(profile)
return providermodeldiscovery.Options{
OAuthResolver: oauthResolver,
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
UserAgent: m.userAgent,
}
}
func (m model) modelPickerIsLoading() bool {
return m.picker != nil && m.picker.kind == pickerModel && m.modelPickerLoading
}
@@ -1029,10 +1041,14 @@ func (m model) newThemePicker() *commandPicker {
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
func (m model) pickerMoved(delta int) {
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
if m.picker == nil {
return
return m, nil
}
m.picker.move(delta)
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
return m, nil
}
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more