Commit Graph
734 Commits
Author SHA1 Message Date
Neil 2bf538a4e1 fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see (#21436)
* fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see

A local terminal-artifact grant pinned the file as `dev:ino:nlink:size:mtimeMs`.
On Linux every one of those can survive an unlink+recreate: ext4 reuses the
just-freed inode (measured: 100% of the time), nlink and size are unchanged for a
same-size replacement, and the mtime clock is tick-quantized to 1ms, so a swap
inside one tick produces a byte-identical identity string. The grant then served
the attacker's bytes as if nothing had changed.

Local grants now also pin a sha256 of the artifact's content, taken from the same
handle as the stat so nothing can swap the file between them, and every local
read, preview and write re-checks it before returning or committing content.

The stat identity string itself is unchanged: the relay recomputes it verbatim to
honour `expectedStatIdentity`, so its format is a wire contract. Remote grants
keep the stat-only check and are untouched.

This is also the mechanism behind the intermittent
`orca-runtime-files-terminal-artifact-io.test.ts` failure on
`rejects stale absolute terminal artifact previews before returning changed
content`: it replaces an 8-byte artifact with 8 different bytes, so whenever the
two writes shared a 1ms tick the product genuinely could not tell them apart.

* docs(runtime): record what the terminal artifact grant checks do not close

The digest makes the same-size swap detectable; it does not make the sequence
atomic. A reader arriving at the access module would reasonably assume otherwise,
so write down the measured limits of the stat identity, why the identity string
cannot change, and the four windows that stay open — the write path's surviving
rename() gap above all.
2026-09-18 23:33:21 -07:00
github-actions[bot] 742fa638f3 Update README downloads badge 2026-09-19 00:58:02 +00:00
09073086a8 feat(terminal): inline images via @xterm/addon-image (perf-first) (#19512)
* feat(terminal): inline images via @xterm/addon-image, perf-first

Add opt-in inline terminal images (SIXEL, iTerm2 IIP, Kitty graphics)
through @xterm/addon-image, designed to keep idle terminals unaffected.

Performance:
- The addon (base64-inlined wasm decoders + protocol handlers) loads off
  the boot critical path via a deferred loader that mirrors the WebGL
  addon: primed after first paint only when the setting is on, read back
  synchronously at attach, with a 3-attempt cap so a transient failure
  never disables images for the session and a missing chunk never
  refetches per pane. renderer-boot-graph guards against eager import.
- enableSizeReports:false so the addon never sets windowOptions and
  double-answers Orca's own CSI 14t/16t responder.
- Perf-tuned decode/storage limits (storageLimit, sixel/iip/kitty size
  caps) in one place.

Correctness:
- Orca's DA1 handler wins over the addon's (last-registered-first), and
  the default DA1 response never advertised Sixel (;4), so DA1-detecting
  tools (chafa, img2sixel, viu, timg) never emitted it. The winning
  handler now appends ;4 while the setting is on, resolved per query so a
  live toggle changes the next DA1; idempotent against the ConPTY
  response that already lists it.
- ORCA_IMAGE_PROTOCOL=kitty is exported to spawned shells (local, daemon,
  relay/SSH) and forwarded across the WSL boundary, so image-capable
  agents can pick an encoder. Unknown image sequences are swallowed by
  xterm when the addon is detached, so this never garbles output.
- Settings toggle (default on) gates rendering and DA1 advertisement.

Cross-checked against community PRs #7775, #11706, and #19201 at the end;
credited below.

Co-authored-by: s546126 <s546126@users.noreply.github.com>
Co-authored-by: XRX193 <XRX193@users.noreply.github.com>
Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com>

* fix(terminal): bound inline image memory and classify Kitty replies

* fix(terminal): bound image decode and release image resources on cleanup

* fix(terminal): address image addon review feedback

* test(terminal): stub setPaneInlineImagesEnabled in appearance manager fakes

* fix(terminal): evict unplaced kitty payloads before displayed images

Byte-budget eviction dropped the oldest transmitted blob regardless of
placement, so a new upload could erase a visible image while abandoned
blobs still held budget. Unplaced payloads now go first and displayed
ones only when that is not enough. The incoming image is always stored,
so an oversized one overshoots the cap by one payload instead of being
dropped after the protocol already acked OK.

* fix(terminal): gate DA1 Sixel on real addon attachment; claim SSH image spec in CI

- DA1 advertised Sixel from the setting alone, so a pane whose lazy addon
  chunk was still loading (or had failed all three attempts) told
  feature-detecting tools to emit DCS that nothing could render. Track the
  attached decoder per terminal and require it before setting the ;4 bit.
- tests/e2e/terminal-inline-images-ssh.spec.ts was Docker-gated but claimed
  by no lane runner, so pr-e2e-gate-contract failed and the spec would have
  self-skipped green forever.
- Reject non-positive PNG IHDR dimensions before decode: they are parsed with
  signed shifts, so a dimension >= 0x80000000 came back negative and slipped
  past the pixel-limit comparison.
- One resolveTerminalInlineImagesEnabled() for the default-on setting; the
  four call sites mixed '?? true' with '!== false', which disagree on null.
- One readInlineImageResources() walk of the addon internals instead of two
  copies that could drift against the patched dependency.
- Isolate the deferred-attach drain per pane; make the zoom-invariance and
  backing-storage e2e assertions fail when the feature is dead.

* refactor(terminal): one lazy xterm addon loader for webgl and image

terminal-image-addon-loader was a structural clone of the webgl one — same
memo, attempt cap, and .then(ok,err)-clears-memo recovery. Both now wrap
createLazyXtermAddonLoader; each keeps its literal import() specifier so the
bundler still splits the chunk (verified against a fresh build: addon-image
stays out of the boot graph).

* refactor(terminal): name openTerminal's addon flags; pin image addon limits

Two adjacent optional booleans could be swapped without a type error once
inline images added the second one.

* docs(terminal): state the real per-pane image ceiling; drop test ordering dependency

storageLimit:32 reads like the pane's budget but keys three pools — decoded
pixels, retained encoded Kitty blobs, and pending WASM decoders — so the worst
case is ~98 MB per pane with no cross-pane governor. Say so at the constant.

pane-inline-images.test.ts's deferred case needed to run first; it now takes a
fresh module instead, and the rest prime in beforeAll. Verified by running the
file with that test moved last.

* fix(terminal): satisfy rebased static analysis gate

* fix(terminal): complete casting gate cleanup

* fix(terminal): recover failed image addon loads

* fix(terminal): bound image decoder allocations

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: s546126 <s546126@users.noreply.github.com>
Co-authored-by: XRX193 <XRX193@users.noreply.github.com>
Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 16:32:49 -07:00
github-actions[bot] f2f2c37fc6 Update README downloads badge 2026-09-18 18:30:04 +00:00
github-actions[bot] a98314e8bb Update README downloads badge 2026-09-18 12:35:55 +00:00
593141590e fix(terminal): retire captured remote handles when pending panes close (#21005)
* fix(terminal): retire captured remote handles when pending panes close

A restored pane can hold a scoped `remote:<environment>@@<handle>` layout
binding while `remote.attach()` is still waiting for `terminal.resolvePane`.
The transport's `getPtyId()` is null, so an explicit split close passed null to
`closeWebRuntimeTerminal`, dropped the binding and destroyed only the viewer.
The host terminal stayed connected.

Only an exact scoped handle whose environment matches the owning workspace's
runtime authorizes the close. The provider helper captures the pairing
revision, runs its existing compatibility check, then rechecks pairing and
ownership immediately before dispatch.

Rebased onto main after #21001 was squash-merged. The previous head was a merge
commit that carried its own conflict-resolution content -- the runtime branch in
`terminal-pane-close-admission.ts` and the restored `it.each([false, true])`
parameter -- which a plain rebase drops along with the merge. Rebuilt from the
recorded net diff instead and verified byte-identical at 15 files,
906 insertions, 41 deletions.

* test(memory): rebase the pending runtime-close proof onto the squashed base

`fix.patch` recorded a baseline taken against #21001's pre-squash branch tip.
Squash-merging #21001 replaced that tip with a single commit, so the recorded
hunks no longer reverse-applied and `reproduce.mjs` aborted with
`Source changed: use-terminal-pane-close-actions.ts` -- confirmed by running it
before regenerating rather than assuming the rebase alone would fix it.

Regenerated against `main` and re-run: 5 pass / 10 fail before, 15 pass / 0
fail after, exit 0, and every `results.json` hash recomputed from the run
rather than hand-edited.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 02:13:11 -07:00
d139760c06 fix(sessions): cancel transcript acquisition during host teardown (#21006)
* fix(sessions): cancel TUI transcript acquisition during teardown

* fix(sessions): settle canceled handoffs without replacement launches

* test(sessions): assert fenced teardown release

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-18 01:10:32 -07:00
1e3795de99 fix(log-tail): retire watches with their renderer lifetime (#21009)
* fix(log-tail): retire watches with their renderer lifetime

* fix(ci): clean up renderer tests

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-18 00:02:03 -07:00
2bdf281433 fix: avoid retaining foreign SSH file frames before metadata (#21167)
* fix: avoid retaining foreign SSH file frames before metadata

* test(ssh): exercise empty metadata through the streaming mux fixture

* fix(ssh): fail the file read when beforeResolve never runs

Moving the metadata install from .then() to beforeResolve moved it from a
mandatory callback to an optional one, and handleResponse clears the request
timer before beforeResolve runs. That left "response fulfilled, metadata never
installed" with no deadline: the read never settled, holding its notification
and dispose closures until mux disposal. Before this PR the same state failed
after the 60s inactivity deadline.

Unreachable with the concrete mux, which calls resolve on the line after
beforeResolve, but the hook is optional in the type and nothing enforces the
pairing. The guard is a no-op on every real path: empty, missing streamId,
cap-exceeded and alloc-failure all settle first, and the success path sets
metadataReady.

Found during review of #21167; raised at
https://github.com/stablyai/orca/pull/21167#issuecomment-5726058832

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-17 23:59:42 -07:00
69246e9b06 fix(terminal): retire explicitly closed pending split connections (#21001)
* fix(terminal): retire explicitly closed pending split connections

* test(memory): keep pending split proof compatible with formatted source

* fix(terminal): confirm pending split retirement before stopping work

* fix(terminal): restore the pending split-close gates CI checks

Three CI gates were red on this branch and all three were this branch's own.

The hook-order parity snapshot did not count the `confirmedCloseRef` this
branch adds to `use-terminal-pane-close-actions.ts`. Dumping the flattened
order against clean `main` shows exactly one added `useRef` at position 148
and no reordering, so the count moves 211 -> 212 and the digest with it.

`pending-split-close-test-fixture.ts` is Vitest support code, but it sits
outside the `*.test` / `*.spec` / `tests` globs that already switch
`anti-slop/no-module-mocking` off, so the gate failed on all twelve of its
`vi.mock` calls. It carries a file-scoped disable with the reason, matching
`work-item-search-test-harness.ts`.

`fix.patch` still described the pre-confirmation shape of the close hook, so
`reproduce.mjs` aborted with `Source changed` and the cited ablation could not
run at this head. Regenerated against the committed sources; the harness again
reports 10 pass / 14 fail before and 24 pass / 0 fail after.

Merges `main` rather than rebasing: #21005 is stacked on this branch.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-17 23:52:01 -07:00
github-actions[bot] 660969d191 Update README downloads badge 2026-09-18 04:38:25 +00:00
Neil a61119ceb0 refactor(runtime): name the four answers a host probe can give (#21207)
The renderer expressed every non-answer as one nullable `status`, so a probe in
flight, a probe that failed, a host that refused us and a retired pairing all
reached readers as the same `null` -- and readers spent that `null` on decisions
of very different weight, including destructive ones.

`RuntimeHostContact` names the four. Nothing changes yet: the connection-state
derivation is rewritten on top of it and a 384-case parity table asserts the
result is identical to a frozen copy of the old one on every combination of
verification, transport, retired, answered and remote-control state.
2026-09-17 21:22:39 -07:00
d04b05b5c8 Detach retained CI and terminal tails from oversized strings (#20960)
* fix(memory): detach retained CI and terminal tails from oversized strings

* fix(terminal): detach retained error and reattach string slices

* fix(terminal): release oversized recent-output backing strings

* fix(terminal): release backing strings held by PTY detectors

* fix(memory): own bounded Claude background task labels

* fix: detach retained terminal mode scan tails

* fix: own retained plugin worker output strings

* fix: own incomplete OSC 133 carry strings

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-17 20:34:19 -07:00
OrcaWinandm4air 1aadf91153 fix(runtime): preserve observed exit during explicit terminal close (#21019)
* fix(pty): reconcile daemon exits after synthetic notifications

* fix(runtime): preserve observed exit during explicit terminal close

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:32:49 -07:00
OrcaWinandm4air c3c051dfa6 Release provider children after structured session holds disappear (#20978)
* fix(chat): release provider children after lost resume holds

* test: load audit fixtures as modules and verify combined mobile payload

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:32:45 -07:00
OrcaWinandm4air f0dfc5de7b fix(projects): release processed repository scan records (#21022)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:31:37 -07:00
OrcaWinandm4air 5723c5baa9 fix(runtime): preserve exited PTY authority across queued graphs (#21011)
* fix(pty): reconcile daemon exits after synthetic notifications

* fix(runtime): preserve exited PTY authority across queued graphs

* test(runtime): include shared socket fixture for graph reproduction

* docs(memory): clarify graph reproduction dependency and source hashes

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:31:34 -07:00
OrcaWinandm4air 0e935c4b0a fix(runtime): terminate nonblank tail scan at the first row (#21018)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:28:58 -07:00
OrcaWinandm4air c09e8fe59a fix(sessions): stop transcript catch-up after TUI owner close (#21002)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:28:55 -07:00
OrcaWinandm4air 41059f65b2 fix(pty): reconcile daemon exits after synthetic notifications (#21000)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:28:52 -07:00
OrcaWinandm4air 54e11473a6 fix(browser): fence late registration replies to their guest owner (#21012)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:27:27 -07:00
a037180630 fix(ai-vault): release retired search write fences (#20986)
* fix(ai-vault): release retired search write fences

* test(ai-vault): use checked search writer mocks

* test: use typed access in memory retention regressions

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-17 20:25:19 -07:00
OrcaWinandm4air 9ed2f743a4 fix(runtime): fence terminal snapshot completion by owner (#20996)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-17 20:19:51 -07:00
OrcaWinandm4air 78289d8ebe fix: release settled browser results after dispatcher close (#21164)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:34 -07:00
OrcaWinandm4air 98998b18ad fix: release retired shared daemon owner metadata (#21162)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:31 -07:00
OrcaWinandm4air 1d09d55787 fix: fence viewport state after browser guest retirement (#21160)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:28 -07:00
OrcaWinandm4air 14654d03cb fix: release completed SSH writer queue entries (#21150)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:25 -07:00
OrcaWinandm4air ab331253a0 fix: release canceled working-directory waiter references (#21144)
* fix: release canceled working-directory waiter references

* test: normalize working-directory proof patch

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:22 -07:00
OrcaWinandm4air 3c138bd863 Skip empty chunks in streamed agent text (#21142)
* fix: skip empty chunks in streamed agent text

* test: lint empty-delta retention reproducer

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:19 -07:00
OrcaWinandm4air b899b22545 fix: release native PTY spawn environment after setup (#21140)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:16 -07:00
OrcaWinandm4air 79800e60b4 fix: release completed terminal spawn inputs (#21139)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:13 -07:00
OrcaWinandm4air fbfe3a2e74 fix: release Codex prompt claims when their turns complete (#21138)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:11 -07:00
OrcaWinandm4air 51f809aa82 fix: retire obsolete GitLab host cache generations (#21136)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:08 -07:00
OrcaWinandm4air f90370fb6b fix: detach aborted shared auth filesystem waits (#21135)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:05 -07:00
OrcaWinandm4air 0e3acf577d fix: release consumed runtime RPC queue entries (#21131)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:12:02 -07:00
OrcaWinandm4air bdad0e0f00 fix(browser): release page callbacks when a guest is destroyed (#21010)
* fix(browser): release page callbacks when a guest is destroyed

* fix: address memory PR review regressions and withdraw false positives

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:11:58 -07:00
OrcaWinandm4air e9c04fb8d9 fix(ai-vault): ignore cancellations after request settlement (#20980)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:11:55 -07:00
github-actions[bot] 0d23ea6e68 Update README downloads badge 2026-09-17 12:37:33 +00:00
Neil ea01cd0ccd fix(windows): reject a node-pty addon that predates the MSYS breakaway denial (#20047)
* docs(windows): record the measured MSYS job-breakaway mechanism

The per-PTY job already denies JOB_OBJECT_LIMIT_BREAKAWAY_OK for Cygwin/MSYS
shells (#19068), but nothing records why, and a conpty.node built before that
commit fails windows-msys-job.win32.test.ts in a way that reads as a source
defect. Measured on a real Windows 11 host: both the plain and the exec-
replacement Git Bash shapes leak, the escape is the MSYS runtime's own
spawn/exec (fork keeps membership), and a single-variable A/B on
usesCygwinRuntime flips the result 0/2 -> 4/4.

Also names the gap the failure hid behind: node-pty-job-ownership.cjs asserts
symbol presence, which cannot distinguish patch revisions.

* fix(windows): reject a node-pty addon that predates the MSYS breakaway denial

The native-runtime gate asserted only that terminateJob, listJobProcessIds and
assignCurrentProcessToJob were exported. All three predate the Cygwin/MSYS
breakaway denial, so an addon built before it passes every gate,
isPtyJobOwnershipAvailable() returns true, and windows-pty-job.win32.test.ts
passes 6/6 -- while every Git Bash child is created outside its pane's job and
survives terminatePtyJob.

Read the resolved .node and require the wide msys-2.0.dll literal that
usesCygwinRuntime holds, the way stagedRelayAddonIsUnpatched() already tells a
patched windows-process-tree addon from a published one. An addon the caller
cannot name is refused rather than skipped: a gate that cannot see its subject
is not a gate.

Verified against real binaries on a Windows 11 host: the shared checkout's
pre-#19068 build errors, a build from current patched source passes, a missing
path errors.

Also closes the cross-host packaging skip. The export half has to load the
addon so it cannot run when the packaging host is not the target, which is how
a Windows release built elsewhere could ship this. The marker is a file read
and needs neither; an unrecognised layout warns rather than fails a release
that was packaging fine.

* fix(windows): check the MSYS breakaway denial on the rebuild path too

The Electron probe carried the marker check, but it lives inside
probeElectronNativeModules, which returns early whenever the Electron package
binary is unusable. Covered by another path is not this path checks -- and the
defect this whole change closes was a gate that looked like it checked.

Reading the binary needs neither a loadable Electron nor an executable target
arch, so assert it after the rebuild, beside the windows-process-tree
assertion that exists for the same reason: this is the addon copied into the
packaged app. Absent warns (a cross-platform rebuild need not leave a win32
addon on this disk); present and unmarked is fatal.

The fixtures now write a real addon file, because the gate reads the binary it
was told about rather than trusting the exports. Verified against the two real
binaries measured on the Windows host: the pre-#19068 build fails this path,
the build from current patched source passes.

* fix(windows): check the marker on every ConPTY path the packaged app can load

The packaged marker check read one hard-coded path, `build/Release/conpty.node`,
and warned when it was absent. `loadNativeModule` tries `build/Release`, then
`build/Debug`, then `prebuilds/win32-<arch>`, swallowing each failure, and
`prunePackagedNodePty` drops the published prebuild only when a same-arch
`build/Release` exists to replace it. So the two packages the check was added for
were the two it could not see:

- cross-host: no host but Windows can build conpty.node, so there is no
  `build/Release` and the prebuild is what ships. The check warned and returned.
- cross-arch: `build/Release` is the packaging host's own arch, patched and
  marked, so the check printed OK -- while the target app cannot load it and
  falls through to the unmarked prebuild underneath.

Measured, not assumed: both published Windows prebuilds in the node-pty tarball
contain neither `msys-2.0.dll` nor `cygwin1.dll` in any encoding. They are the
binary that leaks every MSYS pane child out of its job.

It now sweeps every candidate present for the *target* arch and refuses a package
with no candidate at all, which is a package with no ConPTY backend rather than a
layout to shrug at. It runs for every Windows slice instead of only the branch
the export check skips, so deleting the export check cannot silently take it too.
A stale source build keeps the rebuild advice; the prebuild gets the advice that
actually works, which is to package the slice on a Windows host of that arch.

Also: the marker constant was re-typed in four places and was tied to the C++
literal that produces it by nothing at all, so editing the patch would have left
a gate that fails every correctly rebuilt addon and tells the developer to do the
one thing that cannot help. The fixtures now take the constant from the gate, and
a test asserts the patch still adds `L"msys-2.0.dll"` to conpty.cc.

And the rebuild path treated a missing addon as a warning even on the host that
will run the install, where node-pty would fall through to that same prebuild.
The verdict is now a value, so it is tested without a platform gate.

* fix(windows): resolve the packaged ConPTY the way its loader does

Sweeping every candidate and demanding the marker on all of them was wrong in
the one case it was meant to make safe. `beforeBuild` runs
`rebuild-native-deps.mjs --platform=win32 --arch=<target>`, so a cross-arch slice
normally does get a patched `build/Release` for the target; `prunePackagedNodePty`
keeps the prebuild anyway because its guard is `electronArch === process.arch`
rather than the arch of the binary. That package is correct and its leftover
prebuild is never reached, and the sweep failed it -- telling whoever ran it to
package on a Windows arm64 host, which is both the wrong remedy and one no runner
here can offer.

Presence cannot separate that package from the one whose cross-arch rebuild
quietly emitted the host's architecture, because the only difference is the arch
of `build/Release`. So the gate now resolves the addon the way `loadNativeModule`
does -- first candidate whose PE `IMAGE_FILE_HEADER.Machine` matches the target,
walking root-then-lib for each layout in node-pty's own order -- and checks the
marker on the one that will actually run. A package with no candidate, or none of
the target's architecture, is refused: it has no ConPTY backend either way, and
the second is exactly what a silently host-arch cross-build looks like.

The PE machine reader already existed, privately, in the relay addon builder that
needed the same "a cross-build cannot silently emit host arch" guarantee. It is
now shared rather than copied.

Two seams were unreachable from anything but Windows, so nothing tested them:

- the afterPack hook's win32 block was an inline if/else that only a source-text
  assertion could inspect, and that assertion could not tell the difference
  between the check running and the check being wrapped in `try {} catch {}`. It
  is now `verifyPackagedWindowsNodePty`, and "the marker check runs even where
  the export check cannot" is four spied assertions instead of a string match.
- the rebuild path's verdict read `process` directly, so the branch that fires
  only on the host being rebuilt for was dead on every other host. It now takes
  the host as arguments, and the fs checks, the warning and the failure are all
  exercised from macOS.

Fixtures write a real PE header rather than `MZ fake addon`, since the gate now
reads one. The machine table is pinned to the documented IMAGE_FILE_MACHINE
values, because every fixture builds its header from that table and a table wrong
in both entries would otherwise agree with itself.

* fix(windows): say why the packaged ConPTY fell back, not just that it did

The previous commit resolved the addon by architecture but still had one message
for every way the resolution could land on the published prebuild. Those ways
want opposite remedies, and the one it printed was the remedy the commit before
it had just called wrong:

- no source build in the package at all — the slice has to be built somewhere
  that can build node-pty for the target arch.
- a source build that is there but is the packaging host's architecture, because
  the cross-arch rebuild did not honour `--arch` — re-running that rebuild is the
  fix, and "package on a Windows arm64 host" is neither necessary nor possible.

The second is the common one, since node-pty publishes a prebuild for both
Windows arches and prune keeps the target's on every cross-arch package. So the
old text fired mostly on the case it described least. It now reports which source
builds were skipped and the machine field each carried, and names the rebuild
command.

"Nothing the target can load" had the same problem in reverse: a zero-length or
truncated `conpty.node` got a cross-architecture diagnosis. Every candidate is
now named with what was actually read, including "not a PE image".

The rebuild path asserts the architecture too. A rebuild that ignored `--arch`
was otherwise only visible at packaging, two steps from the command that fixes
it. Arches with no known machine value are left unjudged rather than guessed at.

Two things the extraction broke or nearly broke, both found by mutation:

- the shared PE reader answers `null` where the relay builder's private copy
  returned a number, which would have turned its "node-gyp ignored --arch" error
  into a `TypeError`. Both callers now go through `describePeMachine`.
- the rebuild fixtures stage a script's co-located modules by walking its
  imports, and the walker only understood `from '...'` — so the gate's new
  `require('./windows-pe-machine.cjs')` was left behind and every subprocess test
  failed with a resolution error, which is the exact failure its own comment
  warns about. It now follows `require` and bare side-effect `import` as well,
  and has tests; the fixture stages the gate by walking it rather than by naming
  one file.

Fixtures write real PE headers through one shared builder instead of three
hand-rolled ones.

* fix(windows): run the node-pty addon gates on the Windows job that can

`rebuild-native-deps-node-pty.test.mjs` carries four `skipIf(platform !== 'win32')`
tests. The full suite runs on ubuntu, and the Windows PR job runs an explicit
file list that never named this file -- so those tests were skipped on Linux and
never reached anywhere else. Three of them predate this branch. The Windows job
is added the four node-pty addon suites plus the module-walker one; the comment
above that list already says why it is the right place, which is that the addon
assertions only hold once natives have been rebuilt. Running the path-joining
suites there also covers the separator this gate's candidate list is built from.

The rest is round-three review:

- the rebuild-time arch assertion told a reader "node-gyp did not honour --arch"
  about a file that was not a PE image at all, which is a truncated or
  quarantined artifact and a different command to run. The two now read
  differently, and neither claims the other's cause. Same fix the packaged gate
  had one commit ago, in the place that had not had it yet.
- the missing-addon error said node-pty "would load" a prebuild without checking
  it is there. It says "fall through to" now, which is true either way.
- `isLoadableByArch` had no caller left once the packaged gate started needing
  the raw machine field for its message. Removed rather than kept warm.
- each candidate's header is read once instead of up to three times.
- the module walker's comment claimed every shape that reaches a co-located
  module; it does not follow `projectRequire`/`requireLocal`, and it must not --
  those specifiers resolve against the project root, so following one stages the
  wrong path and the copy fails. Proven by trying: widening the pattern to
  require-shaped names broke nine tests on
  `projectRequire('./config/scripts/...')`. The comment now says what it follows
  and why it stops there.
- a new test resolved a file URL with `.pathname`, which keeps the drive-letter
  slash on Windows -- the very job this commit adds it to.

* docs(windows): put the superseded export-only gate in the past tense

It describes what used to pass a broken addon, so present tense reads as a
description of the gate the same document then explains replacing it.

* fix(windows): repair what running the node-pty suites on Windows exposed

Putting these files on the Windows job turned four assertions red on the first
run. Three of them were in tests that carried `skipIf(platform !== 'win32')` and
had therefore never executed anywhere, on any branch.

- `writeFakeElectronRebuild` emitted the `windows-process-tree` addon a real
  rebuild leaves but never node-pty's, so every Windows test of the rebuild path
  ran against a tree no real rebuild can produce: node-pty "rebuilt" with nothing
  in `build/Release`. The new same-host check reads that state correctly and said
  so. The fake rebuild now writes `build/Release/conpty.node` when it was asked
  to rebuild node-pty for win32, with the marker and the target machine.
- `mkTempProject` never staged `windows-process-tree-creation-time.cjs`. The
  rebuild script reaches it through `projectRequire`, which resolves against the
  project root, so the module walker cannot follow it and must not try. Staged by
  name, with a comment saying which of the two it is. Without it the
  windows-process-tree probe failed to load its own checker and the module joined
  `modulesToRebuild`, which is the second and third red assertion.
- the two `nodePtyAddonPath` cases compared against a literal POSIX string.
  `resolve` returns a drive letter and backslashes on Windows, so they could only
  ever pass off it. Built from segments now, which still pins the `..` traversal
  that is the point of the test.

Verified on macOS: ensure-native-runtime-job-ownership,
verify-packaged-node-pty-job-ownership, windows-pe-machine,
script-module-dependencies, rebuild-native-deps-node-pty, rebuild-native-deps,
rebuild-native-deps-windows-process-tree, ensure-native-runtime -- 109 passed, 6
skipped. The 6 are the Windows-gated rebuild tests, which is the job this change
is aimed at; Windows CI is the arbiter.

* fix(windows): give the packaged fallback a third verdict, for a file that is no image

The packaged gate had two remedies for landing on the published prebuild and
picked between them on `!prebuilt`, which puts a truncated, empty or quarantined
`build/Release/conpty.node` in the cross-arch bucket: "the source build beside it
is the wrong architecture ... re-run with --arch". It is not the wrong
architecture, it is not an architecture, and `--arch` is not the command. The
rebuild-path gate was split for exactly this a commit ago; this is the same split
in the place that had not had it.

Also from review of the settled state:

- the stale-source-build branch ended in a call that happened to throw, so a
  reader could not see it was terminal and the file was read twice to get there.
  The verdict is now an Error the caller throws, built once from the read it
  already did, and shared with `assertCygwinBreakawayDenied` rather than copied.
- four injection seams had no consumer in production or in tests
  (`deniesBreakaway`, `peMachine`, and `exists`/`peMachine` on the rebuild
  verdict). An unused seam is a way for the tested path and the real one to drift
  apart; the tests drive both with real files. Removed.
- the loader table existed in a docblock and in the reference doc, already
  disagreeing about row four. The docblock cites the doc now.
- `peImage` stamped machine `0x0000` for an arch it had no value for, because
  `writeUInt16LE(undefined)` coerces to zero. A fixture that quietly invents the
  field the gates read is the same species of silent lie the gates exist to
  catch; it throws, and a test holds it to that.
- a test named for refusing an unreadable candidate asserted only that something
  threw. Renamed to what it proves.

* fix(windows): make the rebuild fixtures represent a tree that can exist

Second round of what running these suites on Windows exposed. The module the
walker could not stage is now staged, so the probe reached its own checker and
the real reasons surfaced:

- `writeFakeWindowsProcessTree` exported `{}`. The creation-time gate reads
  `supportedProcessDataFlags` off the addon and calls its absence "the tarball
  prebuilt, not a build of the patched source" — correctly. The fixture predates
  that gate and, being Windows-only, never met it. The healthy fake now reports
  the flag, taken from the gate's own constant. Two tests were failing on this,
  the second only because the module then joined `modulesToRebuild`.
- `rebuilds a loadable ConPTY native that lacks Orca job ownership` asked for a
  node-pty rebuild in a tree where node-pty had none of the payload its package
  ships. It gets `writeFakeNodePtyConptyPayload` like its two siblings.

I also tried making the fake rebuild emit `build/Release/conpty.node` the way a
real one does, and backed it out: `restoreNodePtyWindowsConptyRuntime` keys off
that file and then reads `third_party/conpty`, so emitting it in a tree without
the package payload turns one honest gap into an ENOENT two steps away. The
payload fixture is where "node-pty has its addon" belongs.

macOS: ensure-native-runtime-job-ownership, verify-packaged-node-pty-job-ownership,
windows-pe-machine, script-module-dependencies, rebuild-native-deps-node-pty,
rebuild-native-deps, rebuild-native-deps-windows-process-tree,
ensure-native-runtime — 112 passed, 6 skipped. The 6 are the Windows-gated
rebuild tests; Windows CI is the arbiter and is why they are on that job now.

* fix(windows): register the node-pty addon suites in the scope list too

Putting the five suites in the Windows lane's vitest argv gets them run once the
job starts; `WINDOWS_PACKAGE_TESTS` in `pr-code-change-scope.mjs` is what decides
whether the job starts at all. Only the argv was updated, so a PR touching just
`rebuild-native-deps-node-pty.test.mjs` would not have started the Windows job,
and its four Windows-only cases — including the same-host-absent one added here —
would have run on no machine for that PR. Exactly the shape of gap this branch is
about. Both lists now name all five, and `windows-pe-machine`,
`windows-pe-image-fixture` and `script-module-dependencies` join
`NATIVE_RUNTIME_PREFIXES` so a change to the modules themselves starts it too.

`win32-test-lane-registration.test.mjs` exists to catch precisely this and did
not, because its matcher only recognises suite-level gates (`describe.runIf` /
`describe.skipIf`) and a `.win32.` filename. These tests gate per `it`. Widening
it is not this branch's change to make: about thirty files across the repo carry
per-`it` Windows gates and are unregistered, so the ratchet would move far beyond
node-pty. Flagged rather than done.

Message repairs from the same review:

- the non-PE arm of the rebuild-time arch error read "... is not a PE image, so
  nothing can load it, so node-pty would fall back ...". The shared consequence
  clause already opens with ", so".
- the no-source-build packaging error ended "Package this Windows slice on such a
  host", which is wrong advice for the case where the host IS such a host and the
  rebuild simply left nothing — reachable when the artifact is removed before
  prune runs. It now names both readings and points at the beforeBuild output.
- the relay-addon builder blamed `--arch` for a build output that is not a PE at
  all, the same guess the node-pty gate was taught to stop making.
- the patch-drift assertion was a bare `toBe(true)`, so a real drift read as
  "expected false to be true". It now names the two things that can have drifted
  and what happens until they agree.
2026-09-16 22:23:30 -07:00
Neil 6c3b97b950 fix(mobile): a scope refusal is not a missing method on the Relay pairing probes (#19952)
* fix(mobile): a scope refusal is not a missing method on the Relay pairing probes

The desktop's mobile allowlist gate runs before its RPC dispatcher, so a method an
older desktop predates is absent from both and the phone is answered `forbidden`,
never `method_not_found`. Keying the "too old for Relay, stay on LAN" fallback on
`method_not_found` alone therefore never fired against the exact desktop it exists
for: first-time pairing threw instead of committing a LAN host.

`isPairingRelayRpcUnavailable` accepts both codes at the three pairing probe sites.
It is pairing-scoped on purpose - `isMethodNotFoundRefusal` has four other consumers
that must keep reading `forbidden` as a refusal, not as absence.

The main-side test pins the claim the fallback rests on: the dispatcher really does
answer `forbidden` to a mobile-scoped device and `method_not_found` to a runtime one,
and this build allowlists both probes, so `forbidden` on either can only mean an
older desktop.

* fix(mobile): leave a breadcrumb when a desktop refuses relay pairing

The LAN fallback now commits a host instead of throwing, so the refusal code
was the only record of why a phone ended up without a relay endpoint and
nothing wrote it down. Log it on the path that swallows it.

Narrow `isPairingRelayRpcUnavailable` to the two codes it matches rather than
to `RpcFailure`: a plain failure guard would collapse the *false* branch to
`RpcSuccess`, which a refusal carrying any other code still reaches.

Rename the `'method-not-found'` sentinel in the direct-upgrade reader, which
stopped describing what it covers, and correct two comments that named a
`method_not_found` mechanism the desktop cannot produce for these methods:
both probes have been allowlisted and registered by the same commit since
Relay landed, and an unwired pairing provider answers `runtime_error`.

* docs(wire): record that the mobile surface refuses by scope, not by absence

Two comments cited this page for "a scope refusal is not a missing method" and
the page did not say it — the only nearby statement says the opposite, because
it describes the runtime-scoped surface, where the dispatcher does answer
`method_not_found`. The allowlist gate makes the mobile surface the exception,
and the harness does not run that surface, so this note is the only record.

* docs(mobile): name the pairing site the scope refusal actually reached

The comments and the wire-compat note said this fixed first-time QR pairing.
It cannot: the `relay` block on the pairing offer, both RPC handlers and both
allowlist entries all landed in 77b154d5dd, so a desktop old enough to refuse
the probe also omits the offer block, and that flow commits a LAN host without
ever probing. The site that reached is `upgradeDirectMobileRelay`, which
re-probes every LAN-only host on reconnect: the refusal threw into the
controller's swallowing catch, so the write-once journal it had just written —
and the pending resume secret in it — was never retired.

Also drop two overclaims: the phone's Files and Git fallbacks have read both
codes since they shipped, so this is settled practice rather than a new rule,
and the reason the allowlisted-but-unregistered case cannot ship is
mobile-rpc-allowlist.test.ts, not a convention about what lands together.
2026-09-16 22:22:05 -07:00
Jinwoo Hong a28085adbf refactor(mobile): checked reply readers for the source-control domain (step 7 pilot) (#20950)
* test(mobile): ratchet the 201 unchecked RPC reply readers

Step 4 moved every call-site cast into an RpcOperation's `read`, but 201 of those
readers still answer `compatible: true` for any payload: `rpcUncheckedPayloadReader`
(163), `rpcReadUnchecked` (26 outside its own module) and `rpcUncheckedMemberReader`
(12), across 42 files. The cast moved; it did not become true.

Held as data with an AST boundary test, shaped on the raw-request-port ratchet: a file
that is not listed fails, a listed file that no longer has one fails, and a count that
rises fails. Only a call counts, so an import is not a reader and prose never is.

No behaviour change: this commit adds a list and a test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): validate the source-control domain's RPC replies at arrival

Replaces all 17 unchecked readers in mobile/src/source-control/ with
`rpcResultVariant(variant, schema)`, so a malformed reply is an
`RpcIncompatibleReplyError` naming the operation instead of a TypeError three
frames downstream. The inventory drops 201 -> 184 and the five source-control
operations files leave it entirely.

This is a behaviour change, scoped to malformed replies. Six reply-matrix
goldens move; every named-scenario golden and every `normal` partition is
byte-identical, which is the parity claim.

Schemas live one module per reply domain, beside the operations that read them:
git-status, git-compare, git-history, hosted-review and worktree-metadata. A
member is required only where a consumer reads it unguarded, and each schema
records the consumer line that justifies it. Nothing is `.strict()`; every
reply a consumer publishes verbatim keeps `z.looseObject` so an undeclared host
member still passes through. Six replies have no reader anywhere in mobile and
get `z.unknown()`, which is the honest schema for them, not a holdout.

Three readers stay total by construction, because their contract is that an
unreadable reply is a value rather than an error: the `git.status` projection
(a null status three screens route on), the `session.tabs.list` reveal (a null
list means poll again) and the generated commit message (a screen's copy, never
a decode error in a text field). They gain the salvage report, not a verdict.

Consumers take the schema's output type, so `MobileGitStatusResult` and the
branch-compare aliases now name what mobile reads rather than the desktop
aggregate, and seven call-site casts are gone.

Three requirements came from the goldens, not from the host types:
`git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility`
sends a `reviewLookupOutcome` the shared union does not list, and the
`git.status` projection writes an absent member as a present `undefined`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the six source-control reply-matrix goldens step 7 moves

Six goldens, all on malformed partitions. Every named-scenario golden and every
`normal` partition is unchanged, which is the parity claim for this step.

  git.history-read / git.history#1
    result-absent, result-null, inner-ok-missing, inner-false-string-error,
    inner-false-object-error: the load rejected with a TypeError reading 'items'
    or 'map' off undefined/null; it now rejects with
    `incompatible_reply: git.history-page (git.history)`.

  hostedReview.eligibility + create-intent / hostedReview.getCreationEligibility
    result-absent, result-null, inner-ok-*: the fetch fulfilled with the error
    envelope itself, re-typed as an eligibility and published into the compose
    prefill; it now rejects, and both callers already route that to the same
    "eligibility unavailable" state a null answer produced.

  hostedReview.create-chain + create-intent / hostedReview.create
    result-absent, result-null, inner-ok-missing, inner-false-object-error: the
    create form showed the raw TypeError text "Cannot read properties of
    undefined (reading 'ok')"; it now shows the incompatible-reply message.

Every header digest is unchanged -- baseline, recorder, adapter, scenario and
lockfile all match -- so the diff is the behaviour and nothing else.

Recorded from this branch into a scratch directory and copied in, because there
is no scoped honest alternative: scripts/rpc-recording.mts refuses to run unless
the product tree equals the pinned baseline, and the README's remedy for an
intended behaviour change is to repin, which rewrites the `baseline` header of
all 667 goldens. So these six now carry a pin whose tree no longer produces
them. That is a real gap in the oracle's design for behaviour changes, not a
detail of this step, and it needs a decision before this lands.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the four reply-schema properties the goldens found

Each of these cost a reply-matrix golden while writing the source-control
schemas, and none of them follows from reading the consumers or the host types:
a newer host's undeclared members must still decode, `git.history` sends
`timestamp: null`, `hostedReview.getCreationEligibility` sends a
`reviewLookupOutcome` the shared union does not list, and the `git.status`
projection writes an absent member as a present `undefined`.

The `.strict()` case is the one worth stating twice: at the top level it rejects
the reply, and on the entry it drops the row, which shows a dirty worktree an
empty Changes list. The fifth test pins the salvage report that makes such a
drop visible instead of silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give an unreadable reply a message a user can read

`RpcIncompatibleReplyError` put `incompatible_reply: <op> (<method>)` in
`message`, and `message` is what the screens hand to a toast. Step 7 is the
first change that can reach this error at all, so the token would have shipped
to users as its own error copy.

Fixed at the boundary rather than per site: `message` is now plain copy, and the
machine token moved to `code` (`incompatible_reply`) and `name`
(`RpcIncompatibleReplyError`), both readable by callers. The cross-bundle
fallback in `isRpcIncompatibleReplyError` matched on the old message prefix, so
it now matches on `name`, which a foreign copy of the module still carries.

No existing test pinned the old text. Two new ones pin the copy, the token and
the foreign-copy match.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to this branch and re-record

Commit adeb5f9531 recorded the six moved goldens into a scratch directory and
copied them back, which left them pinned to `e7206f62`, a tree that no longer
produces them. That is the one claim the `baseline` header exists to make, so
this replaces it with the README's remedy done in full.

`baseline` is now f741b2ea82, the last commit on
this branch that touches a fenced path, so the recording fence passes in place
and every golden is pinned to the tree that produced it. All 667 were
re-recorded through `scripts/rpc-recording.mts --record`; none were hand-edited.

Decoding every value pool against the branch point b8d4cde09f sorts the corpus
into 661 header-only moves where `baseline` is the only key that moved, 6 whose
body moved as well, 0 added and 0 deleted. The 6 are the disclosed step-7 delta,
unchanged at 69 moved observation fields across malformed reply partitions, plus
the readable incompatible-reply copy from f741b2ea82. No `normal` partition and
no named-scenario golden moved.

`scenarioSha256` hashes the derived scenarios, not the manifest, so the repin
moves no other header key; the README section this adds records that, the
scratch-copy failure mode, and the follow-up repin main needs after a squash
merge.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): narrow the incompatible-reply error by instanceof, not by cast

The two new tests in f741b2ea82 read the error through `as` casts, which the
changed-code casting gate rejects. An `instanceof` guard narrows the same value
and checks the class at the same time.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the branch tip and re-record

71d8c6a1e2 touched a fenced path (`mobile/src`), so the pin from 5f3f184fdf no
longer named the tree that produces these goldens. The fence compares the whole
of `mobile/src`, and a test file is inside it, so the pin follows the last commit
that touches a fenced path rather than the commit whose behaviour moved.

Re-recorded all 667 in place through `scripts/rpc-recording.mts --record`.
Decoding every value pool against the branch point b8d4cde09f still gives 661
header-only moves with `baseline` the only moved key, 6 body moves, 0 added and
0 deleted; the six and their 69 moved observation fields are unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record the four source-control reads that had no oracle

git.status (host payload), git.branchCompare, git.commitCompare and
git.branchDiff were migrated to checked readers with no recording observing
them, so a required member a host omits would have surfaced only in production.

Three families mount the owners rather than the senders, because each reply is
only visible in what the owner then publishes: the Changes screen's loader hook
(git.status, and the base-ref chain and git.branchCompare it triggers), the
history list screen (git.history and the per-commit git.commitCompare), and the
committed-diff opener hook (git.branchDiff). Ten goldens: three pilot recordings
and seven reply matrices.

Two adapter capabilities this needed. An inert FlatList never calls `renderItem`,
so the history adapter renders one row through the screen's own callback, both to
reach the handler that expands a commit and to read the file list back; without
that the commit-compare reply changes nothing observable. And `lowlight` joins
`react` and `zod` as a real library rather than a refusing proxy, because the
branch diff highlights on its success arm before the preview reaches state, so
the shipped text arm was otherwise unrecordable. No golden recorded its absence,
so only `recorderSha256` moves.

Recording the same scenarios against 4b0009d414, the pre-refactor tree, is the
before column. Decoding every value pool across the two gives 11 body moves and
666 header-only, 0 added, 0 deleted: the 6 already disclosed, plus the 5 new
matrices at 63 moved observation fields. What moved is the point. A malformed
git.status used to leave Changes `ready` over the malformed payload and go on to
fetch a branch compare; it now says the host sent a reply it could not read. An
absent git.branchDiff result used to put "Cannot read properties of undefined
(reading 'kind')" on the screen. An unreadable git.commitCompare used to spin the
expanded commit forever; it now says "No file changes".

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the merge commit and re-record

The merge is the last commit touching a fenced path, so it is the only tree
the recorder's fence can match. Every golden moves `baseline` and picks up
main's `recorderSha256` from #20920; the six the checked readers changed are
the only bodies that move against main.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): note the merge-commit pin and unwrap the recipe's record command

`format:check` from `mobile/` caught the wrapped inline command the recipe
had been carrying since it landed; pointing at the command above removes the
duplicate and the wrap together.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): open the source-control reply enums so a newer host's arm degrades

A closed `z.enum` in a reply schema is a version claim, and it refused replies
every declared reader could have rendered: a `git.branchCompare` summary status
of 'shallow-base' failed the whole Changes compare, a 'codeberg' provider failed
the whole eligibility, and a 'typechange' entry status dropped the row. Main
passed all three through.

`openEnum` in zod-salvage declares the arm set open: an unrecognised arm reads as
a member the consumers already handle, while absence and a non-string stay fatal.
Not `.catch()`, which would swallow those two as well.

`area` stays closed and says why: every arm grants stage, unstage or commit, so
there is no member to degrade to that would not offer an action against a row
this build cannot place. Main rendered such a row in no section either.

Also drops two claims the code does not back. Nothing reads the salvage report,
so the two comments promising a dropped entry "arrives as salvage.droppedPaths"
are gone, and `hostKind` on the non-text diff arm had no reader.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs: write down the open-enum rule and the header keys a branch moves

Rule 4 in the wire-compatibility page, beside the three rules it belongs with:
an enum arm set is a wire surface, unknown arms degrade rather than reject, and
leaving one closed is a decision to state where the schema is declared.

The recorder recipe's step 4 said `baseline` would be the only moved header key,
which is only true of a branch that never touched the recorder. It now names the
three digests a branch's own edits move, so a reader recognises a clean result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the recorder's own timeout killing a full re-record

The corpus records in ~110s warm and 160s under load, against a 120s budget, so
a full re-record was killed roughly half the time. A killed run wrote a partial
reporter banner and exited 1, which reads as a failing scenario rather than as a
run that never finished — it cost two investigations here. The budget is now ten
minutes, and a killed run says so.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the open-enum commit and re-record

`baseline` is the only header key that moves and no golden body moves: no matrix
partition scripts an unknown enum arm, so the corpus cannot see this change. The
eight schema unit tests are its only oracle.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop an unresolvable eligibility claiming the branch is not ready

Both fallback prefills set `canCreate: false`, which is a determination nobody
made. It short-circuits getMobilePrCreateBlockMessage before reviewLookupOutcome
is read, so a malformed, refused or rejected eligibility told the user "This
branch is not ready for a pull request yet." instead of asking them to retry.
Dropping it leaves `canCreate` undefined, which is what "unproven" means here.
Only a host that determined `canCreate: false` still gets the blocked copy.

`area` now degrades to absent rather than staying closed. Dropping the row also
dropped it from the unresolved-conflict gate, which grants create on a conflicted
worktree; absent withholds stage, unstage and commit while keeping the row, since
every area reader is an equality check. Its four consumers narrow explicitly: the
diff-review queue filters unplaceable rows, the opener withholds the route, and
the commit-failure prompt pins 'staged' where its own filter already did.

`git.branchCompare` entries are nullish, matching the `?? []` its consumers use.

Deletions: `MobileGitStatusProjection` and `uncheckedReaderCount` lose `export`,
the boundary test drops its dead inventory self-file (the AST counter finds zero
calls there, only prose), and `isRpcIncompatibleReplyError` is gone — it had no
caller in mobile, desktop or e2e.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): formatting and a thrown rejection in the round-2 tests

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the round-2 tip and re-record

The round-2 eligibility fix is a behaviour change, so the corpus has to be
re-recorded at a pin that includes it. Four goldens move body: the two
create-intent eligibility matrices on every non-normal partition, and the two
prefill scenarios that lose the fallback's `canCreate: false`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the main merge and re-record

The merge is now the last commit touching a fenced path, so the corpus has to
carry its sha. No body moves against the pre-merge corpus: main's engine change
shifts `recorderSha256` on every golden and nothing else, and main's fifteen
step-6 goldens re-record byte-identical.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): admit the three unchecked readers #20954 landed

The ratchet is a ceiling against this branch adding readers, not a claim about
what main may land. #20954 brought `notification-stream-closed`,
`native-chat-session-page` and `terminal-buffer-cleared`, so the merge has to
raise those lines and say where they came from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the recording baseline to the inventory commit and re-record

The ratchet inventory is a fenced path, so admitting #20954's three readers
moved the fence head again. Baseline only; no body moves against the merge
re-record.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send the host's own provider token back instead of a fallback

`provider` is not a member mobile only reads. The eligibility reply names it and
the create call returns it, so `openEnum(..., 'unsupported')` did not soften a
reading — it rewrote the bytes, and a host that had just named `codeberg` refused
its own provider as unsupported. The action-sheet Create path has no provider
gate, so nothing caught it.

Passes the token through as a string from the reply to the create params. The
allow-list that decides whether mobile may create stays supportsHostedReviewCreation(),
which already answers no for a token this build does not know; its parameter
widens to `string`, since answering for an unknown token is the whole job. The
worktree-link switch gains a default, which also fixes an older hole: an
unrecognised provider used to fall out of the switch as `undefined` params.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the provider pass-through in the corpus

Repins to the provider fix and records `sc-create-intent-unlisted-provider`,
whose eligibility reply names `codeberg` and whose recorded `hostedReview.create`
params carry it back unchanged. Restoring the old enum fallback fails that
golden on `Request params mismatch: hostedReview.create#1` and nothing else.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 13:32:09 -04:00
Brennan Benson aee98ccaa0 fix(browser): make the browser identity one process-wide choice (#13822) (#20767)
* feat(browser): process-wide browser identity, chosen before ready

Electron resolves worker identity from a single process-global default, so two
coherent identities cannot coexist in one process. This makes clean/native one
app-wide decision read before `ready`, instead of a per-profile one that leaves
documents on one identity and every worker request on the other.

Both identities are load-bearing, measured across four origins at five reps:
the cleaned identity clears an embedded Turnstile widget and WhatsApp's browser
check where native is refused; native clears a full-page Cloudflare interstitial
that the cleaned identity never clears.

Base commit only: removing the per-profile field, its settings surface, and the
migration notice follow.

* test(browser): cover cross-context UA wire identity

* refactor(browser): make user agent identity app-wide

* test(browser): repair process identity wire fixture

* Fix browser identity startup migration failures

* WIP: rescue in-flight reduced-design work from a dead worker

Worker ctx_cb5b1262d7fe stopped ~2h ago mid-implementation (last heartbeat
2026-09-14T22:48:06Z) leaving this uncommitted. Committed unverified to make it
recoverable; not reviewed, not necessarily green.

* fix(browser): repair the rescued identity work so it typechecks

Finishes the interrupted edits in 7db9c54b54:

- browser-user-agent-migration-notice.ts was truncated mid-write; close the
  then() callback so the file parses.
- Register browser.identity.get/set in the generated RPC params catalog so the
  params type-parity gate is satisfied.
- Retire the persistence assertions for the superseded design: a
  migratedNativeProfileIds event map, a notice-acknowledgement clear, and a
  global persistence-failure accessor. Legacy userAgentMode bytes are retained
  now, so these assert retention plus a failed notice write still hydrating.
- The in-memory fs fixture threw a codeless ENOENT, which reads as "unreadable"
  rather than "missing" and made every identity write refuse. Carry the code.
- Use the segmented control's per-option disabled rather than adding a
  control-level prop it does not have.

* refactor(browser): make the identity store the only writer

The rescued work already serialized identity writes, but the writer lived beside the pre-ready reader, so nothing stopped a second caller from writing the record directly -- which is the shape of the bug this change set removes.

browser-identity-mode-record.ts is now read-only: record shape, path, parsing and the pre-ready synchronous read. browser-identity-mode-store.ts owns every mutation behind one queue, holds the snapshot and listeners, and derives restartRequired from appliedMode vs configuredMode rather than storing it. Consumers move to the store.

The two identity RPC methods also move out of browser-core.ts into browser-identity-rpc.ts: they read and write this host's own process identity rather than driving a page, and browser-core.ts was over its line cap. The generated params catalog is byte-identical.

* feat(browser): make resetting unhealthy identity data explicit and lossless

A corrupt or newer-version record left the identity unchangeable with no way out. An explicit reset now copies the old bytes verbatim to a fresh unique path before publishing a replacement, and refuses the whole operation if that backup cannot be written -- so the reset can never be the thing that loses the data. Nothing resets automatically.

Future-version data says update Orca rather than reporting corruption. Reset is opt-in via browser.identity.set and orca browser identity set --reset.

ProfileCreate and BrowserIdentitySet move to browser-identity-params.ts: both carry the per-profile to app-wide identity move, and browser-params.ts was over its line cap.

Also registers browser as a top-level CLI name so the Windows launch redirect covers it -- without it orca browser identity get boots the GUI and exits silently there -- and adds the canonical browser identity show alias the CLI vocabulary policy requires.

* feat(browser): advertise the identity capability only where it exists

browser.identity.v1 was static, so every host claimed it including one that never initialized the identity store, where both methods can only throw. It now follows the browser.headless.v1 precedent and is pushed at status time when the store is actually initialized.

Also covers the retired profileCreate userAgentMode field at the dispatcher rather than only at the schema, so an older client provably gets the changed-semantics rejection over the wire instead of a success with the field quietly dropped.

* refactor(browser): delete the identity write queue and guard backup uniqueness

The queue could not be falsified by any test: writeRecord is synchronous end to end, so two calls cannot interleave and removing serialization entirely left every store test green. Carrying machinery whose guard is unconstructible is what the design review told us to cut, so it is gone. If durable writes ever become async, serialization comes back with the change that makes it testable.

The test that claimed to prove serialization now states what it actually pins -- the later of two selections is the one that survives -- and the module doc no longer claims a queue that is not there.

Adds the guard that was missing on reset: two resets across separate launches must produce two distinct backups, each holding its own original bytes. Verified discriminating -- a fixed backup filename fails it.

* test(browser): guard the identity capability and harden two weak assertions

Pins the mixed-version guarantee that had no test: browser.identity.v1 is advertised when the identity store is initialized and absent when it is not. Verified discriminating -- advertising it unconditionally fails the test.

The profileCreate rejection test asserted ok:false against a runtime with no browserProfileCreate, so that assertion passed even when the retired field was accepted. It now stubs a working runtime method, making ok:false load-bearing, and asserts the runtime is never reached.

Removes the persistence fixture's dead failIdentityWrite branch on writeFileAtomically: nothing on that path calls it, so it implied a second write mechanism that does not exist. Failure is injected through node:fs, which is what the identity write actually uses.

* test(browser): classify the identity channels on the preview seam

The channel split is asserted total, so adding browser:identity:get/set left it
short by two. They manage the host's own process-wide user-agent choice rather
than acting on a guest the reader is looking at, so they sit with the session
and profile channels, not the preview tools.

* test(browser): audit the identity rig's global-fetch call sites

The wire probe server and CDP collector arrived with the cross-context coverage
and were never added to the audit list. The collector's two real call sites are
safe: the poll cancels its unread body and the version probe consumes it through
response.json(). Every hit in the probe server is inside an injected page or
worker script source string, not a call this process makes.

* fix(browser): strip an app name that contains a space

app.setName decides the app token in the user agent, and dev sets "Orca Dev".
The cleaner matched a single whitespace-delimited token, which cannot span that
space, so the replace failed outright and every dev build presented
"Orca Dev/1.4.203" on the wire — the exact token class that gets transplanted
sessions revoked.

Anchoring on the engine comment and consuming lazily up to Chrome/ removes any
number of app tokens. A user agent without that comment is returned unchanged
rather than mangled, because over-stripping is worse than under-stripping.

The function had no unit test at all; it was only exercised through the
real-Electron wire tests, which run with a single-token fixture name. That is
why this survived.

* fix(browser): anchor the cleaner on the gap before Chrome/

My first attempt anchored on the engine comment, which broke a startup fixture
whose platform comment is "(Test)" with no "(KHTML, like Gecko)" at all — the app
token survived and the ordering test went red.

Anchoring on the nearest ")" before Chrome/ and consuming only non-")" tokens
keeps the match inside that gap, so it handles a multi-word app name, a synthetic
platform comment, and an already-clean identity alike. A user agent with no such
gap is still returned unchanged.

The fixture shape is now a test case, since it is what caught the first attempt.

* test(browser): repair the cleaner's case table

A missing comma between two it.each elements was reformatted into an index
expression, collapsing the table so every case ran with undefined input.

* test(browser): make a CI-only capture failure diagnosable

This probe passes locally and fails on CI with an empty receipt set, an empty
CDP diagnostic list, and a fixture that still exits 0 — so the assertion message
carried nothing usable. Thread the fixture's own result and stderr into the
capture assertion so the next run says what the fixture actually did.

* fix(browser): let an explicit choice retire the migration notice for good

The retired per-profile userAgentMode bytes are retained on disk by design, so
every launch rediscovers them and re-arms the notice — including the launch
right after the user answers it, and every launch after that. Documented as
one-time, it was permanent.

The record already carries explicitSelection, which is exactly the fact that
should end the notice. Gate the mark at the single writer rather than deleting
the legacy key, so the retained bytes stay untouched and disk never claims a
notice is pending beside a choice the user already made.

The new test pushed the persistence suite past max-lines, so the in-memory fs
and module mocks move to a named fixture module and the retired-identity tests
move beside them in their own file.

* fix(browser): stop reporting an unhydratable profile as a retired choice

A profile that fails validation for a reason unrelated to identity — a non-UUID
id, a mismatched partition — armed both the notice and its degraded flag. Since
hydrateFromPersisted skips such entries silently and nothing ever repairs them,
the user got "an old browser identity choice could not be inspected" forever,
about a profile that never carried one.

Key the notice on the presence of userAgentMode instead, and use validation only
to decide whether the choice that was found is inspectable. Refusing to hydrate
an entry and finding a retired choice are now separate facts.

The old case table asserted the defect for null, 42 and 'broken', so it is
replaced by two tables stating the new contract rather than adapted to pass.

* fix(browser): stop rewriting worker requests for viewport emulation

A worker request carries no webContentsId, so it always took the session-wide
branch and picked up the mobile UA if any tab in the session had a mobile
preset. That made a single context disagree with itself: a desktop tab's shared
worker reported a desktop navigator.userAgent — the per-target CDP override
cannot reach a worker — while its fetches left as CriOS. It also leaked across
tabs, and closing the emulated tab silently reverted it.

On main the divergence was between contexts, each internally coherent. Making
one context internally inconsistent is worse by this PR's own standard, so
accept that viewport emulation reaches documents only. Workers keep the session
identity on the wire, which is the identity they report in JavaScript.

That left hasSessionMobileViewportIntent with no reader, so the map it fed and
its three accessors go too, rather than leaving a dead latch behind the guard.

The electron fixture models this rule in its own header hook, so its hook and
both mobile arms are rewritten around the invariant that each context's wire
identity equals the identity its own JavaScript reports — not adapted to keep
the old path list passing.

* test(browser): point the identity tests at keys and writers that exist

browserUserAgentMode appears in zero production files and zero commits on main;
`git log -S` finds nothing. The retired key is profile.userAgentMode inside
browser-session-meta.json. Two tests were built on the invented one.

The global-settings test is deleted rather than repointed: no browser identity
key has ever lived in global settings, and stripRetiredGlobalSettings strips
only three unrelated keys, so the test asserted that an arbitrary unknown key
survives an object spread — a fact about the normalizer, not about identity.

The ready-phase test asserted on writeFileAtomically while the identity store
writes through writeFileDurableSync, so it could not go red for the write it
existed to forbid. It now watches the real writer, matched on the record path so
an unrelated durable write cannot fail it for the wrong reason, and the invented
settings key is gone from the Store mock.

Proven by ablation: injecting a byte-identical rewrite of the record into ready
composition leaves every snapshot and record assertion green and is caught only
by the new assertion, while writeFileAtomically is never called.

* fix(browser): let an unavailable process identity reject instead of throwing

installBrowserSessionPartitionPolicies returned Promise<void> without being
async, and configures the user agent policy before any suspension point.
getBrowserProcessUserAgentIdentity throws when the process identity was never
initialized, so that throw escaped synchronously past every caller's handler:
`void install(...).catch(...)` in the registry, and a bare `void install(...)`
in the route policies, which has no handler at all.

Bookkeeping must never gate a user action. Session startup would have died on a
failure its callers were already written to absorb and report.

* docs(browser): scope the meta-store claim about dropped legacy keys

The comment said persistMeta drops legacy keys on the next write because the
loader no longer carries them. That holds for the top-level userAgent keys it
describes, but not for the retired per-profile userAgentMode: it sits inside
each BrowserSessionProfile in `profiles`, which is carried through untouched, so
those bytes survive every write.

Retaining them is deliberate — it is what makes rollback and data-loss machinery
unnecessary, and the startup notice keys on their presence — so the comment read
as broader cover than it provided, in the one place someone would look before
deciding it was safe to strip them.

* test(browser): pin the unmapped-webContents path beside an emulated tab

A popup carries a webContentsId that maps to no registered tab, so it resolves
through the same branch as a worker request that carries none at all. The branch
already handled both, but only the absent-id case was covered.

* test(browser): make the ordering fixture exhibit a multi-word app name

This file sets the dev app name to "Orca Development" and then used a
single-token user agent fixture, so it set up the multi-word scenario and used a
fixture that could not exhibit it — which is how the multi-word app-name leak
got through. The fixture now carries a two-word app token, matching what
app.setName produces in dev, and the assertion names both words: a single \S+
match would leave "Orca" on the wire and still pass a one-token check.

* test(settings): cover the local branch of the browser identity setting

The only existing test covered the remote-host branch. The local branch — load,
select, refused write, and reset-required — had none, and that is the path the
retired-identity notice sends users down to make the choice that retires it.

Covers the selected-mode render, the commit that reports restartRequired, a
refused write surfacing its message without showing the mode as changed, and the
reset-required state offering no control.

* test(browser): run the real registry path in the ready identity pin

The test stubbed browser-session-startup and browser-session-registry, which are
the one ready-phase path that can write the identity record, so the record
content assertion could not fail for the write it existed to forbid.

Both are now real. Only the pieces hanging off the identity path are stubbed —
partition policies, route sessions, cookie staging, webauthn — so the meta load,
the retired-choice inspection, the identity store and the durable write all run
for real against temp directories. The canonical path mock moves to
persistence/loading-store/user-data-path, which is where the registry reads it;
mocking persistence alone left the registry pointed elsewhere. The active
profile directory is now a real temp dir, so the seeded browser-session-meta.json
is actually found — against the old /test-profile literal the meta load found
nothing and the whole exercise would have been vacuous.

A third case proves the path is live: with no explicit choice, the same retired
profile arms the notice through ready and lands migrationNoticePending on disk.
The two authority cases assert the opposite, that an explicit choice leaves the
record untouched.

initializeBrowserSessionsForApp latches on module state, so each case resets
modules and imports ready dynamically.

Ablated: disabling the explicitSelection gate turns both authority cases red on
the record content assertion while the arming case stays green.

* fix(browser): reject an unrecognized identity mode at the IPC door

normalizeBrowserUserAgentMode turned any unrecognized value into 'clean', so the
IPC door reported success for a mode it had quietly replaced, while the RPC door
validates against z.enum(['clean', 'native']) and rejects. One concept answered
an unknown value two different ways, and a future mode name was silently
downgraded rather than refused.

The handler now rejects, which is what the RPC door does and what the renderer
already handles — its catch puts the message in the error slot. Returning a
result instead would have meant inventing a fourth error code for a case no
legitimate caller can reach.

normalizeBrowserUserAgentMode had no other consumer, so it goes with the change:
leaving a coercion helper called "normalize" in shared/ invites the behaviour
straight back in.

* fix(settings): name the reset command where identity data is unusable

When configuredMode is null the setting says identity data must be reset
explicitly and then offers no control, because the reset overwrites data that
may belong to a newer Orca. The only escape is the CLI, which the message never
named — so it told the user to do something and gave them no way to do it.

Copy only: one line naming the command, no control and no destructive action in
the UI. The command goes in a new key beside the existing sentence rather than
expanding its default, which keeps the already-translated string valid.

No en.json entry: this component has no catalog entries for any of its keys, so
English resolves from the call-site defaults and adding one only for the new key
would be inconsistent with its siblings.

* fix(i18n): add the browser identity keys to the localization catalog

* fix(i18n): regenerate the runtime-required English catalog

* fix(browser): attach nested CDP targets paused before enabling Network

An OOPIF or dedicated worker was reached only through Target.targetCreated plus
an explicit attachToTarget, which never pauses the target. The frame could issue
its subresource fetch before Network.enable took effect, so the capture came back
empty and the cross-context assertion failed under CI load.

Re-arm auto-attach on each attached session, filtered to nested target types, so
an OOPIF or worker arrives waiting for the debugger and its enables are ordered
ahead of the resume. Drop the explicit attach, which is now both redundant and
the racy path.

* fix(settings): localize the browser identity search keywords

* fix(browser): await route policy setup

* fix(browser): satisfy strict static analysis

* test(browser): update live identity fixture API

* test(browser): preserve native UA in live probe

* fix(browser): close the open review findings on the identity revert

- drop a stray JSDoc left over from the removed per-profile setting
- leave user agents without a Chromium engine comment byte-identical
  instead of anchoring the app-token strip on the OS comment and
  destroying a real engine token
- localize the browser identity unavailable error
- correct the worker comment: only shared and service worker requests
  carry no webContentsId, so emulation still reaches dedicated workers
- retire the session user agent policy when a profile is deleted

* test(browser): model a real Electron fallback in the startup UA fixture

The ordering fixture carried no "(KHTML, like Gecko)" engine comment, a
shape app.userAgentFallback cannot actually produce. That unfaithfulness
was what made the old over-stripping look correct, and it broke once the
cleaner started leaving non-Chromium identities alone.

Add the engine comment, keeping the two-word "Orca Development" app token
so the multi-word leak this test exists to catch is still caught. Both
assertions are unchanged.
2026-09-16 10:31:01 -07:00
Jinwoo Hong b997fcc77a fix(session-search): try phrase and AND routes for prose queries before OR (#20754)
* fix(session-search): try phrase and AND routes for prose queries before OR

An exact sentence pasted out of a transcript was not returned. The route
ladder only ran the phrase and AND rungs for a literal-looking query, so
prose fell straight to OR, where the sentence's common words filled the
candidate limit with recent sessions and the old session holding the
sentence never reached ranking.

The planner now carries a `phrase` candidate: the query's tokens in order
with stop words kept, which is what the sentence is actually indexed as.
The ladder runs phrase then AND over those tokens for every query of two
or more tokens. A one-token query still takes the rung only when it
looked literal. `incomplete` is reported by the rung that answered rather
than accumulated across every rung tried.

* fix(session-search): mark a snippet with the route that retrieved it

A phrase hit was highlighted with the OR expression over the stop-word
stripped terms, so an exact sentence rendered as scattered bold words with
its stop words plain. The snippet now uses the expression the route
matched by: one run for a phrase, every typed word for AND, the terms for
OR.

* fix(session-search): repair a prose phrase without dropping its stop words

Typo repair re-planned the query from `plan.body`, which prose has already
had its stop words removed from. `relay is droppng frames` therefore came
back as the plan for `relay dropping frames`, and the phrase rung searched
for a sentence nobody wrote: the transcript holds `relay is dropping
frames`, so the exact match fell through to AND.

The repair now maps over `plan.phrase`, the tokens as typed, and re-plans
from those. Only terms the body holds are offered to the corrector, so a
stop word is still never repaired, and the re-plan recomputes the body
from the corrected sentence exactly as before.
2026-09-16 12:27:05 -04:00
Jinwoo Hong 3e5eb0329a feat(cli): orca search over the agent session index (#20514)
* feat(cli): orca search over the agent session index

`orca search <query>` calls PR 5's `aiVault.searchSessions` over the CLI's
existing runtime RPC, against the host `--environment` / `--pairing-code`
selects and no other. `orca search --index-status` calls `aiVault.searchStatus`.
It is the proof the contract works with no panel.

Every flag maps onto a contract field and nothing else: `--scope`, `--fresh`,
`--limit`, `--cursor`, repeatable `--agent` and `--path`, `--since`, `--sort`,
`--debug`, `--json`. No fan-out, no merged output, no `--host`.

One command rather than a `search status` subcommand: the query is a bare
positional, so `orca search status` could not be told apart from searching for
the word "status". `--status` is unavailable because `orchestration task-list
--status <state>` already owns the name as a valued flag.

No new runtime capability. PR 5 decided an explicit `method_not_found` refusal
maps to `unavailable/no-service`, so reusing `createSessionSearchClient` gives
an old host a plain "this host runs no session search service" answer at exit 0
instead of a raw JSON-RPC error.

`CommandSpec.repeatableFlags` scopes repeatability per command, because
`--agent` must repeat for search and stay single-valued for `worktree create`.
`help.ts` sat exactly at max-lines, so `skills-command-flag-help.ts` becomes
`command-scoped-flag-help.ts` carrying both tables at the same call-site size.

* refactor(cli): drop the search type assertions main's casting gate now rejects

Main gained a `consistent-type-assertions: never` scan in the changed-code gate
after this branch was cut, and it reported twelve assertions in the new files.

The four in the argument parser were avoidable. `readEnum` now keeps the value
`find` returns, which already carries the narrow type, and the agent filter goes
through an `isAiVaultAgent` predicate over a `Set<string>` instead of widening
the agent tuple.

The test now narrows the printed envelope by shape and re-reads the printed
result through `AiVaultSearchResponseSchema`, so the JSON assertions are checked
rather than claimed, and the flag table is typed so its callback needs no cast.
One assertion is left, for the structural fake client, with the SAFETY rationale
AGENTS.md requires.

* fix(cli): sanitize host strings and scope pre-command repeatable flags

Route every host-supplied string the search formatter prints through the
escape stripper, and resolve the repeatable-flag set from the command
tokens ahead when a flag sits before the command.

* refactor(cli): resolve repeatable flag rules once per command

* fix(cli): clarify session search availability and SSH scope

* feat(cli): hide orca search until the settings toggle ships

`orca search` stays dispatchable but leaves every discovery surface: root
help, group help, unknown-command suggestions, and `agent-context --json`.
`buildAgentContext` did not filter hidden specs, so it also stops leaking
the hidden `terminal stop`.
2026-09-16 12:03:38 -04:00
Jinwoo Hong dec0e2cd56 feat(session-history): add local search settings and index controls (#20582)
* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(settings): live index status, enable confirm, advanced delete

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 11:59:22 -04:00
github-actions[bot] 0b28d354fe Update README downloads badge 2026-09-16 12:37:45 +00:00
Brennan Benson 291b4ddd6f feat(agent-status): route structured sessions through canonical ownership (#20718)
* feat(agent-status): route structured status through canonical ownership and fence child lifetimes

Restacked onto the canonical store and child-work contract. Completing that
restack drops the `reopenStructuredParent` mutation flag this change had
carried, along with its contract field, its codec branch, and its single
call site in structured ingest, which passed a hardcoded `true`.

The flag was a narrow escape hatch from the absolute `tombstones.has(...)`
rule that governed parent upserts in this branch's original base. The
canonical store replaces that rule with a revision envelope, because a
bounded store compacts tombstones away and a presence-based guard silently
stops fencing once one is evicted. With the envelope deciding the outcome,
the escape hatch has nothing left to escape from, so removing it changes no
production behaviour.

`agent-status-store-reopen.test.ts` is rewritten against the envelope: the
reopen case now pins that an unflagged republication succeeds while replay
from before the reopen stays fenced even after the parent tombstone is
compacted away, and the second case pins where the guard genuinely bites —
a republication inside the removing mutation itself, for every subject kind.

* fix(agent-status): re-admit unchanged structured owners after teardown

* fix(agent-status): clear anti-slop object-param and Reflect.apply findings

- agent-status-store-byte-budget.ts: type the byte-budget helper's
  record parameter as the union of what its call sites actually pass
  (the snapshot header plus each store entity record) instead of the
  broad `object`.
- server-structured-canonical-status.test.ts: replace `Reflect.apply`
  with a typed, explicitly-bound call that models a caller at an
  untyped boundary omitting the trusted owner subject.

* docs(agent-status): drop the 2A progress doc from docs/reference

docs/reference/ holds implementation detail, not rollout progress. The
canonical-boundary notes move to the effort's working directory; the
agent-status-store status section keeps the boundary statement and loses
the now-dangling link.

* fix(agent-status): mint the canonical epoch on first use, not at construction

The hook server's canonical store was built in an instance-member initializer, so
constructing AgentHookServer — which happens at import time for the module
singleton — demanded a live randomUUID. Any importer that stubs node:crypto threw
'Invalid agent status store epoch' before a single test ran.

The store is now created on first canonical access and reset by dropping it, so
construction owes nothing to a crypto implementation and the epoch still rotates
per authority incarnation.

* fix(agent-status): drop the orphaned snapshot budget and a duplicated pane guard

Two leftovers from the canonical-store routing change:

agent-status-store-snapshot-budget.ts lost its only caller when the store state
switched to agentStatusStoreFitsByteBudget. Nothing in the repo imports it now,
so the module goes with the caller it existed for. The replacement is not a
straight copy: it only memoises a record's measured size once the record is
frozen, so a still-mutable record can no longer return a stale byte count.

persistedStructuredWorkerPaneKeyIsValid repeated its public-pane-key rejection
verbatim three lines below the first one. The tests covering that rejection pass
on the first occurrence alone, so the second decided nothing and only obscured
which predicate was load-bearing.

* fix(agent-status): stop a failed structured publish from latching as owned

Three defects found reviewing the structured routing path.

combinedStatusEntries defaulted a missing listing order to 0, but the counter it
compares against starts at 1, so any unordered row sorted above every ordered
one. Unknown order now sorts last.

The owner map recorded a session as owned before the sink ran. A publish that
threw therefore left matchesLocation reporting an owned location for a row that
was never written, and the unchanged-projection path — the only thing that would
re-offer it — stopped. The address still has to survive a throw so teardown can
forget a row that did land, so the two facts are now separate: the address is
recorded up front, and only a publish that returned marks the row as landed.

The reopen test claimed the revision envelope rather than the tombstone fences a
stale replay. It cannot tell: transport consecutiveness, the parent-revision
validator and the tombstone guard each refuse that replay alone, and ablating any
two leaves the test green. It now asserts the outcome and says so.
2026-09-16 01:20:58 -07:00
OrcaWinandm4air 16ac9018db docs: remove unavailable diff shortcuts (#20974)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-15 23:44:23 -07:00
Neil 13ba649c22 fix(terminal): let a runtime-created Windows terminal BE the requested shell (#20825)
* fix(terminal): let a runtime-created Windows terminal BE the requested shell

`orca terminal create --environment <windows-host> --command 'cmd.exe'` never
created a cmd terminal. `--command` is text the provider TYPES into whatever
shell it spawned, so the PTY stayed the host's default shell with cmd running
inside it. Captured on `awin`, whose default is Git Bash:

    $ orca terminal create --environment awin --command 'cmd.exe' --json
    $ orca terminal send --environment awin --terminal term_10656cf7... \
        --text exit --enter
    $ orca terminal read --environment awin --terminal term_10656cf7... --screen
      neil@awin MINGW64 ~/orca/orca ((30f820708f...))
      $ cmd.exe
      Microsoft Windows [Version 10.0.26200.9445]
      C:\Users\neil\orca\orca>exit
      neil@awin MINGW64 ~/orca/orca ((30f820708f...))
      $

The handle is alive the whole time and `terminal list` shows one healthy
terminal, because the PTY never changed — so the only symptom is that the
caller's terminal is now a shell it never asked for, and every later `send` is
quoted for the wrong one. On `win-lowspec` (default pwsh) the same create lands
cmd inside PowerShell.

Root cause
----------
There are two spawn preflights and they are twins:

- `src/main/ipc/pty/ipc/spawn-preflight.ts` — renderer/IPC spawns, i.e. a
  terminal tab opened in the app.
- `src/main/ipc/pty/runtime/spawn-preflight.ts` — runtime spawns: the CLI's
  `terminal.create`, headless `orca serve`, and every paired remote
  environment.

Only the IPC twin read the caller's requested shell. The runtime twin passed a
literal `requestedShellOverride: undefined`, so a runtime-created terminal on
Windows could only ever be the host default. Everything downstream of that
point — `spawn-options`, the daemon, `resolvePtyShellOverride` in the relay,
`local-pty-launch-plan` — already honoured `shellOverride`; nothing upstream
could supply one.

Change
------
- Thread `shellOverride` through the runtime lane: `RuntimePtySpawnArgs` ->
  runtime `spawn-preflight` -> `RuntimePtyController.spawn` ->
  `TerminalCreateOptions` -> the `terminal.create` RPC's new `shell` param ->
  `orca terminal create --shell`.
- Thread it through the renderer-backed lane too (`createDesktopTerminal` ->
  `terminal:requestTabCreate` -> `store.createTab`), so `--shell --focus` is not
  silently dropped on a local Windows app.
- An agent launch quotes its startup command for the shell it will actually run
  in, so a requested shell now owns the startup-shell family instead of the
  global `terminalWindowsShell` setting.
- Lift the relay's `ALLOWED_WINDOWS_SHELL_OVERRIDES` into
  `isSupportedWindowsShellOverride` in `src/shared/windows-terminal-shell.ts`
  (membership unchanged) so the CLI, the zod param schema, and the relay refuse
  the same names. `--shell` therefore cannot carry a path or a command line into
  `pty.spawn`; only allowlisted bare shell names pass.
- Gate on `TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY`. An older host
  strips the unknown `shell` param and answers with a healthy terminal running
  its default shell — a reply indistinguishable from success — so the CLI
  refuses before creating anything rather than creating the wrong shell quietly.

`--shell` stays Windows-only; macOS and Linux hosts spawn the login shell and
the relay drops the value off win32 rather than honouring it half-way. A WSL
project runtime still outranks it, unchanged.

Tests
-----
- `pty-spawn-shell-override-parity.test.ts` pins both preflights against the
  exact drift that caused this (verified failing with the fix reverted).
- `createTerminal` passes `shellOverride` to `ptyController.spawn` with no
  startup command.
- CLI: sends `shell`, refuses a shell the host cannot spawn, and refuses a host
  without the capability — in both refusals without making the round trip.
- Allowlist and `terminal.create` schema accept/refuse cases, including paths
  and appended arguments.

* fix(terminal): refuse a requested shell the execution host cannot apply

The first commit made `--shell` reach the spawn, but only a LOCAL win32
execution host applies it: `spawn-options` gates the override on
`process.platform === 'win32' && !args.connectionId`. So `--shell cmd.exe`
against an SSH-routed worktree, or against a macOS/Linux host, still returned a
healthy terminal running that host's default shell — the same
indistinguishable-from-success reply the capability gate exists to prevent, one
layer down.

Refuse instead, before anything spawns. The check sits at the top of
`resolveAgentTerminalCreateOptions`, which every create lane funnels through, so
neither lane has to remember it; the desktop lane additionally refuses a
worktree-less create, which has no execution host to resolve a shell on.

An SSH host's platform and installed shells are not visible to this runtime, and
a POSIX host has no Windows shell to pick. Neither can honour the request, and
saying so is the whole point of the flag.

Docs and the CLI spec now say "refused", not "ignored".

* fix(terminal): refuse a shell that contradicts the project execution runtime

`resolveLocalWindowsTerminalRuntimeOptions` does not merely rank the project's
execution runtime above a per-terminal pick -- it REWRITES the pick, in both
directions, and says nothing:

- a WSL project forces `wsl.exe`, discarding `--shell cmd.exe`;
- a Windows-host project discards a WSL name and falls back to `COMSPEC`
  (`getHostShellForProjectRuntime`), so `--shell wsl.exe` spawns cmd. That is
  the common case, not an edge: `resolveProjectExecutionRuntime` resolves
  `windows-host` for every project that is not WSL, while a repo belonging to no
  project honours `wsl.exe` -- so the same flag behaved differently depending on
  whether the repo was in a project.

Either rewrite returns a healthy terminal running a shell the caller did not ask
for, which is the failure `--shell` exists to remove.

It also split an agent launch's quoting from the shell that receives it. The
previous commit made the startup-shell family follow the REQUESTED shell, so
`--shell wsl.exe --command codex` on a Windows-host project typed POSIX-quoted
launch args into cmd. Refusing the contradiction removes that case rather than
papering over it.

Refuse instead, alongside the SSH and non-Windows refusals, from the same
`resolveAgentTerminalCreateOptions` seam every create lane funnels through.

Also from review:
- the allowlist test looped the list against itself; spell the members out.
- the runtime spec case claimed to prove the pty's shell when it asserts the
  controller received the field; name it for what it checks.

Reported by an adversarial review of the branch.

* fix(terminal): canonicalize --shell and refuse a WSL-path rewrite

Review of the --shell create path turned up two ways the terminal could
still end up being a shell the caller never asked for -- the exact failure
--shell exists to remove.

Bare and mixed-case spellings passed the allowlist but reached consumers
that exact-match the canonical name: resolveWindowsShellStartupFamily
classified `cmd` as the PowerShell family, resolveWindowsShellLaunchArgs
fell through to empty shellArgs (no `chcp 65001`, no OSC 133 bootstrap that
Windows foreground status depends on), and resolveWindowsGitBashShellPath
compares case-sensitively so `Git-Bash` spawned a literal `Git-Bash`.
The allowlist is now one canonical-name map and terminal.create canonicalizes
on parse, so the spawn path only ever sees `.exe` spellings. `pwsh` and
`powershell` stay distinct binaries.

A `\\wsl$\<distro>\...` cwd made the providers force wsl.exe regardless of
the request, and terminalShellOverrideRefusal only inspected the project
runtime -- undefined for a folder workspace with no project. Refuse on the
resolved cwd and the workspace path, judging what the PTY actually gets.

Also: the capability gate reported an unreachable host as too old rather
than unavailable; the SSH CLI shim dropped capabilities from status, so
--shell there blamed the host version instead of naming SSH; and --shell
had no help entry, rendering bare in `orca terminal create --help`. Adding
that entry crossed help.ts's max-lines cap, so the flag table moved to
flag-help-text.ts rather than suppressing the rule.

Adds a behavioural test for the runtime preflight (the one-line fix was
pinned only by a source-text scan), plus coverage for the startup-command
quoting family, the no-workspace refusal, and the WSL-path refusal.

* fix(build): keep tests out of the RPC params catalog bundle

The catalog walk under methods/ already skips *.test.ts, but the contract
directory glob took every .ts. terminal-create-shell-param.test.ts is the
first test to live there, so the bundle pulled vitest into a CJS build and
the generator threw on require(). Same exclusion, same reason.
2026-09-15 16:34:16 -07:00
github-actions[bot] f742ab88d2 Update README downloads badge 2026-09-15 18:29:06 +00:00