Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 524a738eb0 docs(agents): record the normalize-before-match blocker
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-13 10:40:51 +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
86 changed files with 11273 additions and 309 deletions

No files matched your search

+8
View File
@@ -91,6 +91,14 @@ These classes drive multi-round reviews. Fix them before requesting review:
final-component-only no-follow is insufficient. On multi-step setup, roll back
only what this run created; never destroy pre-existing resources you did
not create; never report success when cleanup or unlock failed.
- **Normalize before matching:** Any transform that REMOVES bytes without
leaving a gap is also a reassembler, so it has to run before whatever matches
on the result. Redacting by shape and then stripping control bytes lets a
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
fragments and be rejoined on the way out; comparing a path against a form
produced by the same resolver the kernel just used makes a redirect agree with
itself. Strip, decode, or canonicalize first, then match. The unsplit value
passing is not evidence, so the test needs a split case.
- **Atomic shared state:** Write a complete temporary file, then atomically
replace the destination so concurrent readers never see a partial write.
Exclusive create or a write lock alone is not enough for readers. Serialize
+40
View File
@@ -70,3 +70,43 @@ Endpoint resolution order:
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
instead of reinstalling.
## Windows recovery state (standalone installs)
This section describes Windows only. On Linux and macOS a standalone update
renames the staged file directly over the executable path through the
installation directory's file descriptor, so the replacement is atomic and no
aside copy, marker, or recovery record is ever created — a failed update leaves
the previous binary in place and nothing to resolve.
On Windows, a running executable cannot be replaced in place, so the previous
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
records that exact file — bound to its filesystem identity, in per-user state
outside the installation directory — and deletes only that recorded copy after
the next update is verified in place. Backups it did not create are never
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
recorded copy that something else holds open (an on-access scanner, an editor)
stays recorded and is removed by a later update instead.
An update **refuses to run** while unresolved recovery state exists beside the
binary:
| State on disk | Meaning |
|---|---|
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
The refusal names the paths involved and the two moves that end the state:
either move the recovery binary back over the executable path, or — if the
installed binary is the one you want — delete the `.keep` marker (or the
`.recovery` copy, once you have verified the installed binary) and update again.
This is deliberately fail-closed and differs from older releases, which deleted
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
and make every subsequent update refuse until an operator clears them. That is
the safe direction: the alternative is an update that overwrites the only
verified copy of the previous binary. If updates start refusing, inspect those
files before removing them — an installation directory that a lower-privileged
account can write to is itself worth fixing.
+2 -1
View File
@@ -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.44.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
+2
View File
@@ -64,6 +64,8 @@ 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/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.
+1
View File
@@ -842,6 +842,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,
+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{
+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")
+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
+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 -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)
}
}
}
+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
+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:",
+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
}
+71
View File
@@ -10,6 +10,7 @@ import (
"reflect"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
@@ -52,6 +53,40 @@ func TestModelPickerDetectsOllamaCloudFromBaseURL(t *testing.T) {
}
}
func TestModelPickerChatGPTDiscoveryUsesMatchingOAuthAccount(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)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{
AccessToken: "live-token",
Account: "workspace-77",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("save OAuth token: %v", err)
}
m := model{userAgent: "zero/test"}
options := m.modelPickerDiscoveryOptions(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"})
if options.OAuthResolver == nil || options.CodexAccountResolver == nil {
t.Fatal("ChatGPT model discovery must carry both bearer and account resolvers")
}
header, bearer, ok, err := options.OAuthResolver(context.Background(), false)
if err != nil || !ok || header != "Authorization" || bearer != "Bearer live-token" {
t.Fatalf("discovery bearer = (%q, %q, %v, %v)", header, bearer, ok, err)
}
account, ok, err := options.CodexAccountResolver(context.Background())
if err != nil || !ok || account != "workspace-77" {
t.Fatalf("discovery account = (%q, %v, %v)", account, ok, err)
}
if options.UserAgent != "zero/test" {
t.Fatalf("discovery user agent = %q", options.UserAgent)
}
}
func TestModelPickerRefreshesLiveModelsForActiveProvider(t *testing.T) {
var captured config.ProviderProfile
m := newModel(context.Background(), Options{
@@ -1109,6 +1144,42 @@ func TestEffortPickerOpensForModelWithoutEffortControls(t *testing.T) {
}
}
func TestEffortPickerPrefersLiveProviderReasoningLevels(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "future-reasoner"})
m.providerProfile = config.ProviderProfile{CatalogID: "test-provider"}
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
"test-provider": {{
ID: "future-reasoner",
Reasoning: true,
ReasoningEfforts: []string{"low", "high", "xhigh", "max", "ultra", "unknown", "high"},
}},
}
got := m.availableReasoningEfforts()
want := []modelregistry.ReasoningEffort{
modelregistry.ReasoningEffortLow,
modelregistry.ReasoningEffortHigh,
modelregistry.ReasoningEffortXHigh,
modelregistry.ReasoningEffortMax,
modelregistry.ReasoningEffortUltra,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("reasoning efforts = %#v, want %#v", got, want)
}
}
func TestEffortPickerDoesNotFallbackWhenLiveModelAdvertisesNoSelectableEfforts(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-5.5"})
m.providerProfile = config.ProviderProfile{CatalogID: "test-provider"}
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
"test-provider": {{ID: "gpt-5.5", ReasoningEfforts: []string{"none"}}},
}
if got := m.availableReasoningEfforts(); len(got) != 0 {
t.Fatalf("live model with only none exposed efforts %#v, want none", got)
}
}
func TestEffortPickerAutoSelectionKeepsEffortUnset(t *testing.T) {
// Picking "auto" on a model without effort controls clears any stale
// preference and emits the success status text (handleEffortCommand("auto")).
+38 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
tea "charm.land/bubbletea/v2"
@@ -11,6 +13,7 @@ import (
"github.com/charmbracelet/x/term"
"github.com/Gitlawb/zero/internal/peermsg"
"github.com/Gitlawb/zero/internal/terminalpet"
)
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
@@ -41,11 +44,13 @@ func Run(ctx context.Context, options Options) int {
// text first, keeping order intact.
options.RuntimeMessageSink = newTextCoalescer(forward).send
options.AltScreen = useAltScreen(options)
petRenderer := terminalpet.NewImageRendererWithCache(terminalpet.DetectImageSupport(os.Getenv), terminalPetFrameCache(options))
petOutput := newPetImageOutput(os.Stdout, petRenderer)
programOpts := []tea.ProgramOption{
tea.WithContext(ctx),
tea.WithInput(os.Stdin),
tea.WithOutput(os.Stdout),
tea.WithOutput(petOutput),
tea.WithFilter(mouseEventFilter()),
}
// Honor the no-color.org spec ourselves: NO_COLOR set to ANY non-empty value
@@ -56,6 +61,7 @@ func Run(ctx context.Context, options Options) int {
programOpts = append(programOpts, tea.WithColorProfile(colorprofile.Ascii))
}
initialModel := newModel(ctx, options)
initialModel.petRenderer = petRenderer
if initialModel.wantsMouseCapture() {
initialModel.mouseCapture = true
}
@@ -88,6 +94,7 @@ func Run(ctx context.Context, options Options) int {
}
_, runErr := program.Run()
clearErr := petOutput.clearImage()
var closeErr error
if peerStarted {
closeErr = options.PeerService.Close()
@@ -102,9 +109,39 @@ func Run(ctx context.Context, options Options) int {
fmt.Fprintln(os.Stderr, "zero: peer messaging cleanup error:", closeErr)
return 1
}
if clearErr != nil {
fmt.Fprintln(os.Stderr, "zero: terminal companion cleanup error:", clearErr)
}
return 0
}
func terminalPetFrameCache(options Options) string {
return terminalPetFrameCacheWith(options, os.UserConfigDir, os.UserCacheDir)
}
func terminalPetFrameCacheWith(options Options, userConfigDir, userCacheDir func() (string, error)) string {
root := ""
if configPath := strings.TrimSpace(options.UserConfigPath); filepath.IsAbs(configPath) {
root = filepath.Dir(filepath.Clean(configPath))
}
if root == "" {
configDir, err := userConfigDir()
if err == nil && strings.TrimSpace(configDir) != "" {
root = filepath.Join(configDir, "zero")
}
}
if root == "" {
cacheDir, err := userCacheDir()
if err == nil && strings.TrimSpace(cacheDir) != "" {
root = filepath.Join(cacheDir, "zero")
}
}
if root == "" {
return ""
}
return filepath.Join(root, "pets", "frame-cache")
}
func useAltScreen(_ Options) bool {
return true
}
+56
View File
@@ -2,7 +2,9 @@ package tui
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
@@ -16,6 +18,60 @@ func TestUseAltScreenForInteractiveChat(t *testing.T) {
}
}
func TestTerminalPetFrameCache(t *testing.T) {
configRoot := filepath.Join(t.TempDir(), "config-root")
cacheRoot := filepath.Join(t.TempDir(), "cache-root")
absConfig := filepath.Join(t.TempDir(), "custom", "config.json")
configDir := func() (string, error) { return configRoot, nil }
cacheDir := func() (string, error) { return cacheRoot, nil }
tests := []struct {
name string
options Options
want string
}{
{name: "absolute config", options: Options{UserConfigPath: absConfig}, want: filepath.Join(filepath.Dir(absConfig), "pets", "frame-cache")},
{name: "relative config falls back", options: Options{UserConfigPath: "config.json"}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
{name: "whitespace config falls back", options: Options{UserConfigPath: " "}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
{name: "empty config falls back", options: Options{}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := terminalPetFrameCacheWith(test.options, configDir, cacheDir)
if canonicalTestPath(t, got) != canonicalTestPath(t, test.want) {
t.Fatalf("terminalPetFrameCacheWith() = %q, want %q", got, test.want)
}
})
}
unavailable := func() (string, error) { return "", errors.New("unavailable") }
blank := func() (string, error) { return "", nil }
if got, want := terminalPetFrameCacheWith(Options{}, unavailable, cacheDir), filepath.Join(cacheRoot, "zero", "pets", "frame-cache"); canonicalTestPath(t, got) != canonicalTestPath(t, want) {
t.Fatalf("cache fallback = %q, want %q", got, want)
}
if got, want := terminalPetFrameCacheWith(Options{}, blank, cacheDir), filepath.Join(cacheRoot, "zero", "pets", "frame-cache"); canonicalTestPath(t, got) != canonicalTestPath(t, want) {
t.Fatalf("blank config root fallback = %q, want %q", got, want)
}
if got := terminalPetFrameCacheWith(Options{}, unavailable, unavailable); got != "" {
t.Fatalf("unavailable roots returned %q, want empty", got)
}
if got := terminalPetFrameCacheWith(Options{}, unavailable, blank); got != "" {
t.Fatalf("blank cache root returned %q, want empty", got)
}
}
func canonicalTestPath(t *testing.T, value string) string {
t.Helper()
abs, err := filepath.Abs(value)
if err != nil {
t.Fatal(err)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
return filepath.Clean(abs)
}
// TestRunRejectsNonTTYStdin pins that the interactive shell fails fast with a
// non-zero code when stdin is not a terminal, instead of blocking forever in the
// Bubble Tea event loop (e.g. `echo "" | zero`). The guard runs before any model
+119
View File
@@ -13,6 +13,8 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -84,6 +86,96 @@ func (m model) handleEffortCommand(args string) (model, string) {
return m, m.effortStatusCard(string(requested), "Reasoning effort preference is stored for this TUI session.")
}
func (m model) handleFastCommand(args string) (model, string) {
if strings.TrimSpace(args) != "" {
return m, m.fastStatusText("Use /fast to toggle fast mode for supported ChatGPT subscription models.")
}
if m.pending {
return m, m.fastStatusText("Finish or stop the current run before changing fast mode.")
}
if !m.fastModeSupported() {
m.serviceTier = ""
return m, m.fastStatusText("Fast mode is available only for ChatGPT subscription models that advertise it.")
}
if m.activeServiceTier() == "priority" {
m.serviceTier = ""
} else {
m.serviceTier = "priority"
}
return m, m.fastStatusText("Fast mode applies to the next request for this ChatGPT subscription model.")
}
func (m model) fastStatusText(detail string) string {
state := "off"
if m.activeServiceTier() == "priority" {
state = "on"
}
fields := []commandField{
{Key: "fast mode", Value: state},
{Key: "model", Value: displayValue(m.modelName, "none")},
}
if detail == "" {
detail = "Use /fast to toggle fast mode for supported ChatGPT subscription models."
}
return renderCommandCardTranscript(commandCard{
Title: "Fast",
Summary: []string{"fast mode: " + state},
Sections: []commandCardSection{{
Title: "State",
Fields: fields,
Lines: []string{detail},
}},
})
}
func (m model) activeServiceTier() string {
if m.serviceTier == "priority" && m.fastModeSupported() {
return "priority"
}
return ""
}
func (m model) fastModeSupported() bool {
// Priority is a ChatGPT subscription capability. Never forward it for
// metered provider profiles, even if a third-party catalog advertises it.
if providercatalog.NormalizeID(m.providerProfile.CatalogID) != "chatgpt" {
return false
}
for _, model := range m.discoveredModelsForActiveProvider() {
if !strings.EqualFold(strings.TrimSpace(model.ID), strings.TrimSpace(m.modelName)) {
continue
}
for _, tier := range model.ServiceTiers {
if normalizeTUIServiceTier(tier) == "priority" {
return true
}
}
return false
}
return false
}
func (m model) discoveredModelsForActiveProvider() []providermodeldiscovery.Model {
for _, key := range []string{m.providerProfile.CatalogID, m.providerProfile.Name, m.providerName} {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if models := m.modelPickerLiveByProvider[key]; len(models) > 0 {
return models
}
}
return nil
}
func normalizeTUIServiceTier(tier string) string {
tier = strings.ToLower(strings.TrimSpace(tier))
if tier == "fast" {
return "priority"
}
return tier
}
// effortStatusCard renders the small inline confirmation card shown after a
// /effort <value> mutation (set, auto, unknown, unsupported). The body is a
// lime-bordered card so the transition from "picker open" -> "card collapsed"
@@ -236,6 +328,9 @@ func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort {
if strings.TrimSpace(m.modelName) == "" {
return nil
}
if efforts, found := m.discoveredReasoningEfforts(); found {
return efforts
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return nil
@@ -243,6 +338,30 @@ func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort {
return registry.ReasoningEfforts(m.modelName)
}
func (m model) discoveredReasoningEfforts() ([]modelregistry.ReasoningEffort, bool) {
return reasoningEffortsFromModels(m.discoveredModelsForActiveProvider(), m.modelName)
}
func reasoningEffortsFromModels(models []providermodeldiscovery.Model, modelName string) ([]modelregistry.ReasoningEffort, bool) {
for _, discovered := range models {
if !strings.EqualFold(strings.TrimSpace(discovered.ID), strings.TrimSpace(modelName)) {
continue
}
seen := map[modelregistry.ReasoningEffort]bool{}
efforts := make([]modelregistry.ReasoningEffort, 0, len(discovered.ReasoningEfforts))
for _, raw := range discovered.ReasoningEfforts {
effort := modelregistry.ReasoningEffort(strings.ToLower(strings.TrimSpace(raw)))
if effort == modelregistry.ReasoningEffortNone || !modelregistry.ValidReasoningEffort(effort) || seen[effort] {
continue
}
seen[effort] = true
efforts = append(efforts, effort)
}
return efforts, true
}
return nil, false
}
func (m model) effortDisplay() string {
if m.reasoningEffort == "" {
return "auto"
+35
View File
@@ -627,6 +627,41 @@ func TestTwoColumnTranscriptViewWidth(t *testing.T) {
}
}
func TestTwoColumnSidebarRemainsFullHeightBesideFooter(t *testing.T) {
m := sidebarTestModel()
m.width, m.height = 120, 34
m.unpricedTokens = 10000
out := plainRender(t, m.twoColumnTranscriptView())
lines := strings.Split(out, "\n")
tokenRow := -1
composerTop := -1
for index, line := range lines {
if strings.Contains(line, "10K tokens") {
tokenRow = index
}
if strings.HasPrefix(line, "╭") {
composerTop = index
}
}
if tokenRow != len(lines)-1 {
t.Fatalf("sidebar token summary should remain pinned to the bottom, row=%d last=%d\n%s", tokenRow, len(lines)-1, out)
}
if composerTop < 0 {
t.Fatalf("chat composer missing:\n%s", out)
}
composerRunes := []rune(lines[composerTop])
if len(composerRunes) != m.width || composerRunes[m.chatColumnWidth()-1] != '╮' {
t.Fatalf("composer should retain the chat-column width, got %q", lines[composerTop])
}
for index, line := range lines {
runes := []rune(line)
if len(runes) != m.width || runes[m.chatColumnWidth()+1] != '│' {
t.Fatalf("sidebar divider ended early on row %d: %q", index, line)
}
}
}
// stripSidebar joins sidebar lines and strips ANSI for content assertions.
func stripSidebar(lines []string) string {
return ansiPattern.ReplaceAllString(strings.Join(lines, "\n"), "")
+15 -5
View File
@@ -172,18 +172,22 @@ func (m model) composerDividerLine(width int) string {
// footer for run-state), so they're not duplicated on this rule.
meta := zeroTheme.muted.Render(model)
metaWidth := lipgloss.Width(meta)
reserved := m.petComposerReservedColumns(width)
availableWidth := width - reserved
if width < 8 {
return zeroTheme.lineStrong.Render(strings.Repeat("─", width))
}
if width < metaWidth+4 {
return zeroTheme.lineStrong.Render("╰" + strings.Repeat("─", width-2) + "╯")
if availableWidth < metaWidth+4 {
line := zeroTheme.lineStrong.Render("╰" + strings.Repeat("─", availableWidth-2) + "╯")
return line + strings.Repeat(" ", reserved)
}
rule := strings.Repeat("─", width-metaWidth-4)
return zeroTheme.lineStrong.Render("╰"+rule+" ") + meta + zeroTheme.lineStrong.Render(" ╯")
rule := strings.Repeat("─", availableWidth-metaWidth-4)
line := zeroTheme.lineStrong.Render("╰"+rule+" ") + meta + zeroTheme.lineStrong.Render(" ╯")
return line + strings.Repeat(" ", reserved)
}
// statusLine renders the bottom readout as ` │ `-separated groups: the run-state
// chip (permission mode + effort) on the left, a flexible gap, then the
// chip (permission mode + effort/fast tier) on the left, a flexible gap, then the
// context-fill gauge and token/cost usage on the right. The provider lives in the
// title bar and is NOT duplicated here. Groups drop with the width tier.
func (m model) statusLine(width int) string {
@@ -221,6 +225,9 @@ func (m model) statusLine(width int) string {
if m.reasoningEffort != "" {
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort))
}
if m.activeServiceTier() == "priority" {
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render("fast")
}
if m.exitConfirmActive {
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
} else if m.cancelConfirmActive {
@@ -757,6 +764,9 @@ func (m model) pickerOverlay(width int) string {
if m.picker.kind == pickerModel {
return m.modelPickerOverlay(width)
}
if m.picker.kind == pickerPet {
return m.petPickerOverlay(width)
}
overlayWidth := minInt(width, pickerOverlayMaxWidth)
if overlayWidth < pickerOverlayMinWidth {
overlayWidth = width
+123 -31
View File
@@ -2,6 +2,7 @@ package update
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -15,6 +16,15 @@ import (
"github.com/Gitlawb/zero/internal/release"
)
// ErrTargetPossiblyTampered reports that an executable path may hold content
// this updater could not verify, because a promotion failed and the original
// could not be restored over it. Only the Windows promotion path produces it
// today (see replace_windows.go for why that platform has the gap and the POSIX
// descriptor-bound path does not), but it is declared here so callers on every
// platform can test for it without build tags — the helper-refresh loop below
// must fail the apply on it rather than downgrade it to a warning.
var ErrTargetPossiblyTampered = errors.New("target executable path may hold unverified content after a failed update")
// DefaultDownloadTimeout bounds the archive/checksum download phase of a
// standalone Apply, separately from Options.Timeout (which only covers the
// small release-metadata check), so a stalled connection can't hang forever.
@@ -36,6 +46,7 @@ type ApplyResult struct {
var (
windowsOptionalBinaries = []string{"zero-windows-command-runner.exe", "zero-windows-sandbox-setup.exe"}
linuxOptionalBinaries = []string{"zero-linux-sandbox", "zero-seccomp"}
currentExecutable = os.Executable
)
// Apply checks for an update and, if one is available, installs it: via
@@ -47,25 +58,23 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
return ApplyResult{}, err
}
executablePath, err := os.Executable()
executablePath, err := currentExecutable()
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve current executable: %w", err)
}
if resolved, err := filepath.EvalSymlinks(executablePath); err == nil {
executablePath = resolved
}
// Best-effort: remove a "<binary>.old" left behind by a previous Windows
// replaceBinary call now that enough time (a whole separate invocation)
// has passed for the old process to have released the file. Runs
// regardless of whether an update is available, so it isn't stuck waiting
// on a future upgrade that may never come.
CleanupStaleBinary(executablePath)
method := DetectInstallMethod(executablePath)
if method != InstallMethodNpm {
if err := preflightRecoveryState(executablePath); err != nil {
return ApplyResult{}, err
}
}
if !checkResult.UpdateAvailable {
return ApplyResult{Result: checkResult, Message: "already up to date"}, nil
}
method := DetectInstallMethod(executablePath)
switch method {
case InstallMethodNpm:
if err := applyNpmUpdate(ctx); err != nil {
@@ -179,10 +188,6 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
}
targetDir := filepath.Dir(executablePath)
if err := installBinary(newBinaryPath, executablePath); err != nil {
return nil, err
}
var warnings []string
for _, name := range optionalBinaries {
source, err := findByBasename(extractDir, name)
@@ -194,9 +199,29 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
continue // only refresh helpers this install already has
}
if err := installBinary(source, destPath); err != nil {
// An optional helper that simply fails to refresh is a warning: the
// sandbox degrades gracefully without a newer one, and aborting the
// whole update over it would be worse than the stale helper.
//
// Possible tampering is not that. It means this helper's path may now
// hold content the updater could not verify, and helpers are resolved
// from the install directory and EXECUTED by the sandbox runner — so
// reporting Applied: true and exiting 0 would hand the operator a
// success while a sibling executable is suspect. Fail the apply and
// keep errors.Is intact, exactly as the main binary does.
if errors.Is(err, ErrTargetPossiblyTampered) {
return nil, fmt.Errorf("update helper %s: %w", name, err)
}
warnings = append(warnings, fmt.Sprintf("failed to update helper %s: %v", name, err))
}
}
// Refresh helpers before the main executable. If a helper path may have
// been tampered with, the running main binary must remain on the old version
// so the next invocation still sees this release as available and retries
// the helper instead of returning "already up to date".
if err := installBinary(newBinaryPath, executablePath); err != nil {
return nil, err
}
return warnings, nil
}
@@ -218,23 +243,79 @@ func verifyArchiveChecksum(checksumPath string, expectedArchiveName string) erro
return nil
}
// installBinary stages sourcePath next to targetPath (same directory, so the
// final rename is atomic/same-filesystem) and then swaps it into place.
// installBinary stages sourcePath next to targetPath (same filesystem, so the
// final rename is atomic) and then swaps it into place.
func installBinary(sourcePath string, targetPath string) error {
stagedPath := targetPath + ".new"
if err := copyFile(sourcePath, stagedPath); err != nil {
staged, err := stageBinary(sourcePath, targetPath)
if err != nil {
return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
}
defer func() {
_ = os.Remove(stagedPath)
}()
if err := replaceBinary(targetPath, stagedPath); err != nil {
// Registered before the promotion attempt and reached on every failure path,
// including a mid-write copy error: each attempt now stages under a fresh
// random name, so a leaked partial file is never reused by the next attempt
// and would otherwise accumulate release-sized garbage in the install
// directory. Unverifiable leftovers from a hard crash are preserved rather
// than removed based only on their public filename shape.
defer staged.discard()
if err := staged.promote(targetPath); err != nil {
return fmt.Errorf("install %s: %w", filepath.Base(targetPath), err)
}
return nil
}
func copyFile(sourcePath string, destPath string) (retErr error) {
// stagedBinary is a freshly created staging object holding the verified release
// bytes, together with the handle it was created through. Promotion is bound to
// that object rather than to the staging PATHNAME: under the threat model of
// #742 a lower-privileged principal that can write in the installation
// directory may replace a sibling entry, so re-resolving the staging path at
// swap time would let it substitute its own file into the executable path after
// the verified bytes were written. Each platform closes that handoff with its
// own primitive — see the promote implementations.
type stagedBinary struct {
file *os.File
path string
// dir is a private staging directory that must be removed with the file
// (POSIX). Empty on Windows, which binds the swap to the handle instead.
dir string
// dirHandle is an open descriptor on dir (POSIX only), bound to that
// directory's inode rather than its current pathname. promote renames the
// staged file through it (see stage_other.go) so a principal who can write
// in the installation directory cannot redirect the final rename by
// renaming dir aside and recreating a look-alike directory at the same
// path between the identity check and the rename — a pathname lookup at
// that point would resolve through the impostor instead. nil on Windows.
dirHandle *os.File
// parentHandle is the descriptor-bound parent of dir (POSIX only).
parentHandle *os.File
// promoted records that path now IS the installed binary, so discard must
// not delete it.
promoted bool
}
// stageBinary creates the staging object for targetPath and writes sourcePath's
// bytes into it through the handle it was created with, leaving that handle open
// for promote.
//
// It is a package var so a test can force a staging failure on every platform:
// the staging location is created exclusively under a name that cannot be known
// in advance, which is exactly what stops a test (like an attacker) from
// occupying it from outside.
var stageBinary = func(sourcePath string, targetPath string) (*stagedBinary, error) {
staged, err := createStagedBinary(targetPath)
if err != nil {
return nil, err
}
if err := staged.copyFrom(sourcePath); err != nil {
staged.discard()
return nil, err
}
return staged, nil
}
// copyFrom writes sourcePath into the staged file through the handle
// createStagedBinary opened. It never reopens by pathname, so the bytes cannot
// land anywhere other than the object that was exclusively created.
func (staged *stagedBinary) copyFrom(sourcePath string) error {
source, err := os.Open(sourcePath)
if err != nil {
return err
@@ -242,17 +323,28 @@ func copyFile(sourcePath string, destPath string) (retErr error) {
defer func() {
_ = source.Close()
}()
dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
if _, err := io.Copy(staged.file, source); err != nil {
return err
}
defer func() {
if closeErr := dest.Close(); closeErr != nil && retErr == nil {
retErr = closeErr
}
}()
_, retErr = io.Copy(dest, source)
return retErr
return staged.file.Sync()
}
// discard closes the handle(s) and removes what it created, unless the object
// was already promoted into the executable path.
func (staged *stagedBinary) discard() {
if staged == nil {
return
}
// Removal is bound to the staged object before the handle is released
// wherever the platform allows it (Windows, via delete-on-close). Once the
// handle is gone the staging pathname can be re-resolved, and under the
// writable-install-directory threat model that entry may by then name a file
// this updater never created.
staged.discardOpenObject()
if staged.file != nil {
_ = staged.file.Close()
}
staged.discardPaths()
}
func downloadFile(ctx context.Context, url string, destPath string) error {
+105 -8
View File
@@ -2,6 +2,8 @@ package update
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -138,9 +140,12 @@ func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) {
if entries, err := os.ReadDir(installDir); err == nil {
for _, entry := range entries {
name := entry.Name()
// On Windows, replaceBinary leaves "<name>.old" behind (the running
// binary is renamed aside, not deleted) for later best-effort cleanup.
if name == binaryName || (optionalName != "" && name == optionalName) || name == binaryName+".old" || (optionalName != "" && name == optionalName+".old") {
// On Windows, replacement leaves a namespaced recovery copy of each
// replaced binary for identity-bound cleanup by a later update.
windowsRecovery := runtime.GOOS == "windows" && strings.HasSuffix(name, ".old") &&
(strings.HasPrefix(name, binaryName+".zero-update-") ||
optionalName != "" && strings.HasPrefix(name, optionalName+".zero-update-"))
if name == binaryName || (optionalName != "" && name == optionalName) || windowsRecovery {
continue
}
t.Fatalf("unexpected extra file left in install dir: %s", name)
@@ -169,11 +174,11 @@ func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
t.Fatalf("WriteFile helper: %v", err)
}
// Force installBinary's staging copy to fail by occupying its staged
// "<helper>.new" path with a directory instead of a file.
if err := os.MkdirAll(existingHelperPath+".new", 0o755); err != nil {
t.Fatalf("MkdirAll staged path: %v", err)
}
// Force the helper's staging to fail. It cannot be provoked from outside any
// more: the staging location is created exclusively under a name (POSIX: a
// private directory) that nothing else can predict or occupy, which is the
// point of the fix. So fail it through the seam instead.
stubStageBinaryFailure(t, existingHelperPath, errors.New("staging is unavailable in this test"))
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
@@ -241,6 +246,98 @@ func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
}
}
// TestApplyStandaloneUpdateFailsWhenHelperRefreshReportsTampering covers
// jatmn's #751 P2. An optional helper that merely fails to refresh stays a
// warning — the sandbox degrades fine on a stale helper. Possible tampering is
// different in kind: the helper's path may now hold content the updater could
// not verify, and helpers are resolved from the install directory and executed
// by the sandbox runner, so reporting Applied: true and exiting 0 would hand
// the operator a success while a sibling executable is suspect.
func TestApplyStandaloneUpdateFailsWhenHelperRefreshReportsTampering(t *testing.T) {
binaryName := "zero"
optionalName := "zero-seccomp"
switch runtime.GOOS {
case "windows":
binaryName = "zero.exe"
optionalName = "zero-windows-command-runner.exe"
case "darwin":
t.Skip("macOS ships no optional helper binaries to refresh")
}
installDir := t.TempDir()
executablePath := filepath.Join(installDir, binaryName)
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile executable: %v", err)
}
existingHelperPath := filepath.Join(installDir, optionalName)
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
t.Fatalf("WriteFile helper: %v", err)
}
stubStageBinaryFailure(t, existingHelperPath, fmt.Errorf("promote: %w", ErrTargetPossiblyTampered))
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
archivePath := filepath.Join(archiveDir, archiveName)
writeTestTarGz(t, archivePath, map[string]string{
"zero": "new-binary",
"zero.exe": "new-binary-exe",
"zero-seccomp": "new-helper",
"zero-windows-command-runner.exe": "new-helper-exe",
})
checksum, err := release.SHA256File(archivePath)
if err != nil {
t.Fatalf("SHA256File: %v", err)
}
checksumText, err := release.FormatSHA256Checksum(checksum, archiveName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/" + archiveName:
http.ServeFile(w, r, archivePath)
case "/" + archiveName + ".sha256":
_, _ = w.Write([]byte(checksumText))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result := Result{
LatestVersion: "0.2.0",
ReleaseAsset: AssetCheck{
Platform: "linux",
Arch: "x64",
ArchiveName: archiveName,
ArchiveURL: server.URL + "/" + archiveName,
ChecksumName: archiveName + ".sha256",
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
ArchiveFound: true,
ChecksumFound: true,
Verified: true,
},
}
warnings, err := applyStandaloneUpdate(context.Background(), result, executablePath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("applyStandaloneUpdate error = %v, want it to wrap ErrTargetPossiblyTampered", err)
}
if !strings.Contains(err.Error(), optionalName) {
t.Fatalf("error = %v, want it to name the helper %q", err, optionalName)
}
if len(warnings) != 0 {
t.Fatalf("tampering must not be reported as a warning: %v", warnings)
}
mainData, readErr := os.ReadFile(executablePath)
if readErr != nil {
t.Fatalf("ReadFile main binary: %v", readErr)
}
if string(mainData) != "old-binary" {
t.Fatalf("main binary = %q, want old-binary so a retry still sees the update", mainData)
}
}
func TestApplyStandaloneUpdateRejectsChecksumMismatch(t *testing.T) {
binaryName := "zero"
if runtime.GOOS == "windows" {
+58
View File
@@ -0,0 +1,58 @@
//go:build windows
package update
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
)
func TestApplyAlreadyCurrentRefusesUnresolvedRecoveryState(t *testing.T) {
for _, setup := range []struct {
name string
run func(string) error
}{
{"relocated recovery", func(target string) error {
return os.WriteFile(target+".old.deadbeef.recovery", []byte("known-good"), 0o600)
}},
{"marked recovery", func(target string) error {
if err := os.WriteFile(target+".old", []byte("known-good"), 0o600); err != nil {
return err
}
return os.WriteFile(target+".old.keep", []byte("preserve"), 0o600)
}},
{"missing target", func(target string) error {
if err := os.Remove(target); err != nil {
return err
}
return os.WriteFile(target+".old", []byte("known-good"), 0o600)
}},
} {
t.Run(setup.name, func(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if err := setup.run(targetPath); err != nil {
t.Fatal(err)
}
originalExecutable := currentExecutable
currentExecutable = func() (string, error) { return targetPath, nil }
defer func() { currentExecutable = originalExecutable }()
_, err := Apply(context.Background(), Options{
CurrentVersion: "1.0.0",
Fetch: func(context.Context, string) (Release, error) {
return releaseForTarget(t, "v1.0.0", "windows", "amd64"), nil
},
})
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("Apply error = %v, want ErrTargetPossiblyTampered", err)
}
})
}
}
@@ -0,0 +1,5 @@
//go:build !windows
package update
func preflightRecoveryState(string) error { return nil }
-19
View File
@@ -1,19 +0,0 @@
//go:build !windows
package update
import "os"
// replaceBinary installs newPath over targetPath. On POSIX systems renaming
// over a running executable is safe: the process currently executing it keeps
// its open inode, and the rename is atomic within the same filesystem.
func replaceBinary(targetPath string, newPath string) error {
if err := os.Chmod(newPath, 0o755); err != nil {
return err
}
return os.Rename(newPath, targetPath)
}
// CleanupStaleBinary is a no-op outside Windows, which is the only platform
// that requires renaming a running binary aside instead of replacing it directly.
func CleanupStaleBinary(targetPath string) {}
+502 -30
View File
@@ -3,9 +3,18 @@
package update
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unsafe"
"github.com/Gitlawb/zero/internal/config"
"golang.org/x/sys/windows"
)
const (
@@ -13,33 +22,16 @@ const (
restoreRenameRetryDelay = 100 * time.Millisecond
)
// replaceBinary installs newPath over targetPath. Windows will not let a
// running executable be overwritten or deleted directly, but NTFS does allow
// renaming it aside — the same trick already used for locked config files in
// internal/cli/mcp_config.go's replaceMCPWritableConfigFile.
func replaceBinary(targetPath string, newPath string) error {
oldPath := targetPath + ".old"
_ = os.Remove(oldPath) // best-effort cleanup of a leftover from a previous upgrade
if err := os.Rename(targetPath, oldPath); err != nil {
return fmt.Errorf("rename running binary aside: %w", err)
}
if err := os.Rename(newPath, targetPath); err != nil {
// Retry the restore: a transient Windows file lock (antivirus/indexer
// scanning the just-renamed file, a lingering handle) can make a rename
// fail momentarily, and here failure means targetPath is left missing
// entirely rather than merely stale — worth a short retry to avoid that.
if restoreErr := renameWithRetry(oldPath, targetPath); restoreErr != nil {
return fmt.Errorf("install new binary: %w; additionally failed to restore the original binary: %v (original preserved at %s)", err, restoreErr, oldPath)
}
return fmt.Errorf("install new binary: %w", err)
}
return nil
}
func renameWithRetry(oldPath string, newPath string) error {
// renameOpenFileWithRetry renames the object file refers to onto newPath,
// retrying while something transient (an on-access scanner, a stale handle from
// the previous process image) still occupies the destination. The retry is on
// the handle-bound rename rather than a pathname rename: the destination is in a
// directory the threat principal can write, so re-resolving the SOURCE pathname
// between attempts would let a substitute be restored instead.
func renameOpenFileWithRetry(file *os.File, newPath string) error {
var lastErr error
for attempt := 0; attempt < restoreRenameRetryAttempts; attempt++ {
if err := os.Rename(oldPath, newPath); err == nil {
if err := renameRecoveryFileByHandle(file, newPath); err == nil {
return nil
} else {
lastErr = err
@@ -49,9 +41,489 @@ func renameWithRetry(oldPath string, newPath string) error {
return lastErr
}
// CleanupStaleBinary best-effort removes a "<path>.old" file left behind by a
// previous replaceBinary call once the old process holding it has exited.
// Callers should invoke this once at startup for the current executable.
func CleanupStaleBinary(targetPath string) {
_ = os.Remove(targetPath + ".old")
// This file is where ErrTargetPossiblyTampered (declared in apply.go, so
// cross-platform callers can test for it) is produced. It is wrapped into the
// error promote returns when a promotion attempt fails AND restoring the
// original binary to targetPath also fails. That combination means a principal
// who can write in the installation directory occupied targetPath in the gap
// the updater opened by renaming the running binary aside, and Windows would
// not let anything — including the restore — replace it:
// MOVEFILE_REPLACE_EXISTING cannot force past another handle's share-mode lock.
// This is not an ordinary failed update (the previous version simply staying in
// place); the executable path may now hold attacker-controlled bytes, so a
// caller must surface it as a security-relevant condition, not the same "try
// again later" failure as a stalled download.
//
// It is also returned by promote when a PREVIOUS run left that state behind and
// nobody has resolved it yet — see the refusal there.
// restoreOriginalBinary moves the object file refers to — the binary promote
// moved aside from targetPath, held open since before that rename — back onto
// targetPath after a failed promotion. Everything here is bound to that handle:
// oldPath is only ever used to NAME the object for the operator and to site the
// recovery marker, never re-resolved to decide what gets restored. A failed
// immediate restore is surfaced; the aside copy must not be queued as a reboot
// source because its pathname can be replaced before reboot under the
// writable-directory threat model.
func restoreOriginalBinary(file *os.File, oldPath string, targetPath string) error {
err := renameOpenFileWithRetry(file, targetPath)
if err == nil {
err = verifyPromotedTarget(file, targetPath)
}
if err == nil {
return nil
}
// The error this produces tells the operator their original is preserved at
// oldPath. Record that so a later promotion refuses to treat the recovery
// copy as the ordinary destination for another aside rename.
if markErr := markOldBinaryPreserved(oldPath); markErr != nil {
// The marker could not be established, so nothing on disk identifies
// oldPath as the recovery copy. Move it to a distinct name and report that
// authoritative location to the operator.
if kept, keepErr := keepUnmarkedRecoveryCopy(file, oldPath); keepErr == nil {
return fmt.Errorf(
"%w: %v (the recovery marker could not be written: %v; the last binary this updater verified was moved to the distinct recovery path %s)",
ErrTargetPossiblyTampered, err, markErr, kept,
)
} else if kept != "" {
// The move succeeded but the post-move verification did not, so
// oldPath is already vacated — point at kept, the path the
// bytes actually landed at, not the path that no longer holds them.
return fmt.Errorf(
"%w: %v (the recovery marker could not be written: %v; the last binary this updater verified was moved to %s but could not be verified there: %v)",
ErrTargetPossiblyTampered, err, markErr, kept, keepErr,
)
} else {
return fmt.Errorf(
"%w: %v (the recovery marker could not be written: %v — manually copy %s somewhere safe now)",
ErrTargetPossiblyTampered, err, markErr, oldPath,
)
}
}
return fmt.Errorf("%w: %v", ErrTargetPossiblyTampered, err)
}
// oldBinaryPreservedSuffix names the marker written beside a "<path>.old" that
// a failed restore left as the last known-good binary.
const oldBinaryPreservedSuffix = ".keep"
// markOldBinaryPreserved records that oldPath must survive routine cleanup.
//
// The marker is created with the same exclusive, no-follow semantics as a
// staging file, and never truncates. Its pathname is predictable, so under the
// writable-install-directory threat model a lower-privileged writer can
// pre-create it as a hard link or reparse point; opening that with O_TRUNC
// would let the elevated updater write through it into a file of the attacker's
// choosing. CREATE_NEW plus FILE_FLAG_OPEN_REPARSE_POINT plus the same
// fresh-regular-file check refuses that object instead of writing to it.
//
// An existing marker is success, not a rewrite: its contents carry no state
// beyond "this .old is the last verified binary", so there is nothing to update
// and nothing worth opening a pre-existing object for. That also means an entry
// planted at the name by someone else reads as marked, which is the fail-safe
// direction: it makes the next run PRESERVE the recovery copy.
//
// A var so a test can force the failure branch. Every way to make the real
// CreateFile fail here (an unwritable directory, an over-length name) either
// takes the recovery copy down with it or is too platform-fragile to assert on.
var markOldBinaryPreserved = func(oldPath string) error {
markerPath := oldPath + oldBinaryPreservedSuffix
if _, err := os.Lstat(markerPath); err == nil {
return nil
}
pathPtr, err := windows.UTF16PtrFromString(markerPath)
if err != nil {
return err
}
handle, err := windows.CreateFile(
pathPtr,
windows.GENERIC_WRITE|windows.DELETE,
0,
nil,
windows.CREATE_NEW,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
if errors.Is(err, windows.ERROR_FILE_EXISTS) || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
// Something already occupies the marker name. Whatever it is, this
// function's only job is to make the next run preserve oldPath, and a
// present name does that — without this process writing through an
// object it did not create.
return nil
}
return fmt.Errorf("create recovery marker %s: %w", markerPath, err)
}
marker := os.NewFile(uintptr(handle), markerPath)
if err := verifyFreshRegularFile(handle, markerPath); err != nil {
_ = deleteFileByHandle(marker)
_ = marker.Close()
return err
}
writeErr := writeRecoveryMarker(marker)
// A partial marker must not be left beside a recovery copy that the caller
// then relocates. Delete the object this process created through its own
// handle — never by pathname, which the threat principal can point at
// something else once the handle is released.
var deleteErr error
if writeErr != nil {
deleteErr = deleteFileByHandle(marker)
}
closeErr := marker.Close()
if writeErr == nil {
// A close failure still leaves the fully written marker in place, and
// its presence is the entire state this function records.
_ = closeErr
return nil
}
// Below, the write failed. Each remaining branch decides between reporting
// that failure and conservatively claiming success — and only a confirmed
// absence of the marker earns the report, because claiming success keeps
// oldPath as the authoritative recovery location, which is the safe answer.
if deleteErr != nil {
// The partial marker could not be removed, so it is still there.
return nil
}
if _, statErr := os.Lstat(markerPath); statErr == nil || !errors.Is(statErr, os.ErrNotExist) {
// Something occupies the marker name, or its state cannot be established.
return nil
}
return fmt.Errorf("write recovery marker %s: %w", markerPath, writeErr)
}
var writeRecoveryMarker = func(marker *os.File) error {
_, err := marker.WriteString("The update at this path failed and could not restore the original binary.\n" +
"The file without this marker's .keep suffix is the last binary this updater verified.\n")
return err
}
// keepUnmarkedRecoveryCopy moves oldPath out from under routine cleanup when its
// marker could not be established, returning the path it now lives at.
//
// The name is unpredictable for the same reason the staging name is: this runs
// in a directory a lower-privileged principal may be able to write, and a fixed
// recovery name could be pre-created there to make this rename fail or land
// somewhere chosen by someone else. Being unpredictable also means being opaque,
// which is why the caller's error names the path.
func keepUnmarkedRecoveryCopy(file *os.File, oldPath string) (string, error) {
suffix, err := randomStagingSuffix()
if err != nil {
return "", fmt.Errorf("%w: choose recovery path: %v", ErrTargetPossiblyTampered, err)
}
kept := filepath.Join(filepath.Dir(oldPath), filepath.Base(oldPath)+"."+suffix+".recovery")
if _, statErr := os.Lstat(kept); statErr == nil {
return "", fmt.Errorf("%w: recovery path %s already exists", ErrTargetPossiblyTampered, kept)
} else if !errors.Is(statErr, os.ErrNotExist) {
return "", fmt.Errorf("%w: inspect recovery path %s: %v", ErrTargetPossiblyTampered, kept, statErr)
}
if err := renameRecoveryFileByHandle(file, kept); err != nil {
return "", fmt.Errorf("%w: move verified recovery copy to %s: %v", ErrTargetPossiblyTampered, kept, err)
}
if err := verifyPromotedTarget(file, kept); err != nil {
// The rename already reported success, so oldPath is no longer a
// reliable location for the caller to fall back to — surface kept
// anyway so the operator is pointed at the actual (if unverified)
// destination instead of a path the move already vacated.
return kept, fmt.Errorf("%w: verify recovery copy at %s: %v", ErrTargetPossiblyTampered, kept, err)
}
return kept, nil
}
func openRecoveryCopy(path string) (*os.File, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathPtr,
windows.DELETE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return nil, err
}
if err := verifyFreshRegularFile(handle, path); err != nil {
_ = windows.CloseHandle(handle)
return nil, err
}
return os.NewFile(uintptr(handle), path), nil
}
var renameRecoveryFileByHandle = renameOpenFile
// clearOldBinaryPreserved models the operator accepting the installed binary
// by deleting the recovery marker. The recovery copy itself remains preserved.
func clearOldBinaryPreserved(oldPath string) {
_ = os.Remove(oldPath + oldBinaryPreservedSuffix)
}
// oldBinaryPreserved reports whether a failed restore marked oldPath as the last
// known-good binary.
//
// Only a definite "the marker is not there" answers false. An Lstat that fails
// for any other reason (permissions, a transient sharing error) leaves the
// question open, and the conservative answer to an open question here is to keep
// the copy: deleting it is irreversible, while keeping it costs one stale file.
func oldBinaryPreserved(oldPath string) bool {
_, err := os.Lstat(oldPath + oldBinaryPreservedSuffix)
return err == nil || !errors.Is(err, os.ErrNotExist)
}
type recoveryCleanupRecord struct {
Path string `json:"path"`
VolumeSerial uint32 `json:"volumeSerial"`
FileIndexHigh uint32 `json:"fileIndexHigh"`
FileIndexLow uint32 `json:"fileIndexLow"`
}
type recoveryCleanupQueue struct {
Records []recoveryCleanupRecord `json:"records"`
}
var recoveryCleanupStateDir = func() (string, error) {
root, err := config.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(root, "zero", "update-recovery"), nil
}
func recoveryCleanupRecordPath(targetPath string) (string, error) {
dir, err := recoveryCleanupStateDir()
if err != nil {
return "", err
}
absolute, err := filepath.Abs(targetPath)
if err != nil {
return "", err
}
digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(absolute))))
return filepath.Join(dir, fmt.Sprintf("%x.json", digest)), nil
}
func validUpdaterRecoveryPath(targetPath string, recoveryPath string) bool {
if !strings.EqualFold(filepath.Clean(filepath.Dir(targetPath)), filepath.Clean(filepath.Dir(recoveryPath))) {
return false
}
name := strings.ToLower(filepath.Base(recoveryPath))
prefix := strings.ToLower(filepath.Base(targetPath)) + ".zero-update-"
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".old") {
return false
}
suffix := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".old")
if len(suffix) != 32 {
return false
}
for _, character := range suffix {
if !strings.ContainsRune("0123456789abcdef", character) {
return false
}
}
return true
}
// prepareRecoveryCleanup opens only the exact object vouched for by trusted
// per-user state from the previous successful promotion. Recovery discovery is
// intentionally broad and filename-based so suspicious state fails closed, but
// destructive cleanup never treats an install-directory name as provenance.
type recoveryCleanupCandidate struct {
file *os.File
record recoveryCleanupRecord
}
func loadRecoveryCleanupQueue(targetPath string) recoveryCleanupQueue {
recordPath, err := recoveryCleanupRecordPath(targetPath)
if err != nil {
return recoveryCleanupQueue{}
}
data, err := os.ReadFile(recordPath)
if err != nil {
return recoveryCleanupQueue{}
}
var queue recoveryCleanupQueue
if json.Unmarshal(data, &queue) != nil || queue.Records == nil {
var legacy recoveryCleanupRecord
if json.Unmarshal(data, &legacy) == nil && legacy.Path != "" {
queue.Records = []recoveryCleanupRecord{legacy}
}
}
return queue
}
func prepareRecoveryCleanup(targetPath string) []recoveryCleanupCandidate {
queue := loadRecoveryCleanupQueue(targetPath)
var candidates []recoveryCleanupCandidate
// Records outlive a single attempt so a temporarily locked copy is still
// deleted on a later run, but a record that can never become actionable has
// to be retired here or the backlog grows without bound.
retained := queue.Records[:0]
for _, record := range queue.Records {
// Not a name this updater could have produced: unusable as provenance
// no matter what appears at it later.
if !validUpdaterRecoveryPath(targetPath, record.Path) {
continue
}
// Definitively gone — an operator removed it, or a delete this updater
// requested completed once the last handle closed.
if _, err := os.Lstat(record.Path); errors.Is(err, os.ErrNotExist) {
continue
}
retained = append(retained, record)
if oldBinaryPreserved(record.Path) {
continue
}
file, err := openRecoveryCopy(record.Path)
if err != nil {
continue
}
identity, err := recoveryFileIdentity(file)
if err != nil || identity.VolumeSerial != record.VolumeSerial ||
identity.FileIndexHigh != record.FileIndexHigh || identity.FileIndexLow != record.FileIndexLow {
_ = file.Close()
continue
}
candidates = append(candidates, recoveryCleanupCandidate{file: file, record: record})
}
if len(retained) != len(queue.Records) {
queue.Records = retained
_ = writeRecoveryCleanupQueue(targetPath, queue)
}
return candidates
}
type recoveryIdentity struct {
VolumeSerial uint32
FileIndexHigh uint32
FileIndexLow uint32
}
func recoveryFileIdentity(file *os.File) (recoveryIdentity, error) {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &info); err != nil {
return recoveryIdentity{}, err
}
return recoveryIdentity{
VolumeSerial: info.VolumeSerialNumber,
FileIndexHigh: info.FileIndexHigh,
FileIndexLow: info.FileIndexLow,
}, nil
}
// appendRecoveryCleanupRecord adds the copy this promotion moved aside to the
// per-user cleanup backlog. The identity comes from the handle the caller held
// across the aside rename, so a pathname substituted afterwards can never be
// recorded as updater-owned: the next run reopens recoveryPath and only deletes
// it if it is still that same object.
func appendRecoveryCleanupRecord(targetPath string, recoveryPath string, identity recoveryIdentity) error {
if !validUpdaterRecoveryPath(targetPath, recoveryPath) {
return fmt.Errorf("invalid updater recovery path %s", recoveryPath)
}
record := recoveryCleanupRecord{
Path: recoveryPath,
VolumeSerial: identity.VolumeSerial,
FileIndexHigh: identity.FileIndexHigh,
FileIndexLow: identity.FileIndexLow,
}
queue := loadRecoveryCleanupQueue(targetPath)
queue.Records = append(queue.Records, record)
return writeRecoveryCleanupQueue(targetPath, queue)
}
func writeRecoveryCleanupQueue(targetPath string, queue recoveryCleanupQueue) error {
data, err := json.Marshal(queue)
if err != nil {
return err
}
recordPath, err := recoveryCleanupRecordPath(targetPath)
if err != nil {
return err
}
dir := filepath.Dir(recordPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
temporary, err := os.CreateTemp(dir, filepath.Base(recordPath)+".*.tmp")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
return os.Rename(temporaryPath, recordPath)
}
type fileDispositionInfo struct {
DeleteFile byte
}
// deleteFileByHandle marks the object file refers to for deletion, which takes
// effect when its last handle closes. It never resolves a pathname, so it can
// only ever delete the object this process already holds open.
func deleteFileByHandle(file *os.File) error {
info := fileDispositionInfo{DeleteFile: 1}
return windows.SetFileInformationByHandle(
windows.Handle(file.Fd()),
windows.FileDispositionInfo,
(*byte)(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
)
}
func closeRecoveryCleanupCandidates(candidates []recoveryCleanupCandidate) {
for index := range candidates {
if candidates[index].file == nil {
continue
}
_ = candidates[index].file.Close()
candidates[index].file = nil
}
}
// cleanupSupersededRecoveryCopies marks the exact pre-promotion objects for
// deletion only after the replacement has been verified. The handles deny
// delete sharing, so their entries cannot be substituted in the meantime.
//
// A record is only retired once its object is actually gone. A copy another
// process holds open (an on-access scanner, an operator's editor) stays in the
// backlog and is retried by a later promotion, so transient locks cannot leak
// full binaries.
func cleanupSupersededRecoveryCopies(targetPath string, candidates []recoveryCleanupCandidate) {
defer closeRecoveryCleanupCandidates(candidates)
queue := loadRecoveryCleanupQueue(targetPath)
deleted := make(map[recoveryCleanupRecord]bool)
for index := range candidates {
candidate := &candidates[index]
if err := deleteFileByHandle(candidate.file); err != nil {
continue
}
// FileDispositionInfo removes the entry when the last handle closes, so
// releasing ours is what makes the deletion observable.
_ = candidate.file.Close()
candidate.file = nil
if _, err := os.Lstat(candidate.record.Path); errors.Is(err, os.ErrNotExist) {
deleted[candidate.record] = true
}
}
remaining := queue.Records[:0]
for _, record := range queue.Records {
if !deleted[record] {
remaining = append(remaining, record)
}
}
queue.Records = remaining
_ = writeRecoveryCleanupQueue(targetPath, queue)
}
+441 -36
View File
@@ -3,63 +3,468 @@
package update
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/windows"
)
func TestReplaceBinaryReplacesRunningBinary(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
newPath := filepath.Join(dir, "zero.exe.new")
// The replacement path itself (rename the running binary aside, then rename the
// staged object into place through its handle) is covered by
// TestInstallBinaryInstallsVerifiedBytes and
// TestPromoteInstallsTheStagedObjectNotTheStagedPath in
// stage_promote_windows_test.go, which exercise it through the staging handle the
// production code uses rather than a loose pathname.
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(newPath, []byte("new-binary"), 0o755); err != nil {
t.Fatalf("WriteFile new: %v", err)
}
if err := replaceBinary(targetPath, newPath); err != nil {
t.Fatalf("replaceBinary: %v", err)
}
data, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile target: %v", err)
}
if string(data) != "new-binary" {
t.Fatalf("target content = %q, want %q", data, "new-binary")
}
if _, err := os.Stat(targetPath + ".old"); err != nil {
t.Fatalf("expected the original binary to be preserved at %s.old: %v", targetPath, err)
}
}
func TestRenameWithRetrySucceedsImmediately(t *testing.T) {
func TestRenameOpenFileWithRetrySucceedsImmediately(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
file, err := openRecoveryCopy(src)
if err != nil {
t.Fatalf("openRecoveryCopy: %v", err)
}
defer func() { _ = file.Close() }()
if err := renameWithRetry(src, dst); err != nil {
t.Fatalf("renameWithRetry: %v", err)
if err := renameOpenFileWithRetry(file, dst); err != nil {
t.Fatalf("renameOpenFileWithRetry: %v", err)
}
if _, err := os.Stat(dst); err != nil {
t.Fatalf("expected dst to exist after rename: %v", err)
}
}
// A permanently-failing rename (source never appears) must exhaust its
// retries and surface the underlying error, rather than retrying forever.
func TestRenameWithRetryFailsAfterExhaustingAttempts(t *testing.T) {
// A permanently-failing rename (the destination is held by a conflicting
// exclusive handle for good) must exhaust its retries and surface the
// underlying error, rather than retrying forever.
func TestRenameOpenFileWithRetryFailsAfterExhaustingAttempts(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "does-not-exist")
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
for _, path := range []string{src, dst} {
if err := os.WriteFile(path, []byte("data"), 0o644); err != nil {
t.Fatalf("WriteFile %s: %v", path, err)
}
}
file, err := openRecoveryCopy(src)
if err != nil {
t.Fatalf("openRecoveryCopy: %v", err)
}
defer func() { _ = file.Close() }()
blocker, err := openWithoutSharing(dst)
if err != nil {
t.Skipf("cannot hold the destination exclusively on this filesystem: %v", err)
}
defer func() { _ = blocker.Close() }()
if err := renameWithRetry(missing, dst); err == nil {
t.Fatal("expected renameWithRetry to fail for a source that never appears")
if err := renameOpenFileWithRetry(file, dst); err == nil {
t.Fatal("expected renameOpenFileWithRetry to fail against a permanently blocked destination")
}
}
// TestRestoreOriginalBinaryFlagsPossibleTamperingWhenRestoreFails is the
// regression test for a review finding on PR #751: when a promotion attempt
// fails AND the restore of the preserved original also cannot get past
// whatever now occupies targetPath, that combination must be reported as a
// security-relevant condition (ErrTargetPossiblyTampered), not folded into
// the same generic error a stalled download would produce — the caller needs
// to be able to tell "try the update again later" apart from "verify what is
// at this path before running it again".
func TestRestoreOriginalBinaryFlagsPossibleTamperingWhenRestoreFails(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "zero.exe.old")
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(oldPath, []byte("original"), 0o755); err != nil {
t.Fatalf("WriteFile oldPath: %v", err)
}
// Simulate an attacker occupying targetPath with a lock MOVEFILE_REPLACE_EXISTING
// cannot get past: an exclusive, no-share open.
pathPtr, err := windows.UTF16PtrFromString(targetPath)
if err != nil {
t.Fatalf("UTF16PtrFromString: %v", err)
}
handle, err := windows.CreateFile(pathPtr, windows.GENERIC_WRITE, 0, nil, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
t.Fatalf("CreateFile targetPath: %v", err)
}
defer func() { _ = windows.CloseHandle(handle) }()
restoreErr := restoreOriginalBinary(openRecoveryHandle(t, oldPath), oldPath, targetPath)
if restoreErr == nil {
t.Fatal("restoreOriginalBinary succeeded despite a conflicting exclusive lock on targetPath, want an error")
}
if !errors.Is(restoreErr, ErrTargetPossiblyTampered) {
t.Fatalf("error = %v, want it to wrap ErrTargetPossiblyTampered", restoreErr)
}
}
// TestRestoreOriginalBinaryMarksPreservedCopy pins the other half: the path that
// reports "original preserved at <.old>" is the path that makes that true across
// runs.
func TestRestoreOriginalBinaryMarksPreservedCopy(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
oldPath := targetPath + ".old"
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile old binary: %v", err)
}
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
// Hold the target with no sharing so the restore rename cannot replace it,
// which is the condition ErrTargetPossiblyTampered describes.
blocker, err := openWithoutSharing(targetPath)
if err != nil {
t.Skipf("cannot hold the target exclusively on this filesystem: %v", err)
}
defer func() { _ = blocker.Close() }()
recovery := openRecoveryHandle(t, oldPath)
err = restoreOriginalBinary(recovery, oldPath, targetPath)
// Release the handle promote would hold, so these assertions can read the
// files it was keeping unsubstitutable.
_ = recovery.Close()
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
}
if !oldBinaryPreserved(oldPath) {
t.Fatal("a failed restore must mark the preserved copy so later cleanup keeps it")
}
if _, err := os.Stat(oldPath); err != nil {
t.Fatalf("recovery copy was removed after a failed restore: %v", err)
}
}
// TestMarkOldBinaryPreservedRefusesPreCreatedLink covers jatmn's #751 finding
// that the marker was a predictable link-following truncate write: the path is
// fixed, so a lower-privileged writer in the install directory can pre-create it
// as a hard link (or reparse point) and have the elevated updater truncate and
// write through it into a file of their choosing.
func TestMarkOldBinaryPreservedRefusesPreCreatedLink(t *testing.T) {
for _, kind := range []string{"hardlink", "symlink"} {
t.Run(kind, func(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "zero.exe.old")
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile old binary: %v", err)
}
victim := filepath.Join(t.TempDir(), "victim.txt")
const victimContent = "attacker-chosen target that must not be written"
if err := os.WriteFile(victim, []byte(victimContent), 0o600); err != nil {
t.Fatalf("WriteFile victim: %v", err)
}
markerPath := oldPath + oldBinaryPreservedSuffix
var linkErr error
switch kind {
case "hardlink":
linkErr = os.Link(victim, markerPath)
case "symlink":
linkErr = os.Symlink(victim, markerPath)
}
if linkErr != nil {
t.Skipf("%s unsupported here: %v", kind, linkErr)
}
// Whatever this returns, the one thing it must not do is write through
// the planted object. Reporting the marker as present is the safe
// answer: it makes the next run PRESERVE the recovery copy.
_ = markOldBinaryPreserved(oldPath)
got, err := os.ReadFile(victim)
if err != nil {
t.Fatalf("ReadFile victim: %v", err)
}
if string(got) != victimContent {
t.Fatalf("marker write followed the planted %s and wrote into %q: %q", kind, victim, got)
}
// The recovery copy itself is untouched, which is the marker's purpose.
if _, err := os.Stat(oldPath); err != nil {
t.Fatalf("recovery copy was removed while planting the marker: %v", err)
}
})
}
}
func TestMarkOldBinaryPreservedRemovesPartialMarkerBeforeRelocation(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
oldPath := targetPath + ".old"
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile old binary: %v", err)
}
blocker, err := openWithoutSharing(targetPath)
if err != nil {
t.Skipf("cannot hold the target exclusively on this filesystem: %v", err)
}
defer func() { _ = blocker.Close() }()
originalWrite := writeRecoveryMarker
writeRecoveryMarker = func(marker *os.File) error {
_, _ = marker.WriteString("partial")
return errors.New("injected marker write failure")
}
t.Cleanup(func() { writeRecoveryMarker = originalWrite })
stubRandomStagingSuffix(t, "deadbeef")
recovery := openRecoveryHandle(t, oldPath)
err = restoreOriginalBinary(recovery, oldPath, targetPath)
// Release the handle promote would hold, so these assertions can read the
// files it was keeping unsubstitutable.
_ = recovery.Close()
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
}
kept := oldPath + ".deadbeef.recovery"
if !strings.Contains(err.Error(), kept) {
t.Fatalf("error = %v, want relocated recovery path %s", err, kept)
}
if _, err := os.Lstat(oldPath + oldBinaryPreservedSuffix); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("partial marker survived relocation: %v", err)
}
if got, err := os.ReadFile(kept); err != nil || string(got) != "known-good" {
t.Fatalf("relocated recovery = %q err=%v, want known-good", got, err)
}
}
// TestRestoreOriginalBinaryKeepsRecoveryCopyWhenMarkingFails covers the half of
// CodeRabbit's marker finding that surfacing the failure alone does not: when no
// marker can be established, nothing on disk tells the next run to keep the
// copy, so it is moved out from under routine cleanup instead of being left at
// the ordinary "<target>.old" recovery name.
func TestRestoreOriginalBinaryKeepsRecoveryCopyWhenMarkingFails(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
oldPath := targetPath + ".old"
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile old binary: %v", err)
}
// Hold the target with no sharing so the restore rename fails.
blocker, err := openWithoutSharing(targetPath)
if err != nil {
t.Skipf("cannot hold the target exclusively on this filesystem: %v", err)
}
defer func() { _ = blocker.Close() }()
// Force the marker to be unestablishable. Doing it through the seam rather
// than by breaking the filesystem keeps oldPath itself intact, which is the
// state this behavior is about.
originalMark := markOldBinaryPreserved
markOldBinaryPreserved = func(string) error { return errors.New("injected marker failure") }
t.Cleanup(func() { markOldBinaryPreserved = originalMark })
stubRandomStagingSuffix(t, "deadbeef")
recovery := openRecoveryHandle(t, oldPath)
err = restoreOriginalBinary(recovery, oldPath, targetPath)
// Release the handle promote would hold, so these assertions can read the
// files it was keeping unsubstitutable.
_ = recovery.Close()
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
}
kept := oldPath + ".deadbeef.recovery"
if !strings.Contains(err.Error(), kept) {
t.Fatalf("error = %v, want it to name the path the copy was moved to", err)
}
got, readErr := os.ReadFile(kept)
if readErr != nil {
t.Fatalf("recovery copy was not kept: %v", readErr)
}
if string(got) != "known-good" {
t.Fatalf("kept copy = %q, want the last verified binary", got)
}
if _, err := os.Lstat(oldPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("old recovery path still exists after move: %v", err)
}
}
// TestOldBinaryPreservedTreatsAnUnreadableMarkerAsPresent pins the conservative
// side of the marker check: only a definite "not there" allows the copy to be
// deleted, because deleting it is irreversible and keeping it costs a file.
func TestOldBinaryPreservedTreatsAnUnreadableMarkerAsPresent(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "zero.exe.old")
if oldBinaryPreserved(oldPath) {
t.Fatal("a genuinely absent marker must report not-preserved")
}
// A directory at the marker name is an entry Lstat can see; anything other
// than a clean not-exist keeps the copy.
if err := os.Mkdir(oldPath+oldBinaryPreservedSuffix, 0o700); err != nil {
t.Fatalf("Mkdir marker: %v", err)
}
if !oldBinaryPreserved(oldPath) {
t.Fatal("an entry at the marker name must count as preserved")
}
}
// TestRestoreOriginalBinarySurfacesMarkerWriteFailure covers the #751 P3: the
// error promises the original is preserved at <target>.old, but if the marker
// cannot be written the next run's cleanup deletes exactly that file. The
// operator has to be told to act now rather than at their convenience.
func TestRestoreOriginalBinarySurfacesMarkerWriteFailure(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
oldPath := targetPath + ".old"
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile old binary: %v", err)
}
file := openRecoveryHandle(t, oldPath)
// Neither the restore nor the marker nor the relocation can succeed, so the
// recovery copy stays at oldPath and the operator must be pointed there.
originalMark := markOldBinaryPreserved
markOldBinaryPreserved = func(string) error { return errors.New("injected marker failure") }
t.Cleanup(func() { markOldBinaryPreserved = originalMark })
originalRename := renameRecoveryFileByHandle
renameRecoveryFileByHandle = func(*os.File, string) error { return errors.New("injected rename failure") }
t.Cleanup(func() { renameRecoveryFileByHandle = originalRename })
err := restoreOriginalBinary(file, oldPath, targetPath)
_ = file.Close()
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
}
if !strings.Contains(err.Error(), "recovery marker could not be written") {
t.Fatalf("error = %v, want it to disclose the failed marker", err)
}
if !strings.Contains(err.Error(), oldPath) {
t.Fatalf("error = %v, want the path the operator must copy", err)
}
if strings.Contains(err.Error(), "later update") {
t.Fatalf("error = %v, must not promise cleanup that no longer exists", err)
}
if got, readErr := os.ReadFile(oldPath); readErr != nil || string(got) != "known-good" {
t.Fatalf("recovery copy at %s = %q err=%v, want the last verified binary", oldPath, got, readErr)
}
}
func TestKeepUnmarkedRecoveryCopyMovesTheOpenedObject(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "zero.exe.old")
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile recovery copy: %v", err)
}
stubRandomStagingSuffix(t, "deadbeef")
originalRename := renameRecoveryFileByHandle
renameRecoveryFileByHandle = func(file *os.File, kept string) error {
// Try to substitute oldPath after keepUnmarkedRecoveryCopy has opened and
// verified it. The exclusive delete sharing normally blocks this. Even
// on a filesystem that permits it, the handle-bound rename must still
// move the verified object rather than the replacement pathname entry.
displaced := oldPath + ".attacker-moved"
if err := os.Rename(oldPath, displaced); err == nil {
if err := os.WriteFile(oldPath, []byte("attacker"), 0o755); err != nil {
return fmt.Errorf("plant substituted recovery: %w", err)
}
}
return renameOpenFile(file, kept)
}
t.Cleanup(func() { renameRecoveryFileByHandle = originalRename })
recovery := openRecoveryHandle(t, oldPath)
kept, err := keepUnmarkedRecoveryCopy(recovery, oldPath)
_ = recovery.Close()
if err != nil {
t.Fatalf("keepUnmarkedRecoveryCopy: %v", err)
}
if got, err := os.ReadFile(kept); err != nil || string(got) != "known-good" {
t.Fatalf("kept recovery = %q err=%v, want the verified object", got, err)
}
}
// TestRestoreOriginalBinaryNamesTheRelocationItCouldNotVerify covers the gap
// jatmn flagged on the pathname restore and that the handle restore inherited:
// when the relocation rename succeeds but the post-move verification does not,
// oldPath has already been vacated, so an error naming oldPath sends the
// operator to a path that no longer holds anything.
func TestRestoreOriginalBinaryNamesTheRelocationItCouldNotVerify(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
oldPath := targetPath + ".old"
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile old binary: %v", err)
}
file := openRecoveryHandle(t, oldPath)
stubRandomStagingSuffix(t, "deadbeef")
originalMark := markOldBinaryPreserved
markOldBinaryPreserved = func(string) error { return errors.New("injected marker failure") }
t.Cleanup(func() { markOldBinaryPreserved = originalMark })
kept := oldPath + ".deadbeef.recovery"
elsewhere := oldPath + ".deadbeef.elsewhere"
originalRename := renameRecoveryFileByHandle
renameRecoveryFileByHandle = func(handle *os.File, destination string) error {
if destination != kept {
return errors.New("injected restore failure")
}
// The rename reports success but lands somewhere else, so kept is
// vacated and verification there fails.
return renameOpenFile(handle, elsewhere)
}
t.Cleanup(func() { renameRecoveryFileByHandle = originalRename })
err := restoreOriginalBinary(file, oldPath, targetPath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("restore error = %v, want ErrTargetPossiblyTampered", err)
}
if !strings.Contains(err.Error(), kept) {
t.Fatalf("error = %v, want it to name the relocation path %s", err, kept)
}
if _, statErr := os.Lstat(oldPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("oldPath still exists after the relocation rename: %v", statErr)
}
}
// openRecoveryHandle opens path the way promote holds the binary it moved
// aside: with delete access and no delete sharing, so the object cannot be
// substituted while the restore path works with it.
func openRecoveryHandle(t *testing.T, path string) *os.File {
t.Helper()
file, err := openRecoveryCopy(path)
if err != nil {
t.Fatalf("openRecoveryCopy %s: %v", path, err)
}
t.Cleanup(func() { _ = file.Close() })
return file
}
// openWithoutSharing opens an existing file denying every share mode, so a
// rename onto it fails the way a principal squatting the executable path does.
func openWithoutSharing(path string) (*os.File, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathPtr,
windows.GENERIC_READ,
0,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(handle), path), nil
}
+225
View File
@@ -0,0 +1,225 @@
//go:build !windows
package update
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
const stagingDirPrefix = ".zero-stage-"
const stagingDirectoryCreateAttempts = 100
// openStagingDirectory is a test seam for the creation-to-open race.
var openStagingDirectory = func(parentFD int, name string) (int, error) {
return unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
}
// createStagedBinary creates a private directory next to targetPath, binds that
// directory and its parent to descriptors, and creates the staged file through
// the directory descriptor. No later staging operation re-walks the directory
// pathname.
func createStagedBinary(targetPath string) (*stagedBinary, error) {
parentPath := filepath.Dir(targetPath)
parentHandle, err := os.Open(parentPath)
if err != nil {
return nil, fmt.Errorf("open staging parent: %w", err)
}
dirName, createdStat, err := createStagingDirectory(parentHandle)
if err != nil {
_ = parentHandle.Close()
return nil, fmt.Errorf("create staging directory: %w", err)
}
dir := filepath.Join(parentPath, dirName)
dirHandle, err := openAndVerifyStagingDirectory(parentHandle, dirName, createdStat)
if err != nil {
removeStagingDirectoryIfSame(parentHandle, dirName, createdStat)
_ = parentHandle.Close()
return nil, fmt.Errorf("open staging directory: %w", err)
}
path := filepath.Join(dir, filepath.Base(targetPath))
file, err := createStagingFileAt(dirHandle, filepath.Base(path), path)
if err != nil {
(&stagedBinary{
path: path,
dir: dir,
dirHandle: dirHandle,
parentHandle: parentHandle,
}).discardPaths()
return nil, err
}
return &stagedBinary{
file: file,
path: path,
dir: dir,
dirHandle: dirHandle,
parentHandle: parentHandle,
}, nil
}
// createStagingDirectory creates and stats the directory relative to the
// already-open installation directory. POSIX has no mkdir-and-open primitive,
// so openAndVerifyStagingDirectory additionally verifies that the entry still
// names this object and is a private directory owned by this effective user.
func createStagingDirectory(parent *os.File) (string, unix.Stat_t, error) {
for attempt := 0; attempt < stagingDirectoryCreateAttempts; attempt++ {
var random [16]byte
if _, err := rand.Read(random[:]); err != nil {
return "", unix.Stat_t{}, err
}
name := stagingDirPrefix + hex.EncodeToString(random[:])
if err := unix.Mkdirat(int(parent.Fd()), name, 0o700); err != nil {
if errors.Is(err, unix.EEXIST) {
continue
}
return "", unix.Stat_t{}, err
}
var created unix.Stat_t
if err := unix.Fstatat(int(parent.Fd()), name, &created, unix.AT_SYMLINK_NOFOLLOW); err != nil {
// Nothing else knows this name yet, so a failure here would otherwise
// leave an empty .zero-stage-* directory behind for good. Removing it
// with rmdir semantics is safe without a prior identity check: the name
// was minted from fresh randomness moments ago, and rmdir refuses
// anything that is not an empty directory, so the worst an entry
// substituted in that window can do is make this call fail.
_ = unix.Unlinkat(int(parent.Fd()), name, unix.AT_REMOVEDIR)
return "", unix.Stat_t{}, err
}
return name, created, nil
}
return "", unix.Stat_t{}, fmt.Errorf("could not allocate a unique staging directory")
}
func openAndVerifyStagingDirectory(parent *os.File, name string, createdStat unix.Stat_t) (*os.File, error) {
fd, err := openStagingDirectory(int(parent.Fd()), name)
if err != nil {
return nil, err
}
handle := os.NewFile(uintptr(fd), name)
var handleStat unix.Stat_t
if err := unix.Fstat(fd, &handleStat); err != nil {
_ = handle.Close()
return nil, err
}
if handleStat.Dev != createdStat.Dev ||
handleStat.Ino != createdStat.Ino ||
handleStat.Mode&unix.S_IFMT != unix.S_IFDIR ||
handleStat.Mode&0o777 != 0o700 ||
int(handleStat.Uid) != os.Geteuid() {
_ = handle.Close()
return nil, fmt.Errorf("staging directory entry was replaced before it could be bound")
}
return handle, nil
}
// removeStagingDirectoryIfSame removes only the empty directory entry observed
// after creation. It never follows the entry or recursively removes contents;
// a substituted or non-empty directory is preserved.
func removeStagingDirectoryIfSame(parent *os.File, name string, createdStat unix.Stat_t) {
var current unix.Stat_t
if err := unix.Fstatat(int(parent.Fd()), name, &current, unix.AT_SYMLINK_NOFOLLOW); err != nil {
return
}
if current.Dev != createdStat.Dev || current.Ino != createdStat.Ino {
return
}
_ = unix.Unlinkat(int(parent.Fd()), name, unix.AT_REMOVEDIR)
}
// createStagingFileAt creates name inside the directory dir is bound to.
// O_EXCL refuses any pre-existing entry — including a dangling symlink, which
// POSIX guarantees is not resolved — and O_NOFOLLOW refuses a symlink at the
// final component, so neither can redirect the verified bytes elsewhere.
func createStagingFileAt(dir *os.File, name string, displayPath string) (*os.File, error) {
fd, err := unix.Openat(
int(dir.Fd()),
name,
unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW,
0o755,
)
if err != nil {
return nil, fmt.Errorf("create %s: %w", displayPath, err)
}
return os.NewFile(uintptr(fd), displayPath), nil
}
func (staged *stagedBinary) promote(targetPath string) error {
if err := staged.file.Chmod(0o755); err != nil {
return err
}
if err := staged.verifyStagedIdentity(); err != nil {
return err
}
if err := unix.Renameat(
int(staged.dirHandle.Fd()),
filepath.Base(staged.path),
int(staged.parentHandle.Fd()),
filepath.Base(targetPath),
); err != nil {
return fmt.Errorf("rename staged binary onto %s: %w", targetPath, err)
}
staged.path = targetPath
staged.promoted = true
return nil
}
func (staged *stagedBinary) verifyStagedIdentity() error {
var handleStat unix.Stat_t
if err := unix.Fstat(int(staged.file.Fd()), &handleStat); err != nil {
return fmt.Errorf("stat staged binary: %w", err)
}
var childStat unix.Stat_t
if err := unix.Fstatat(
int(staged.dirHandle.Fd()),
filepath.Base(staged.path),
&childStat,
unix.AT_SYMLINK_NOFOLLOW,
); err != nil {
return fmt.Errorf("stat staged binary path %s: %w", staged.path, err)
}
if childStat.Ino != handleStat.Ino || childStat.Dev != handleStat.Dev {
return fmt.Errorf("staged binary %s was replaced after it was written", staged.path)
}
return nil
}
// discardOpenObject has nothing to do here: discardPaths already removes the
// staged child through the descriptor bound to the private staging directory,
// which no other principal can write, so there is no pathname handoff to close.
func (staged *stagedBinary) discardOpenObject() {}
// discardPaths removes the child through the bound directory descriptor and
// removes the directory only while its original parent entry still names it.
func (staged *stagedBinary) discardPaths() {
if staged.dirHandle != nil && !staged.promoted {
_ = unix.Unlinkat(int(staged.dirHandle.Fd()), filepath.Base(staged.path), 0)
}
if staged.dirHandle != nil {
var bound unix.Stat_t
var current unix.Stat_t
boundErr := unix.Fstat(int(staged.dirHandle.Fd()), &bound)
currentErr := unix.Fstatat(
int(staged.parentHandle.Fd()),
filepath.Base(staged.dir),
&current,
unix.AT_SYMLINK_NOFOLLOW,
)
_ = staged.dirHandle.Close()
if boundErr == nil &&
currentErr == nil &&
bound.Dev == current.Dev &&
bound.Ino == current.Ino {
_ = unix.Unlinkat(int(staged.parentHandle.Fd()), filepath.Base(staged.dir), unix.AT_REMOVEDIR)
}
}
if staged.parentHandle != nil {
_ = staged.parentHandle.Close()
}
}
+142
View File
@@ -0,0 +1,142 @@
//go:build !windows
package update
import (
"os"
"path/filepath"
"sync"
"testing"
)
// stageAtPath drives the production staging primitive — createStagingFileAt,
// the one createStagedBinary calls — against path by binding a descriptor to
// its parent first. The tests below go through it rather than a path-taking
// helper of their own so that weakening the real open flags fails them.
func stageAtPath(path string) (*os.File, error) {
parent, err := os.Open(filepath.Dir(path))
if err != nil {
return nil, err
}
defer func() { _ = parent.Close() }()
return createStagingFileAt(parent, filepath.Base(path), path)
}
// TestCreateStagingFileRefusesPrecreatedHardLink is the regression test for
// #742: a lower-privileged attacker who can write in the installation
// directory pre-creates the staging path as a hard link to another file the
// (possibly elevated) updater can write. Staging must fail instead of opening
// and truncating through that link.
func TestCreateStagingFileRefusesPrecreatedHardLink(t *testing.T) {
dir := t.TempDir()
victim := filepath.Join(dir, "victim")
if err := os.WriteFile(victim, []byte("do not touch"), 0o644); err != nil {
t.Fatalf("WriteFile victim: %v", err)
}
staged := filepath.Join(dir, "staged")
if err := os.Link(victim, staged); err != nil {
t.Fatalf("Link: %v", err)
}
if _, err := stageAtPath(staged); err == nil {
t.Fatal("staging succeeded through a pre-existing hard link, want error")
}
data, err := os.ReadFile(victim)
if err != nil {
t.Fatalf("ReadFile victim: %v", err)
}
if string(data) != "do not touch" {
t.Fatalf("victim content = %q, want unchanged", data)
}
}
// TestCreateStagingFileRefusesPrecreatedSymlink covers the reparse-point
// variant of the same #742 primitive: the staging path pre-created as a
// symlink to another writable file.
func TestCreateStagingFileRefusesPrecreatedSymlink(t *testing.T) {
dir := t.TempDir()
victim := filepath.Join(dir, "victim")
if err := os.WriteFile(victim, []byte("do not touch"), 0o644); err != nil {
t.Fatalf("WriteFile victim: %v", err)
}
staged := filepath.Join(dir, "staged")
if err := os.Symlink(victim, staged); err != nil {
t.Fatalf("Symlink: %v", err)
}
if _, err := stageAtPath(staged); err == nil {
t.Fatal("staging succeeded through a pre-existing symlink, want error")
}
data, err := os.ReadFile(victim)
if err != nil {
t.Fatalf("ReadFile victim: %v", err)
}
if string(data) != "do not touch" {
t.Fatalf("victim content = %q, want unchanged", data)
}
}
// TestCreateStagingFileSucceedsForFreshPath is the control: a path with
// nothing pre-existing must still work normally.
func TestCreateStagingFileSucceedsForFreshPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "staged")
file, err := stageAtPath(path)
if err != nil {
t.Fatalf("stageAtPath: %v", err)
}
if _, err := file.WriteString("payload"); err != nil {
t.Fatalf("WriteString: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != "payload" {
t.Fatalf("content = %q, want %q", data, "payload")
}
}
// TestCreateStagingFileConcurrentRaceOnlyOneWinner exercises a race on a
// single fixed path (installBinary normally avoids this by randomizing the
// name, but the exclusive-creation guarantee must hold regardless): exactly
// one concurrent caller may create the file, and the rest must fail cleanly
// rather than silently truncate the winner's content.
func TestCreateStagingFileConcurrentRaceOnlyOneWinner(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "staged")
const attempts = 16
var wg sync.WaitGroup
successes := make([]bool, attempts)
for i := range attempts {
wg.Add(1)
go func(i int) {
defer wg.Done()
file, err := stageAtPath(path)
if err != nil {
return
}
defer func() { _ = file.Close() }()
successes[i] = true
}(i)
}
wg.Wait()
winners := 0
for _, ok := range successes {
if ok {
winners++
}
}
if winners != 1 {
t.Fatalf("concurrent staging winners = %d, want exactly 1", winners)
}
}
+235
View File
@@ -0,0 +1,235 @@
//go:build !windows
package update
import (
"errors"
"os"
"path/filepath"
"testing"
)
// TestPromoteRefusesASubstitutedStagingEntry is the regression test for the live
// handoff half of #742: randomizing the staging name and creating it exclusively
// stops PRE-creation, but not substitution after the verified bytes are written.
// POSIX cannot rename by descriptor, so staging happens inside a private 0700
// directory the attacker cannot write to, and promote additionally verifies the
// entry still names the object it wrote before renaming it into place.
func TestPromoteRefusesASubstitutedStagingEntry(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
staged, err := stageBinary(sourcePath, targetPath)
if err != nil {
t.Fatalf("stageBinary: %v", err)
}
defer staged.discard()
// First line of defence: the staging directory is private, so a principal who
// can write in the installation directory cannot reach the entry at all.
info, err := os.Stat(staged.dir)
if err != nil {
t.Fatalf("Stat staging directory: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o700 {
t.Fatalf("staging directory mode = %#o, want 0700", perm)
}
// Second line of defence: rehearse the substitution anyway (the test runs as
// the directory's owner, so it can do what an attacker cannot) and require
// promote to refuse instead of installing the substitute.
substitute := filepath.Join(staged.dir, "substitute")
if err := os.WriteFile(substitute, []byte("attacker-binary"), 0o755); err != nil {
t.Fatalf("WriteFile substitute: %v", err)
}
if err := os.Rename(substitute, staged.path); err != nil {
t.Fatalf("Rename substitute over the staging entry: %v", err)
}
if err := staged.promote(targetPath); err == nil {
t.Fatal("promote installed a substituted staging entry, want a refusal")
}
installed, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile target: %v", err)
}
if string(installed) != "old-binary" {
t.Fatalf("target = %q, want the original binary left in place", installed)
}
}
// TestPromoteSurvivesAncestorDirectoryReplacement is the regression test for a
// review finding on PR #751: the private staging directory's 0700 mode
// protects its CONTENTS from a principal who can write in the installation
// directory, but not its own directory ENTRY — that principal can still
// rename the staging directory itself out of the way and recreate a
// look-alike at the same path, with an attacker file at the same basename,
// in the gap between verifyStagedIdentity returning and the final rename
// running. A plain pathname rename would re-resolve through the impostor at
// that point; promote must instead stay bound to the exact directory whose
// identity was already checked, via the directory descriptor opened when the
// staging directory was created.
func TestPromoteSurvivesAncestorDirectoryReplacement(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
staged, err := stageBinary(sourcePath, targetPath)
if err != nil {
t.Fatalf("stageBinary: %v", err)
}
discarded := false
defer func() {
if !discarded {
staged.discard()
}
}()
// Rehearse the ancestor swap: move the real staging directory aside (the
// test runs as its owner, so it can do what a merely writable-parent
// attacker — who only needs rename rights on dir, not on the staging
// directory's own contents — can also do), then recreate a look-alike at
// the original path with an attacker file at the same basename.
base := filepath.Base(staged.path)
movedDir := staged.dir + "-moved"
if err := os.Rename(staged.dir, movedDir); err != nil {
t.Fatalf("Rename staging directory aside: %v", err)
}
if err := os.Mkdir(staged.dir, 0o700); err != nil {
t.Fatalf("Mkdir impostor staging directory: %v", err)
}
impostorFile := filepath.Join(staged.dir, base)
if err := os.WriteFile(impostorFile, []byte("attacker-binary"), 0o755); err != nil {
t.Fatalf("WriteFile impostor file: %v", err)
}
if err := staged.promote(targetPath); err != nil {
t.Fatalf("promote: %v", err)
}
installed, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile target: %v", err)
}
if string(installed) != "verified-binary" {
t.Fatalf("target = %q, want the verified bytes from the original staging directory", installed)
}
staged.discard()
discarded = true
// The impostor must be left untouched: promote should never have looked at
// it, let alone consumed or removed it during deferred cleanup.
if impostor, err := os.ReadFile(impostorFile); err != nil {
t.Fatalf("ReadFile impostor file: %v", err)
} else if string(impostor) != "attacker-binary" {
t.Fatalf("impostor file = %q, want it left untouched", impostor)
}
}
func TestCreateStagedBinaryRejectsDirectoryReplacementBeforeOpen(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero")
var impostorMarker string
original := openStagingDirectory
openStagingDirectory = func(parentFD int, name string) (int, error) {
path := filepath.Join(dir, name)
if err := os.Rename(path, path+"-original"); err != nil {
t.Fatalf("Rename original staging directory: %v", err)
}
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatalf("Mkdir impostor: %v", err)
}
impostorMarker = filepath.Join(path, "keep")
if err := os.WriteFile(impostorMarker, []byte("attacker"), 0o600); err != nil {
t.Fatalf("WriteFile impostor marker: %v", err)
}
return original(parentFD, name)
}
defer func() { openStagingDirectory = original }()
if staged, err := createStagedBinary(targetPath); err == nil {
staged.discard()
t.Fatal("createStagedBinary accepted a directory replaced before open")
}
if marker, err := os.ReadFile(impostorMarker); err != nil {
t.Fatalf("substituted directory was removed during failed creation: %v", err)
} else if string(marker) != "attacker" {
t.Fatalf("substituted directory marker = %q, want it preserved", marker)
}
}
func TestCreateStagedBinaryCleansUpAfterDirectoryOpenFailure(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero")
original := openStagingDirectory
openStagingDirectory = func(parentFD int, name string) (int, error) {
return -1, errors.New("forced open failure")
}
defer func() { openStagingDirectory = original }()
if staged, err := createStagedBinary(targetPath); err == nil {
staged.discard()
t.Fatal("createStagedBinary succeeded despite a forced directory-open failure")
}
assertNoStagingLeftovers(t, dir)
}
// TestInstallBinaryInstallsVerifiedBytes is the success control: the ordinary
// path must still install the staged bytes, executable, with nothing left over.
func TestInstallBinaryInstallsVerifiedBytes(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
if err := installBinary(sourcePath, targetPath); err != nil {
t.Fatalf("installBinary: %v", err)
}
installed, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile installed: %v", err)
}
if string(installed) != "verified-binary" {
t.Fatalf("installed binary = %q, want the verified bytes", installed)
}
info, err := os.Stat(targetPath)
if err != nil {
t.Fatalf("Stat installed: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Fatalf("installed binary mode = %#o, want the executable bit set", info.Mode().Perm())
}
assertNoStagingLeftovers(t, dir)
}
// TestInstallBinaryCleansUpWhenStagingFails covers the cleanup ordering: a
// failure after the staging object exists must not leave it behind, because each
// attempt now uses a fresh random name that the next attempt never reuses.
func TestInstallBinaryCleansUpWhenStagingFails(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := installBinary(filepath.Join(t.TempDir(), "missing-source"), targetPath); err == nil {
t.Fatal("installBinary with an unreadable source succeeded, want error")
}
assertNoStagingLeftovers(t, dir)
}
@@ -0,0 +1,910 @@
//go:build windows
package update
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestMain(m *testing.M) {
stateDir, err := os.MkdirTemp("", "zero-update-recovery-test-")
if err != nil {
fmt.Fprintf(os.Stderr, "create updater test state directory: %v\n", err)
os.Exit(1)
}
recoveryCleanupStateDir = func() (string, error) { return stateDir, nil }
code := m.Run()
_ = os.RemoveAll(stateDir)
os.Exit(code)
}
// TestPromoteInstallsTheStagedObjectNotTheStagedPath is the regression test for
// the live handoff half of #742: randomizing the staging name and creating it
// exclusively stops PRE-creation, but not substitution after the verified bytes
// are written. Windows renames through the staging HANDLE, so a substituted entry
// at the staging pathname is simply not what gets promoted.
func TestPromoteInstallsTheStagedObjectNotTheStagedPath(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
staged, err := stageBinary(sourcePath, targetPath)
if err != nil {
t.Fatalf("stageBinary: %v", err)
}
discarded := false
defer func() {
if !discarded {
staged.discard()
}
}()
// Rehearse the strongest form of the substitution: the staging entry is
// replaced wholesale between the write and the swap. (A real attacker also has
// to get past the exclusive share mode this handle holds; the test does not,
// which only makes the check stricter.)
substituted := false
if err := os.Remove(staged.path); err == nil {
if err := os.WriteFile(staged.path, []byte("attacker-binary"), 0o755); err != nil {
t.Fatalf("WriteFile substituted staging entry: %v", err)
}
substituted = true
}
promoteErr := staged.promote(targetPath)
// The staging handle keeps the promoted (or, on failure, discarded) file open
// with an exclusive share mode, so release it before reading installed bytes
// (installBinary's deferred discard does the same).
staged.discard()
discarded = true
if promoteErr != nil {
// Fully unlinking the staging file (its directory entry removed, then a
// new file recreated at that name) is not something every Windows build
// honors a same-handle rename back into: verifyPromotedTarget's post-
// rename identity check can find nothing at targetPath and fail the
// promotion, which restoreOriginalBinary then recovers from by moving
// the pre-update binary back. That is this test's real security
// property holding — the attacker's substituted bytes are never
// installed — just via the fail-closed path instead of the handle-
// rename defeating the substitution outright. Only accept the failure
// when it actually happened via a real substitution; any other promote
// error is a genuine regression.
if !substituted {
t.Fatalf("promote failed without substitution: %v", promoteErr)
}
if installed, readErr := os.ReadFile(targetPath); readErr == nil && string(installed) == "attacker-binary" {
t.Fatalf("attacker-controlled bytes were installed: %q", installed)
}
t.Logf("promote refused after full-unlink substitution (%v); attacker bytes were not installed", promoteErr)
return
}
installed, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile installed: %v", err)
}
if string(installed) != "verified-binary" {
t.Fatalf("installed binary = %q, want the verified bytes", installed)
}
if !substituted {
t.Log("the staging entry could not be replaced (exclusive share mode); the handle-bound rename was still exercised")
}
}
// TestPromoteRejectsALyingRenameByHandle is the regression test for a review
// finding on PR #751: SetFileInformationByHandle reporting success is not, on
// its own, proof the object actually ended up at targetPath. Some Windows
// versions have been observed accepting the rename call against a handle
// whose directory entry was substituted out from under it without the object
// actually moving — this simulates that by stubbing the rename to lie, since
// the real trigger condition is Windows-version-specific and not reliably
// reproducible on demand. Without verifyPromotedTarget, promote would return
// nil while targetPath silently ends up missing, reporting a successful
// update that actually stranded the user without an executable at all.
func TestPromoteRejectsALyingRenameByHandle(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
staged, err := stageBinary(sourcePath, targetPath)
if err != nil {
t.Fatalf("stageBinary: %v", err)
}
defer staged.discard()
original := renameFileByHandle
renameFileByHandle = func(file *os.File, target string) error {
return nil // lie: report success without touching anything
}
defer func() { renameFileByHandle = original }()
promoteErr := staged.promote(targetPath)
if promoteErr == nil {
t.Fatal("promote reported success for a rename that never actually happened, want an error")
}
if !strings.Contains(promoteErr.Error(), "unreachable") {
t.Fatalf("error = %q, want it to explain the target is unreachable", promoteErr.Error())
}
installed, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile target: %v", err)
}
if string(installed) != "old-binary" {
t.Fatalf("target = %q, want the original binary restored", installed)
}
if _, err := os.Stat(targetPath + ".old"); !os.IsNotExist(err) {
t.Fatalf(".old leftover survived a successful restore: %v", err)
}
}
// A promotion failure followed by a blocked restore is security-relevant all
// the way through installBinary; its contextual wrappers must not erase the
// sentinel that callers of Apply use to distinguish possible path tampering.
func TestInstallBinaryPreservesPossibleTamperingError(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
original := renameFileByHandle
var conflicting windows.Handle
renameFileByHandle = func(_ *os.File, target string) error {
pathPtr, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
conflicting, err = windows.CreateFile(pathPtr, windows.GENERIC_WRITE, 0, nil, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
return fmt.Errorf("create conflicting target: %w", err)
}
return errors.New("injected promotion failure")
}
t.Cleanup(func() {
renameFileByHandle = original
if conflicting != 0 {
_ = windows.CloseHandle(conflicting)
}
})
originalMark := markOldBinaryPreserved
markOldBinaryPreserved = func(string) error { return errors.New("injected marker failure") }
t.Cleanup(func() { markOldBinaryPreserved = originalMark })
const suffix = "deadbeefdeadbeefdeadbeefdeadbeef"
stubRandomStagingSuffix(t, suffix)
err := installBinary(sourcePath, targetPath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("installBinary error = %v, want it to wrap ErrTargetPossiblyTampered", err)
}
if strings.Contains(err.Error(), "original preserved at "+targetPath+".old") {
t.Fatalf("installBinary error falsely claims the relocated copy remains at .old: %v", err)
}
expectedRecovery := targetPath + ".zero-update-" + suffix + ".old." + suffix + ".recovery"
if !strings.Contains(err.Error(), expectedRecovery) {
t.Fatalf("installBinary error = %v, want the authoritative relocated recovery path", err)
}
}
// TestPromoteRefusesWhileRecoveryCopyIsMarked covers jatmn's #751 P1: skipping
// the pre-rename cleanup was not enough to protect a marked recovery copy.
// 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 if this promotion then failed,
// restoreOriginalBinary could only move those unverified bytes back.
func TestPromoteRefusesWhileRecoveryCopyIsMarked(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
oldPath := targetPath + ".old"
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile old binary: %v", err)
}
if err := markOldBinaryPreserved(oldPath); err != nil {
t.Fatalf("markOldBinaryPreserved: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
err := installBinary(sourcePath, targetPath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("installBinary error = %v, want a refusal wrapping ErrTargetPossiblyTampered", err)
}
// The whole point: the known-good copy is still there afterwards.
got, readErr := os.ReadFile(oldPath)
if readErr != nil {
t.Fatalf("recovery copy was destroyed by the retry: %v", readErr)
}
if string(got) != "known-good" {
t.Fatalf("recovery copy = %q, want the last verified binary", got)
}
// And the error names both moves that end the state.
for _, want := range []string{oldPath, targetPath, oldPath + oldBinaryPreservedSuffix} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want it to name %q", err, want)
}
}
// Clearing the marker is the operator accepting the installed binary; the
// next promotion proceeds normally without destroying the recovery copy.
clearOldBinaryPreserved(oldPath)
stubRandomStagingSuffix(t, "deadbeef")
if err := installBinary(sourcePath, targetPath); err != nil {
t.Fatalf("installBinary after the operator cleared the marker: %v", err)
}
if data, err := os.ReadFile(targetPath); err != nil || string(data) != "verified-binary" {
t.Fatalf("target = %q err=%v, want the verified bytes installed", data, err)
}
if data, err := os.ReadFile(oldPath); err != nil || string(data) != "known-good" {
t.Fatalf("recovery copy = %q err=%v, want known-good after retry", data, err)
}
}
func TestPromoteRefusesRetryAfterInterruptedAside(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
oldPath := targetPath + ".old"
if err := os.WriteFile(oldPath, []byte("known-good"), 0o755); err != nil {
t.Fatalf("WriteFile recovery copy: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
err := installBinary(sourcePath, targetPath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
}
if _, err := os.Lstat(targetPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing target was unexpectedly created: %v", err)
}
if data, err := os.ReadFile(oldPath); err != nil || string(data) != "known-good" {
t.Fatalf("recovery copy = %q err=%v, want known-good", data, err)
}
}
func TestPromoteRefusesMarkedRandomAsideRecovery(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
canonicalOld := targetPath + ".old"
randomOld := targetPath + ".deadbeef.old"
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(canonicalOld, []byte("stale-older-binary"), 0o755); err != nil {
t.Fatalf("WriteFile canonical recovery: %v", err)
}
if err := os.WriteFile(randomOld, []byte("last-known-good"), 0o755); err != nil {
t.Fatalf("WriteFile random recovery: %v", err)
}
if err := markOldBinaryPreserved(randomOld); err != nil {
t.Fatalf("mark random recovery: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
err := installBinary(sourcePath, targetPath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
}
for _, want := range []string{randomOld, randomOld + oldBinaryPreservedSuffix} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want marked random recovery path %s", err, want)
}
}
if got, err := os.ReadFile(randomOld); err != nil || string(got) != "last-known-good" {
t.Fatalf("random recovery = %q err=%v, want last-known-good", got, err)
}
if got, err := os.ReadFile(targetPath); err != nil || string(got) != "unverified" {
t.Fatalf("target = %q err=%v, want refusal before promotion", got, err)
}
}
func TestPromoteRefusesAmbiguousRecoveryWhenTargetIsMissing(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
canonicalOld := targetPath + ".old"
randomOld := targetPath + ".deadbeef.old"
if err := os.WriteFile(canonicalOld, []byte("older-binary"), 0o755); err != nil {
t.Fatalf("WriteFile canonical recovery: %v", err)
}
if err := os.WriteFile(randomOld, []byte("last-running-binary"), 0o755); err != nil {
t.Fatalf("WriteFile random recovery: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
err := installBinary(sourcePath, targetPath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
}
for _, want := range []string{"ambiguous", canonicalOld, randomOld} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want %q", err, want)
}
}
if _, err := os.Lstat(targetPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing target was unexpectedly created: %v", err)
}
}
func TestVerifyPromotedTargetRejectsDifferentRegularFile(t *testing.T) {
dir := t.TempDir()
stagedPath := filepath.Join(dir, "staged.exe")
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(stagedPath, []byte("verified"), 0o755); err != nil {
t.Fatalf("WriteFile staged: %v", err)
}
if err := os.WriteFile(targetPath, []byte("attacker"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
staged, err := os.Open(stagedPath)
if err != nil {
t.Fatalf("Open staged: %v", err)
}
defer func() { _ = staged.Close() }()
if err := verifyPromotedTarget(staged, targetPath); err == nil {
t.Fatal("verifyPromotedTarget accepted a different regular file at targetPath")
}
}
func TestVerifyPromotedTargetRejectsAHardLinkedName(t *testing.T) {
dir := t.TempDir()
stagedPath := filepath.Join(dir, "staged.exe")
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(stagedPath, []byte("verified"), 0o755); err != nil {
t.Fatalf("WriteFile staged: %v", err)
}
if err := os.Link(stagedPath, targetPath); err != nil {
t.Fatalf("Link target: %v", err)
}
staged, err := os.Open(stagedPath)
if err != nil {
t.Fatalf("Open staged: %v", err)
}
defer func() { _ = staged.Close() }()
if err := verifyPromotedTarget(staged, targetPath); err == nil {
t.Fatal("verifyPromotedTarget accepted a second hard-linked name without a completed rename")
}
}
func TestInstallBinaryThroughReparsePointAncestor(t *testing.T) {
root := t.TempDir()
realDir := filepath.Join(root, "real")
if err := os.Mkdir(realDir, 0o755); err != nil {
t.Fatalf("Mkdir real install directory: %v", err)
}
linkedDir := filepath.Join(root, "linked")
if err := os.Symlink(realDir, linkedDir); err != nil {
t.Skipf("directory symlink unavailable: %v", err)
}
targetPath := filepath.Join(linkedDir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
if err := installBinary(sourcePath, targetPath); err != nil {
t.Fatalf("installBinary through reparse-point ancestor: %v", err)
}
installed, err := os.ReadFile(filepath.Join(realDir, "zero.exe"))
if err != nil {
t.Fatalf("ReadFile installed: %v", err)
}
if string(installed) != "verified-binary" {
t.Fatalf("installed binary = %q, want the verified bytes", installed)
}
}
// TestInstallBinaryInstallsVerifiedBytes is the success control for the ordinary
// path: the staged bytes land at the target, the running binary is preserved at
// an updater-owned recovery path, and no staging artifact survives.
func TestInstallBinaryInstallsVerifiedBytes(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-binary"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
if err := installBinary(sourcePath, targetPath); err != nil {
t.Fatalf("installBinary: %v", err)
}
installed, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile installed: %v", err)
}
if string(installed) != "verified-binary" {
t.Fatalf("installed binary = %q, want the verified bytes", installed)
}
recoveries, err := existingRecoveryPaths(targetPath)
if err != nil {
t.Fatalf("existingRecoveryPaths: %v", err)
}
if len(recoveries) != 1 || !strings.Contains(filepath.Base(recoveries[0]), ".zero-update-") {
t.Fatalf("recovery paths = %v, want one namespaced updater recovery", recoveries)
}
if old, err := os.ReadFile(recoveries[0]); err != nil {
t.Fatalf("the replaced binary must be preserved as the recovery copy: %v", err)
} else if string(old) != "old-binary" {
t.Fatalf("preserved binary = %q, want the previous one", old)
}
assertNoStagingLeftovers(t, dir)
}
func TestInstallBinaryPreservesArbitraryOldFilesDuringCleanup(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("version-0"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
manualBackup := targetPath + ".before-manual-patch.old"
if err := os.WriteFile(manualBackup, []byte("manual-backup"), 0o755); err != nil {
t.Fatalf("WriteFile manual backup: %v", err)
}
// Also cover a name that resembles the updater namespace but does not carry
// the exact 128-bit hexadecimal suffix generated by randomStagingSuffix.
lookalike := targetPath + ".zero-update-not-owned.old"
if err := os.WriteFile(lookalike, []byte("lookalike-backup"), 0o755); err != nil {
t.Fatalf("WriteFile lookalike backup: %v", err)
}
plantedNamespaced := targetPath + ".zero-update-00000000000000000000000000000000.old"
if err := os.WriteFile(plantedNamespaced, []byte("planted-namespaced-backup"), 0o755); err != nil {
t.Fatalf("WriteFile planted namespaced backup: %v", err)
}
for version := 1; version <= 3; version++ {
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte(fmt.Sprintf("version-%d", version)), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
if err := installBinary(sourcePath, targetPath); err != nil {
t.Fatalf("installBinary version %d: %v", version, err)
}
}
for path, want := range map[string]string{
manualBackup: "manual-backup",
lookalike: "lookalike-backup",
plantedNamespaced: "planted-namespaced-backup",
} {
if got, err := os.ReadFile(path); err != nil || string(got) != want {
t.Fatalf("backup %s = %q err=%v, want %q", path, got, err, want)
}
}
}
// TestRecoveryCleanupRefusesSubstitutedAside pins the provenance rule that
// makes handle-bound cleanup safe: a recorded recovery path whose object has
// been swapped out underneath the record is never opened as a cleanup
// candidate, so the substitute is not deleted on the next promotion.
func TestRecoveryCleanupRefusesSubstitutedAside(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
recoveryPath := targetPath + ".zero-update-0123456789abcdef0123456789abcdef.old"
if err := os.WriteFile(recoveryPath, []byte("moved-aside-binary"), 0o755); err != nil {
t.Fatalf("WriteFile recovery: %v", err)
}
original, err := openRecoveryCopy(recoveryPath)
if err != nil {
t.Fatalf("openRecoveryCopy: %v", err)
}
identity, err := recoveryFileIdentity(original)
_ = original.Close()
if err != nil {
t.Fatalf("recoveryFileIdentity: %v", err)
}
if err := appendRecoveryCleanupRecord(targetPath, recoveryPath, identity); err != nil {
t.Fatalf("appendRecoveryCleanupRecord: %v", err)
}
if err := os.Rename(recoveryPath, recoveryPath+".displaced"); err != nil {
t.Fatalf("Rename recovery: %v", err)
}
if err := os.WriteFile(recoveryPath, []byte("substituted-file"), 0o755); err != nil {
t.Fatalf("WriteFile substitute: %v", err)
}
candidates := prepareRecoveryCleanup(targetPath)
if len(candidates) != 0 {
t.Fatalf("cleanup candidates = %d, want the substituted aside to be refused", len(candidates))
}
cleanupSupersededRecoveryCopies(targetPath, candidates)
if got, err := os.ReadFile(recoveryPath); err != nil || string(got) != "substituted-file" {
t.Fatalf("substituted file = %q err=%v, want it left untouched", got, err)
}
}
// TestAppendRecoveryCleanupRecordRejectsForeignPath keeps the trusted record
// from ever vouching for a name this updater could not have created, which is
// what stops cleanup from deleting an operator's own backup.
func TestAppendRecoveryCleanupRecordRejectsForeignPath(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
foreign := targetPath + ".before-manual-patch.old"
if err := os.WriteFile(foreign, []byte("operator-backup"), 0o755); err != nil {
t.Fatalf("WriteFile backup: %v", err)
}
if err := appendRecoveryCleanupRecord(targetPath, foreign, recoveryIdentity{}); err == nil {
t.Fatal("appendRecoveryCleanupRecord accepted a path this updater never created")
}
recordPath, err := recoveryCleanupRecordPath(targetPath)
if err != nil {
t.Fatalf("recoveryCleanupRecordPath: %v", err)
}
if _, err := os.Lstat(recordPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("trusted cleanup record was written for a foreign backup: %v", err)
}
}
// TestRecoveryCleanupRetiresRecordsForVanishedCopies keeps the backlog that
// makes transient-lock retries possible from becoming its own unbounded growth:
// a record whose object an operator already removed can never become
// actionable, so it must not be carried forever.
func TestRecoveryCleanupRetiresRecordsForVanishedCopies(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("version-0"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
source := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(source, []byte("version-1"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
if err := installBinary(source, targetPath); err != nil {
t.Fatalf("installBinary: %v", err)
}
recoveries, err := existingRecoveryPaths(targetPath)
if err != nil || len(recoveries) != 1 {
t.Fatalf("recovery paths = %v err=%v, want exactly one", recoveries, err)
}
if got := recordedRecoveryCleanupCount(t, targetPath); got != 1 {
t.Fatalf("recorded cleanup entries = %d, want 1", got)
}
// The operator removes the recovery copy themselves.
if err := os.Remove(recoveries[0]); err != nil {
t.Fatalf("Remove recovery copy: %v", err)
}
closeRecoveryCleanupCandidates(prepareRecoveryCleanup(targetPath))
if got := recordedRecoveryCleanupCount(t, targetPath); got != 0 {
t.Fatalf("recorded cleanup entries = %d after the copy vanished, want the record retired", got)
}
}
func recordedRecoveryCleanupCount(t *testing.T, targetPath string) int {
t.Helper()
return len(loadRecoveryCleanupQueue(targetPath).Records)
}
func TestInstallBinaryBoundsRecoveryCopiesAcrossRepeatedUpgrades(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("version-0"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
for version := 1; version <= 4; version++ {
contents := fmt.Sprintf("version-%d", version)
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte(contents), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
if err := installBinary(sourcePath, targetPath); err != nil {
t.Fatalf("installBinary version %d: %v", version, err)
}
recoveries, err := existingRecoveryPaths(targetPath)
if err != nil {
t.Fatalf("existingRecoveryPaths: %v", err)
}
if len(recoveries) != 1 {
t.Fatalf("recovery count after version %d = %d (%v), want 1", version, len(recoveries), recoveries)
}
previous, err := os.ReadFile(recoveries[0])
if err != nil {
t.Fatalf("ReadFile recovery after version %d: %v", version, err)
}
wantPrevious := fmt.Sprintf("version-%d", version-1)
if string(previous) != wantPrevious {
t.Fatalf("recovery after version %d = %q, want %q", version, previous, wantPrevious)
}
}
}
func TestInstallBinaryRetainsLockedCleanupRecordUntilLaterRetry(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("version-0"), 0o755); err != nil {
t.Fatal(err)
}
install := func(version string) {
source := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(source, []byte(version), 0o755); err != nil {
t.Fatal(err)
}
if err := installBinary(source, targetPath); err != nil {
t.Fatalf("install %s: %v", version, err)
}
}
install("version-1")
recoveries, _ := existingRecoveryPaths(targetPath)
lockedPath, err := windows.UTF16PtrFromString(recoveries[0])
if err != nil {
t.Fatal(err)
}
locked, err := windows.CreateFile(lockedPath, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
t.Fatal(err)
}
install("version-2")
_ = windows.CloseHandle(locked)
install("version-3")
recoveries, _ = existingRecoveryPaths(targetPath)
if len(recoveries) != 1 {
t.Fatalf("recovery backlog after lock clears = %v, want only newest recovery", recoveries)
}
}
func TestPromoteRestoresOriginalObjectWhenAsidePathIsSubstituted(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("known-good"), 0o755); err != nil {
t.Fatal(err)
}
staged, err := createStagedBinary(targetPath)
if err != nil {
t.Fatal(err)
}
defer staged.discard()
if _, err := staged.file.WriteString("new"); err != nil {
t.Fatal(err)
}
originalRename := renameFileByHandle
originalRecoveryRename := renameRecoveryFileByHandle
var substitutionErr error
call := 0
renameRecoveryFileByHandle = func(file *os.File, path string) error {
call++
if err := originalRecoveryRename(file, path); err != nil {
return err
}
if call == 1 {
substitutionErr = os.Rename(path, path+".stolen")
}
return nil
}
renameFileByHandle = func(_ *os.File, _ string) error {
return errors.New("injected promotion failure")
}
defer func() {
renameFileByHandle = originalRename
renameRecoveryFileByHandle = originalRecoveryRename
}()
if err := staged.promote(targetPath); err == nil {
t.Fatal("promote succeeded despite injected failure")
}
contents, err := os.ReadFile(targetPath)
if err != nil || string(contents) != "known-good" {
t.Fatalf("restored target = %q, %v; want original object", contents, err)
}
if substitutionErr == nil {
t.Fatal("aside pathname substitution succeeded while recovery handle was retained")
}
}
func TestInstallBinaryRefusesRelocatedRecoveryCopy(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
recoveryPath := targetPath + ".old.deadbeef.recovery"
if err := os.WriteFile(recoveryPath, []byte("last-verified"), 0o755); err != nil {
t.Fatalf("WriteFile recovery: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-release"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
err := installBinary(sourcePath, targetPath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
}
if !strings.Contains(err.Error(), recoveryPath) {
t.Fatalf("installBinary error = %v, want recovery path %s", err, recoveryPath)
}
if got, readErr := os.ReadFile(targetPath); readErr != nil || string(got) != "unverified" {
t.Fatalf("target = %q err=%v, want unchanged unverified bytes", got, readErr)
}
if got, readErr := os.ReadFile(recoveryPath); readErr != nil || string(got) != "last-verified" {
t.Fatalf("recovery = %q err=%v, want last-verified", got, readErr)
}
}
func TestInstallBinaryRefusesRecoveryRelocatedFromRandomizedAside(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("unverified"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
recoveryPath := targetPath + ".aside.old.relocation.recovery"
if err := os.WriteFile(recoveryPath, []byte("last-verified"), 0o755); err != nil {
t.Fatalf("WriteFile recovery: %v", err)
}
sourcePath := filepath.Join(t.TempDir(), "new-binary")
if err := os.WriteFile(sourcePath, []byte("verified-release"), 0o755); err != nil {
t.Fatalf("WriteFile source: %v", err)
}
err := installBinary(sourcePath, targetPath)
if !errors.Is(err, ErrTargetPossiblyTampered) {
t.Fatalf("installBinary error = %v, want ErrTargetPossiblyTampered", err)
}
if got, readErr := os.ReadFile(recoveryPath); readErr != nil || string(got) != "last-verified" {
t.Fatalf("recovery = %q err=%v, want last-verified", got, readErr)
}
}
func TestPromotionLockSerializesSameTarget(t *testing.T) {
targetPath := filepath.Join(t.TempDir(), "zero.exe")
releaseFirst, err := acquirePromotionLock(targetPath)
if err != nil {
t.Fatalf("acquire first promotion lock: %v", err)
}
started := make(chan struct{})
acquired := make(chan struct{})
releaseSecond := make(chan struct{})
secondResult := make(chan error, 1)
go func() {
close(started)
release, err := acquirePromotionLock(strings.ToUpper(targetPath))
if err != nil {
secondResult <- err
return
}
close(acquired)
<-releaseSecond
release()
secondResult <- nil
}()
<-started
select {
case <-acquired:
releaseFirst()
close(releaseSecond)
<-secondResult
t.Fatal("second promotion acquired the same target lock before release")
case err := <-secondResult:
releaseFirst()
t.Fatalf("acquire second promotion lock: %v", err)
case <-time.After(100 * time.Millisecond):
}
releaseFirst()
select {
case <-acquired:
close(releaseSecond)
if err := <-secondResult; err != nil {
t.Fatalf("release second promotion lock: %v", err)
}
case err := <-secondResult:
t.Fatalf("acquire second promotion lock: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("second promotion did not acquire the target lock after release")
}
}
// TestInstallBinaryCleansUpWhenStagingFails covers the cleanup ordering: a
// failure after the staging file exists must not leave it behind, because each
// attempt now uses a fresh random name that the next attempt never reuses.
func TestInstallBinaryCleansUpWhenStagingFails(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := installBinary(filepath.Join(t.TempDir(), "missing-source"), targetPath); err == nil {
t.Fatal("installBinary with an unreadable source succeeded, want error")
}
assertNoStagingLeftovers(t, dir)
}
// TestDiscardDeletesTheStagedObjectThroughItsHandle pins that failure-path
// cleanup removes the object this updater staged rather than re-resolving the
// staging name after the handle is released.
func TestDiscardDeletesTheStagedObjectThroughItsHandle(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
staged, err := createStagedBinary(targetPath)
if err != nil {
t.Fatalf("createStagedBinary: %v", err)
}
stagingPath := staged.path
staged.discard()
if _, err := os.Lstat(stagingPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("staged object survived discard: %v", err)
}
assertNoStagingLeftovers(t, dir)
}
// TestDiscardLeavesASubstitutedStagingEntryAlone is the impostor-survival
// counterpart of the POSIX promote tests: cleanup is bound to the staged
// object, so an entry a principal who can write the installation directory
// plants at the staging name once the handle is gone is never deleted. The
// substitution is staged by hand here because the exclusive, no-share handle
// makes it impossible while the updater still holds the object.
func TestDiscardLeavesASubstitutedStagingEntryAlone(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
staged, err := createStagedBinary(targetPath)
if err != nil {
t.Fatalf("createStagedBinary: %v", err)
}
stagingPath := staged.path
// Run the handle-bound half of discard, then let the substitution win the
// race that a pathname-based removal would lose.
staged.discardOpenObject()
if err := staged.file.Close(); err != nil {
t.Fatalf("Close staged handle: %v", err)
}
const impostorContent = "attacker-owned file that cleanup must not delete"
if err := os.WriteFile(stagingPath, []byte(impostorContent), 0o600); err != nil {
t.Fatalf("WriteFile impostor: %v", err)
}
staged.discardPaths()
got, err := os.ReadFile(stagingPath)
if err != nil {
t.Fatalf("cleanup removed the substituted staging entry: %v", err)
}
if string(got) != impostorContent {
t.Fatalf("substituted entry = %q, want it left untouched", got)
}
}
@@ -0,0 +1,17 @@
//go:build windows
package update
import "testing"
// stubRandomStagingSuffix overrides randomStagingSuffix to always return a
// fixed value for the duration of t, restoring the original on cleanup.
// stagingFilePath's random suffix is unpredictable by design in production, so
// the Windows staging tests that need the exact path pin it here instead. POSIX
// stages inside a private directory and has no such name to pin.
func stubRandomStagingSuffix(t *testing.T, suffix string) {
t.Helper()
original := randomStagingSuffix
randomStagingSuffix = func() (string, error) { return suffix, nil }
t.Cleanup(func() { randomStagingSuffix = original })
}
+44
View File
@@ -0,0 +1,44 @@
package update
import (
"os"
"path/filepath"
"strings"
"testing"
)
// stubStageBinaryFailure makes stageBinary fail for targetPath (matched by base
// name, since installBinary is called with the real install path) and behave
// normally for everything else, restoring the original on cleanup.
//
// The staging location is created exclusively under an unpredictable name — a
// private directory on POSIX — so a test can no more occupy it than an attacker
// can. This seam is how a staging failure is exercised instead.
func stubStageBinaryFailure(t *testing.T, targetPath string, failure error) {
t.Helper()
original := stageBinary
want := filepath.Base(targetPath)
stageBinary = func(sourcePath string, target string) (*stagedBinary, error) {
if filepath.Base(target) == want {
return nil, failure
}
return original(sourcePath, target)
}
t.Cleanup(func() { stageBinary = original })
}
// assertNoStagingLeftovers fails when dir still holds a platform staging
// artifact. Checking both patterns is harmless and keeps this test helper
// consistent across POSIX and Windows.
func assertNoStagingLeftovers(t *testing.T, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir %s: %v", dir, err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".zero-stage-") || strings.HasSuffix(entry.Name(), ".new") {
t.Fatalf("staging leftover survived in the install directory: %s", entry.Name())
}
}
}
+498
View File
@@ -0,0 +1,498 @@
//go:build windows
package update
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
// createStagedBinary stages beside targetPath under an unpredictable name. Unlike
// POSIX this needs no private directory: Windows can rename an object through the
// very handle it was created with (see promote), so the staging pathname never has
// to be re-resolved and cannot be substituted.
func createStagedBinary(targetPath string) (*stagedBinary, error) {
path, err := stagingFilePath(targetPath)
if err != nil {
return nil, err
}
file, err := createStagingFile(path)
if err != nil {
return nil, err
}
return &stagedBinary{file: file, path: path}, nil
}
// createStagingFile creates path exclusively and without following any
// reparse point that may already occupy it. CREATE_NEW alone can still
// resolve through an existing reparse point (symlink/junction) when deciding
// whether the target exists — if that reparse point's target is a real file
// writable by this (possibly elevated) process, CreateFile would open and
// truncate it instead. FILE_FLAG_OPEN_REPARSE_POINT makes CreateFile operate
// on the reparse point itself, so CREATE_NEW fails on it exactly like it
// would fail on a pre-existing regular file or hard link.
//
// DELETE access is requested alongside GENERIC_WRITE because promote renames
// this object through the handle, which requires it.
func createStagingFile(path string) (*os.File, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathPtr,
windows.GENERIC_WRITE|windows.DELETE,
0,
nil,
windows.CREATE_NEW,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return nil, fmt.Errorf("create %s: %w", path, err)
}
file := os.NewFile(uintptr(handle), path)
if err := verifyFreshRegularFile(handle, path); err != nil {
// Delete through the handle rather than by pathname: the object this
// process just created is the only thing it may remove, and its name is
// writable by the threat principal the moment the handle is released.
_ = deleteFileByHandle(file)
_ = file.Close()
return nil, err
}
return file, nil
}
// verifyFreshRegularFile defends in depth against the handle unexpectedly
// referring to a reparse point, directory, or an object with other hard
// links: CREATE_NEW + FILE_FLAG_OPEN_REPARSE_POINT should already guarantee
// a brand-new regular file, but this catches any surprise before any of the
// verified release bytes are written through the handle.
func verifyFreshRegularFile(handle windows.Handle, path string) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("stat new staging file %s: %w", path, err)
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("staging file %s is unexpectedly a reparse point", path)
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
return fmt.Errorf("staging file %s is unexpectedly a directory", path)
}
if info.NumberOfLinks > 1 {
return fmt.Errorf("staging file %s unexpectedly has %d hard links", path, info.NumberOfLinks)
}
return nil
}
// promote makes the staged object the installed binary. Windows will not let a
// running executable be overwritten or deleted directly, but NTFS does allow
// renaming it aside — the same trick already used for locked config files in
// internal/cli/mcp_config.go's replaceMCPWritableConfigFile.
//
// The second rename goes through the staging HANDLE
// (SetFileInformationByHandle/FileRenameInfo) instead of the staging pathname.
// A pathname rename would re-resolve the staging entry, so a lower-privileged
// principal that can write in the installation directory could replace that entry
// after the verified bytes were written and have the updater install its file
// instead. Renaming the object the handle already refers to removes that handoff:
// there is no second lookup to win.
func (staged *stagedBinary) promote(targetPath string) error {
releasePromotionLock, err := acquirePromotionLock(targetPath)
if err != nil {
return fmt.Errorf("lock binary promotion: %w", err)
}
defer releasePromotionLock()
if err := preflightRecoveryStateLocked(targetPath); err != nil {
return err
}
// Always use a namespaced unpredictable aside path. Besides avoiding any
// existing recovery copy, this gives trusted cleanup state a narrow path
// format to validate instead of accepting arbitrary *.old files.
suffix, suffixErr := randomStagingSuffix()
if suffixErr != nil {
return fmt.Errorf("choose recovery path: %w", suffixErr)
}
asidePath := targetPath + ".zero-update-" + suffix + ".old"
cleanupCandidates := prepareRecoveryCleanup(targetPath)
cleanedCandidates := false
defer func() {
if !cleanedCandidates {
closeRecoveryCleanupCandidates(cleanupCandidates)
}
}()
original, openErr := openRecoveryCopy(targetPath)
if openErr != nil {
return fmt.Errorf("open running binary for recovery: %w", openErr)
}
defer func() { _ = original.Close() }()
originalIdentity, identityErr := recoveryFileIdentity(original)
if identityErr != nil {
return fmt.Errorf("capture running binary identity: %w", identityErr)
}
if err := renameRecoveryFileByHandle(original, asidePath); err != nil {
return fmt.Errorf("rename running binary aside: %w", err)
}
if err := verifyPromotedTarget(original, asidePath); err != nil {
return fmt.Errorf("verify running binary aside: %w", err)
}
renameErr := renameFileByHandle(staged.file, targetPath)
if renameErr == nil {
if verifyErr := verifyPromotedTarget(staged.file, targetPath); verifyErr != nil {
renameErr = fmt.Errorf("promoted object unreachable at %s: %w", targetPath, verifyErr)
}
}
if renameErr != nil {
if restoreErr := restoreOriginalBinary(original, asidePath, targetPath); restoreErr != nil {
return fmt.Errorf("install new binary: %v; additionally failed to restore the original binary: %w", renameErr, restoreErr)
}
return fmt.Errorf("install new binary: %w", renameErr)
}
if err := appendRecoveryCleanupRecord(targetPath, asidePath, originalIdentity); err == nil {
cleanupSupersededRecoveryCopies(targetPath, cleanupCandidates)
cleanedCandidates = true
}
staged.path = targetPath
staged.promoted = true
return nil
}
func preflightRecoveryState(targetPath string) error {
release, err := acquirePromotionLock(targetPath)
if err != nil {
return fmt.Errorf("lock binary recovery preflight: %w", err)
}
defer release()
return preflightRecoveryStateLocked(targetPath)
}
func preflightRecoveryStateLocked(targetPath string) error {
relocatedRecoveries, recoveryErr := relocatedRecoveryPaths(targetPath)
if recoveryErr != nil {
return fmt.Errorf("%w: inspect relocated recovery state for %s: %v", ErrTargetPossiblyTampered, targetPath, recoveryErr)
}
if len(relocatedRecoveries) != 0 {
return fmt.Errorf(
"%w: a previous update moved the last binary this updater verified to %s after recovery-marker creation failed; restore the correct recovery binary or remove it after verifying %s before updating again",
ErrTargetPossiblyTampered, strings.Join(relocatedRecoveries, ", "), targetPath,
)
}
// Refuse to promote while a previous failure left its recovery copy in place.
//
// Skipping the cleanup below is not enough to protect it: os.Rename uses
// MOVEFILE_REPLACE_EXISTING on Windows, so renaming the running binary aside
// would overwrite the last verified copy with whatever now occupies
// targetPath — the very bytes the earlier failure could not verify. If this
// promotion then failed, restoreOriginalBinary could only move those
// unverified bytes back, and the known-good binary would be gone.
//
// Automation cannot resolve this safely on the operator's behalf: only they
// can say whether the file at targetPath is theirs. So this fails closed and
// says exactly which two moves end the state.
markedRecoveries, recoveryErr := markedRecoveryPaths(targetPath)
if recoveryErr != nil {
return fmt.Errorf("%w: inspect previous recovery state for %s: %v", ErrTargetPossiblyTampered, targetPath, recoveryErr)
}
if len(markedRecoveries) != 0 {
recoveryPaths := make([]string, 0, len(markedRecoveries))
markerPaths := make([]string, 0, len(markedRecoveries))
for _, recoveryPath := range markedRecoveries {
recoveryPaths = append(recoveryPaths, recoveryPath)
markerPaths = append(markerPaths, recoveryPath+oldBinaryPreservedSuffix)
}
return fmt.Errorf(
"%w: a previous update could not restore the original binary. Recovery path(s) %s may hold the last binary this updater verified and %s may hold unverified content. "+
"Move the correct recovery binary back over %s to restore it, or delete its marker (%s) to accept the installed binary, then update again",
ErrTargetPossiblyTampered, strings.Join(recoveryPaths, ", "), targetPath,
targetPath, strings.Join(markerPaths, ", "),
)
}
if _, targetErr := os.Lstat(targetPath); errors.Is(targetErr, os.ErrNotExist) {
recoveryPaths, err := existingRecoveryPaths(targetPath)
if err != nil {
return fmt.Errorf("%w: inspect recovery copies for missing %s: %v", ErrTargetPossiblyTampered, targetPath, err)
}
switch len(recoveryPaths) {
case 1:
return fmt.Errorf(
"%w: %s is missing and %s may be the only recoverable binary; move it back before updating again",
ErrTargetPossiblyTampered, targetPath, recoveryPaths[0],
)
case 0:
// Let the ordinary aside rename below report the missing target.
default:
return fmt.Errorf(
"%w: %s is missing and multiple recovery binaries exist (%s); resolve the ambiguous recovery state before updating again",
ErrTargetPossiblyTampered, targetPath, strings.Join(recoveryPaths, ", "),
)
}
}
return nil
}
// acquirePromotionLock serializes the full target-specific recovery transaction
// across updater processes. Without it, one updater could capture another's
// unmarked aside while that process is still trying to restore or mark it.
// Windows mutex ownership is thread-affine, so the caller remains pinned until
// the returned release function runs.
func acquirePromotionLock(targetPath string) (func(), error) {
absolutePath, err := filepath.Abs(targetPath)
if err != nil {
return nil, err
}
digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(absolutePath))))
name, err := windows.UTF16PtrFromString(fmt.Sprintf("Local\\zero-update-%x", digest))
if err != nil {
return nil, err
}
runtime.LockOSThread()
handle, createErr := windows.CreateMutex(nil, false, name)
if createErr != nil && !errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) {
runtime.UnlockOSThread()
return nil, createErr
}
if handle == 0 {
runtime.UnlockOSThread()
return nil, fmt.Errorf("create target mutex returned an invalid handle")
}
event, waitErr := windows.WaitForSingleObject(handle, windows.INFINITE)
if waitErr != nil || event != windows.WAIT_OBJECT_0 && event != windows.WAIT_ABANDONED {
_ = windows.CloseHandle(handle)
runtime.UnlockOSThread()
if waitErr != nil {
return nil, waitErr
}
return nil, fmt.Errorf("wait for target mutex returned %#x", event)
}
return func() {
_ = windows.ReleaseMutex(handle)
_ = windows.CloseHandle(handle)
runtime.UnlockOSThread()
}, nil
}
// existingRecoveryPaths returns every canonical or randomized aside path that
// could hold a binary moved away from targetPath by a previous promotion. The
// directory is attacker-writable under the threat model, so these names are
// only reasons to fail closed; their presence is never proof of file contents.
func existingRecoveryPaths(targetPath string) ([]string, error) {
dir := filepath.Dir(targetPath)
base := filepath.Base(targetPath)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
// NTFS is case-insensitive (case-preserving), so a recovery file can exist
// on disk as e.g. "zero.exe.OLD" — fold both sides before matching, or it
// silently drops out of every caller's fail-closed check below.
lowerBase := strings.ToLower(base)
var paths []string
for _, entry := range entries {
name := entry.Name()
lowerName := strings.ToLower(name)
if lowerName == lowerBase+".old" ||
(strings.HasPrefix(lowerName, lowerBase+".") &&
strings.HasSuffix(lowerName, ".old") &&
len(lowerName) > len(lowerBase)+len("..old")) {
paths = append(paths, filepath.Join(dir, name))
}
}
return paths, nil
}
// relocatedRecoveryPaths returns copies moved to the distinct recovery name
// after a failed restore could not establish a .keep marker. Unlike ordinary
// unmarked .old files, these are authoritative unresolved recovery state and
// must block every later promotion until the operator resolves them.
func relocatedRecoveryPaths(targetPath string) ([]string, error) {
dir := filepath.Dir(targetPath)
base := strings.ToLower(filepath.Base(targetPath))
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
prefix := base + "."
var paths []string
for _, entry := range entries {
name := entry.Name()
lowerName := strings.ToLower(name)
if !strings.HasPrefix(lowerName, prefix) || !strings.HasSuffix(lowerName, ".recovery") {
continue
}
// The canonical relocation is <target>.old.<suffix>.recovery.
// A failed promotion that already used a randomized aside can also
// produce <target>.<aside-suffix>.old.<recovery-suffix>.recovery.
middle := strings.TrimSuffix(strings.TrimPrefix(lowerName, prefix), ".recovery")
if strings.HasPrefix(middle, "old.") && len(middle) > len("old.") ||
strings.Contains(middle, ".old.") {
paths = append(paths, filepath.Join(dir, name))
}
}
return paths, nil
}
// markedRecoveryPaths finds recovery copies protected by either the canonical
// marker or a marker beside a randomized aside path. A failed second-or-later
// update commonly uses the latter because the canonical .old already exists.
func markedRecoveryPaths(targetPath string) ([]string, error) {
recoveryPaths, err := existingRecoveryPaths(targetPath)
if err != nil {
return nil, err
}
var marked []string
for _, recoveryPath := range recoveryPaths {
if oldBinaryPreserved(recoveryPath) {
marked = append(marked, recoveryPath)
}
}
return marked, nil
}
// verifyPromotedTarget reports whether targetPath names the same object as the
// staged handle after a reported-successful rename. SetFileInformationByHandle
// returning success is not, on its own, proof the object ended up there: a
// handle whose directory entry was removed and replaced out from under it
// (the substitution createStagingFile's exclusive, no-share open defends
// against — see its doc comment) can leave some Windows versions accepting
// the rename call without it taking effect, which would otherwise let
// promote report success while targetPath is left missing entirely.
//
// A metadata-only open is not blocked by staged.file's exclusive data/delete
// sharing. Comparing the volume and file index is independent of path spelling,
// including 8.3 names, case, UNC prefixes, and reparse points in ancestors.
func verifyPromotedTarget(file *os.File, targetPath string) error {
targetPathPtr, err := windows.UTF16PtrFromString(targetPath)
if err != nil {
return err
}
targetHandle, err := windows.CreateFile(
targetPathPtr,
0,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return fmt.Errorf("open promoted target metadata: %w", err)
}
defer func() { _ = windows.CloseHandle(targetHandle) }()
var stagedInfo windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &stagedInfo); err != nil {
return fmt.Errorf("query staged object identity: %w", err)
}
var targetInfo windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(targetHandle, &targetInfo); err != nil {
return fmt.Errorf("query promoted target identity: %w", err)
}
if targetInfo.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("promoted target is unexpectedly a reparse point")
}
if targetInfo.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
return fmt.Errorf("promoted target is unexpectedly a directory")
}
if stagedInfo.NumberOfLinks != 1 || targetInfo.NumberOfLinks != 1 {
return fmt.Errorf("promoted object unexpectedly has %d hard links", targetInfo.NumberOfLinks)
}
if stagedInfo.VolumeSerialNumber != targetInfo.VolumeSerialNumber ||
stagedInfo.FileIndexHigh != targetInfo.FileIndexHigh ||
stagedInfo.FileIndexLow != targetInfo.FileIndexLow {
return fmt.Errorf("target path does not name the staged object")
}
return nil
}
// discardOpenObject schedules the staged object for deletion through the handle
// it was created with, so failure-path cleanup removes exactly the object this
// updater staged. The pathname alternative (os.Remove after the handle is
// closed) re-resolves the staging entry, and a principal who can write in the
// installation directory can substitute that entry in the gap — the same
// handoff createStagingFile and promote are built to avoid. Deletion takes
// effect when the last handle closes, which discard does immediately after.
//
// If the request fails, the staged file is left behind rather than removed by
// name: a leaked file costs disk, deleting an attacker-chosen entry costs the
// operator a file they own.
func (staged *stagedBinary) discardOpenObject() {
if staged.promoted || staged.file == nil {
return
}
_ = deleteFileByHandle(staged.file)
}
func (staged *stagedBinary) discardPaths() {
// POSIX-only state is present in the shared struct and always nil here.
_ = staged.dir
_ = staged.dirHandle
_ = staged.parentHandle
// Nothing is removed by pathname here; see discardOpenObject.
}
// fileRenameInfo mirrors FILE_RENAME_INFO. FileName is a variable-length WCHAR
// array that follows the header, so the buffer is sized by hand and the name is
// appended after fileRenameInfoHeaderSize bytes.
type fileRenameInfo struct {
ReplaceIfExists bool
RootDirectory windows.Handle
FileNameLength uint32
}
// fileRenameInfoHeaderSize is the offset of FILE_RENAME_INFO's FileName member.
// It is derived rather than hardcoded because the RootDirectory pointer's
// alignment (and therefore the offset) differs between 32- and 64-bit Windows.
var fileRenameInfoHeaderSize = func() uintptr {
var info fileRenameInfo
return unsafe.Offsetof(info.FileNameLength) + unsafe.Sizeof(info.FileNameLength)
}()
// renameFileByHandle renames the object file refers to, not the object its
// current pathname resolves to. targetPath must be fully qualified.
//
// It is a package var, like stageBinary, so a test can simulate
// SetFileInformationByHandle reporting success without the rename actually
// taking effect — the exact failure mode verifyPromotedTarget defends
// against — without needing to reproduce whatever Windows-version-specific
// condition triggers it for real.
func renameOpenFile(file *os.File, targetPath string) error {
name, err := windows.UTF16FromString(targetPath)
if err != nil {
return err
}
// FILE_RENAME_INFO declares FileName as NUL-terminated even though
// FileNameLength excludes the terminator. Keep the terminator in the buffer:
// omitting it makes the ordinary rename fail with ERROR_PATH_NOT_FOUND on the
// supported Windows runner.
buffer := make([]byte, int(fileRenameInfoHeaderSize)+len(name)*2)
info := (*fileRenameInfo)(unsafe.Pointer(&buffer[0]))
// ReplaceIfExists stays false: promote already renamed the running binary
// aside, so a target that exists again means something raced the update, and
// failing is better than clobbering whatever appeared there.
info.FileNameLength = uint32((len(name) - 1) * 2)
for index, unit := range name {
binary.LittleEndian.PutUint16(buffer[int(fileRenameInfoHeaderSize)+index*2:], unit)
}
if err := windows.SetFileInformationByHandle(
windows.Handle(file.Fd()),
windows.FileRenameInfo,
&buffer[0],
uint32(len(buffer)),
); err != nil {
return fmt.Errorf("rename staged binary onto %s: %w", targetPath, err)
}
return nil
}
var renameFileByHandle = renameOpenFile
+130
View File
@@ -0,0 +1,130 @@
//go:build windows
package update
import (
"os"
"path/filepath"
"sync"
"testing"
)
// TestCreateStagingFileRefusesPrecreatedHardLink is the regression test for
// #742: a lower-privileged attacker who can write in the installation
// directory pre-creates the staging path as a hard link to another file the
// (possibly elevated) updater can write. createStagingFile must fail instead
// of opening and truncating through that link.
func TestCreateStagingFileRefusesPrecreatedHardLink(t *testing.T) {
dir := t.TempDir()
victim := filepath.Join(dir, "victim")
if err := os.WriteFile(victim, []byte("do not touch"), 0o644); err != nil {
t.Fatalf("WriteFile victim: %v", err)
}
staged := filepath.Join(dir, "staged")
if err := os.Link(victim, staged); err != nil {
t.Fatalf("Link: %v", err)
}
if _, err := createStagingFile(staged); err == nil {
t.Fatal("createStagingFile succeeded through a pre-existing hard link, want error")
}
data, err := os.ReadFile(victim)
if err != nil {
t.Fatalf("ReadFile victim: %v", err)
}
if string(data) != "do not touch" {
t.Fatalf("victim content = %q, want unchanged", data)
}
}
// TestCreateStagingFileRefusesPrecreatedSymlink covers the reparse-point
// variant of the same #742 primitive. Creating a file symlink on Windows
// needs SeCreateSymbolicLinkPrivilege (admin) or Developer Mode; skip rather
// than fail where that isn't available.
func TestCreateStagingFileRefusesPrecreatedSymlink(t *testing.T) {
dir := t.TempDir()
victim := filepath.Join(dir, "victim")
if err := os.WriteFile(victim, []byte("do not touch"), 0o644); err != nil {
t.Fatalf("WriteFile victim: %v", err)
}
staged := filepath.Join(dir, "staged")
if err := os.Symlink(victim, staged); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := createStagingFile(staged); err == nil {
t.Fatal("createStagingFile succeeded through a pre-existing symlink, want error")
}
data, err := os.ReadFile(victim)
if err != nil {
t.Fatalf("ReadFile victim: %v", err)
}
if string(data) != "do not touch" {
t.Fatalf("victim content = %q, want unchanged", data)
}
}
// TestCreateStagingFileSucceedsForFreshPath is the control: a path with
// nothing pre-existing must still work normally.
func TestCreateStagingFileSucceedsForFreshPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "staged")
file, err := createStagingFile(path)
if err != nil {
t.Fatalf("createStagingFile: %v", err)
}
if _, err := file.WriteString("payload"); err != nil {
t.Fatalf("WriteString: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != "payload" {
t.Fatalf("content = %q, want %q", data, "payload")
}
}
// TestCreateStagingFileConcurrentRaceOnlyOneWinner exercises a race on a
// single fixed path (installBinary normally avoids this by randomizing the
// name, but the exclusive-creation guarantee must hold regardless): exactly
// one concurrent caller may create the file, and the rest must fail cleanly
// rather than silently truncate the winner's content.
func TestCreateStagingFileConcurrentRaceOnlyOneWinner(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "staged")
const attempts = 16
var wg sync.WaitGroup
successes := make([]bool, attempts)
for i := range attempts {
wg.Add(1)
go func(i int) {
defer wg.Done()
file, err := createStagingFile(path)
if err != nil {
return
}
defer func() { _ = file.Close() }()
successes[i] = true
}(i)
}
wg.Wait()
winners := 0
for _, ok := range successes {
if ok {
winners++
}
}
if winners != 1 {
t.Fatalf("concurrent createStagingFile winners = %d, want exactly 1", winners)
}
}
+38
View File
@@ -0,0 +1,38 @@
//go:build windows
package update
import (
"crypto/rand"
"encoding/hex"
"fmt"
"path/filepath"
)
// randomStagingSuffix returns hex-encoded random bytes for stagingFilePath.
// Overridden in tests for a deterministic path; production always takes this
// default, cryptographically random one.
var randomStagingSuffix = func() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// stagingFilePath returns an unpredictable path in targetPath's directory
// (same filesystem, so the later rename into place is atomic). A fixed
// "<target>.new" name is guessable in advance, and a lower-privileged
// process that can write in the installation directory could pre-create it
// as a hard link or reparse point to another file the elevated updater can
// write, turning the staging copy into an arbitrary-file-overwrite primitive.
// createStagingFile's exclusive, no-follow creation is the other half of
// closing that: even a correctly-guessed name can't be opened through.
func stagingFilePath(targetPath string) (string, error) {
suffix, err := randomStagingSuffix()
if err != nil {
return "", fmt.Errorf("generate staging file name: %w", err)
}
name := filepath.Base(targetPath) + "." + suffix + ".new"
return filepath.Join(filepath.Dir(targetPath), name), nil
}
+2
View File
@@ -40,6 +40,7 @@ func TestDefaultTurnSessionStreamForwardsVerbatim(t *testing.T) {
request := CompletionRequest{
Messages: []Message{{Role: MessageRoleUser, Content: "hi"}},
ReasoningEffort: "high",
ServiceTier: "priority",
PromptCacheKey: "session-123",
}
stream, err := session.Stream(context.Background(), request)
@@ -60,6 +61,7 @@ func TestDefaultTurnSessionStreamForwardsVerbatim(t *testing.T) {
recorded := provider.requests[0]
if recorded.PromptCacheKey != request.PromptCacheKey ||
recorded.ReasoningEffort != request.ReasoningEffort ||
recorded.ServiceTier != request.ServiceTier ||
len(recorded.Messages) != 1 || recorded.Messages[0].Content != "hi" {
t.Fatalf("request not forwarded verbatim: %#v", recorded)
}
+4
View File
@@ -205,6 +205,10 @@ type CompletionRequest struct {
// Anthropic/Gemini thinking budgets) and ignores it for models that do not
// support reasoning. Empty means "let the provider decide".
ReasoningEffort string
// ServiceTier selects an optional provider-advertised request tier. Empty
// means the provider default; "priority" is the wire value used by fast mode.
// Callers must only set a tier advertised for the active provider/model.
ServiceTier string
// PromptCacheKey, when non-empty, is an opaque stable identifier for the
// conversation (the session ID). Providers with server-side prefix-cache
// routing forward it — OpenAI `prompt_cache_key` — so consecutive requests