Commit Graph
8992 Commits
Author SHA1 Message Date
Jinjing 4e058d4a52 Fix flaky CI tests by adding retry logic and increasing timeouts (#15635)
* Fix flaky CI tests by adding retry logic and increasing timeouts

Add Electron launch retry for CI runners where startup wedges before
reaching 'ready', with fresh profile per attempt to avoid mid-init state.
Increase skill install lock timeout from 100ms to 5s to account for
fsync cost plus retry duration on loaded CI runners.

* shorten comments
2026-08-20 12:53:44 -07:00
JinjingandJinwoo-H 6e25a90085 fix(terminal): keep a quick command queued until its own spawn takes it (#15630)
* Increase shell readiness timeout to match daemon barrier

Slow interactive rc files can take longer than 1.5s to initialize. Raise
the startup command readiness timeout from 1.5s to 15s to match the daemon
barrier and prevent queued commands from executing mid-startup.

* fix(terminal): keep a quick command queued until its own spawn takes it (STA-4876)

Triggering a quick command opened a terminal tab titled with the command's
label, the shell started and drew its prompt, and the command never ran.

TerminalPane snapshots `pendingStartupByTabId[tabId]` in a useState lazy
initializer, and a mount effect deleted the entry immediately. The pane's key
is `${tab.id}-${tab.generation ?? 0}`, so anything that bumps generation before
the command reaches a shell — the stall-recovery remount fired from
`requestTerminalPaneRecovery`, or the allDead activation regeneration — mounted
a second pane that re-read an emptied slot and spawned with no command at all.
The loss was permanent, which is why every scope failed alike: repo, global and
agent-prompt all funnel through the same queue-then-snapshot sequence.

Spend the entry at `onPtySpawn` instead, which is the one point that proves this
pane's own fresh spawn exists. A pane retired mid-connect never reaches it, so
the command stays queued for the next mount; reattach skips `onPtySpawn`, so it
cannot spend a command it never delivers.

Three details are load-bearing:

- Ownership is reference identity (`paneOwnsQueuedStartup`). Setup and issue
  splits borrow the same `deps.startup` field for their own one-shot payload, and
  that payload can be structurally identical to the queued command, so a
  truthiness test would let a split pane spend a command it never runs.
- The consume runs after `bindActivePanePty`. While the tab still has no ptyId,
  the queued entry is the only thing holding its worktree out of the
  retention-budget force-park, so dropping it first unmounts the pane mid-spawn.
- The callback is one-shot. `onPtySpawn` fires on every fresh spawn a pane makes,
  including hibernation wake and the respawn ladder, and a command queued after
  the first launch belongs to that later launch.

Known residual, documented at the call site: the consume tracks "a pty exists",
not "the command ran". Windows embeds short commands in the shell argv, so they
execute before the spawn resolves and a pane retired in that window re-delivers
on remount; on POSIX the write waits for shell-ready, so a pty that dies in that
window loses a command already spent. Closing either needs a delivery signal
from main rather than this callback. Both windows are narrow, and both are
strictly better than losing the command unconditionally.

* fix(terminal): guard the queued-startup wiring the review found untested

Follow-ups from the final review pass on this branch.

- Collapse the ownership + one-shot decision into `createQueuedStartupConsumer`
  so the call site is a single call rather than inline logic no test could
  reach. Two mutants survived the whole suite before this: relaxing ownership to
  a truthiness check, and dropping the one-shot guard. Both now fail.
- Rewrite the throwing-consume test. It asserted `updateTabPtyId` had been
  called, which runs *before* the callback, so it passed with the try/catch
  deleted. It now asserts the throw does not escape into the connect promise,
  which is the invariant the try/catch actually provides.
- Correct the `onQueuedStartupSpawned` docblock. It claimed the callback is "the
  first moment the command is guaranteed to reach a shell"; the diff's own caveat
  says otherwise, since Windows runs an argv-embedded command before this fires
  and a POSIX shell can die before the shell-ready write. It marks a live shell,
  not delivery.

No behavior change: the consumer is the same predicate and the same one-shot,
moved behind one exported seam.

* fix(terminal): roll shell wrapper isolation into a fresh daemon

* Revert "Increase shell readiness timeout to match daemon barrier"

This reverts commit 6ab273ef36.

* Condense queued startup spawn comment

Simplify the multi-paragraph explanation into a concise summary that captures the key points: spawn must wait until after the pane is bound to preserve the worktree from force-parking, and the behavior differs between POSIX and Windows for delivery timing.

* fix(terminal): prevent consuming replaced queued startup commands

When a queued startup is replaced before the pane's first spawn,
the one-shot guard alone still allows consuming the replacement.
Add isStillQueued callback to verify the slot still holds the
originally captured command (STA-4876).

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-08-20 12:15:08 -07:00
e41074cb1f docs(readme): add Android install guide link to README.ko.md (STA-4817) (#15566)
Taken from #15396. Matches the English README's install-guide link added in #14978.

Fixes #15395

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: erishforG <eric.signal@kakaocorp.com>
2026-08-20 10:45:08 -07:00
Jinjing d8e9fa1bb9 Revert "fix(terminal): apply pane padding on all four edges (#15544)" (#15623)
This reverts commit 4b2ed5ddd4.
2026-08-20 09:57:55 -07:00
github-actions[bot] 0f26ff4ad8 Update README downloads badge 2026-08-20 12:28:13 +00:00
Neil feaebabf2c test(terminal): pin the two conditions that keep macOS period substitution out (#15393)
macOS rewrites a double space into ". " and hands the period to the pty.
Orca is not immune to that: a plain Chromium textarea in the Electron
version pinned here does substitute, measured on hardware with real key
events, and spellcheck="false" does not prevent it. What prevents it is
that the forwarder claims a plain space keydown and then empties the
helper textarea, so the text system never sees the word preceding the
space.

Neither half was a decision. Before 01bcc8dca2 the claim predicate
excluded space, letters and digits, the field accumulated, and the
substitution fired on real hardware. That commit widened the predicate
for unrelated reasons - IME commit survival and kitty encoding - and
suppressed this as a side effect it never mentions. The predicate
already declines on modifiers and during composition, so a later
narrowing would return the bug with nothing to catch it.

This does not test the substitution, which a unit environment cannot
produce. It tests the two conditions measured to suppress it. Verified
non-vacuous: narrowing the predicate back toward punctuation fails three
of the four, and removing the blanking fails the fourth.

Refs #11504
2026-08-20 03:08:29 -07:00
NeilandBrennan c92f394cde fix(pty): delete the reply-withholding scheduler (#15578)
* fix(pty): answer a terminal colour query in its own turn

Root-cause follow-up to #15559, which stopped a CPR overtaking a deferred
colour reply but left the deferral itself in place.

Orca answers terminal queries by writing to the PTY master, which a line
discipline in ECHO copies straight back out as junk on a cooked prompt
(#12112). The guard was to withhold the write until an `stty` subprocess
proved ECHO clear — and forking is what forced the decision to be async.
Any deferral, however short, lets a reply written later in the same turn
overtake this one, so the async probe was the bug's root cause.

Read the bit synchronously instead. Linux and the BSDs redirect a
master's mode ioctls to the slave, so a `tcgetattr` on the master fd
node-pty already owns answers for the slave with no fork: measured 0.26us
against 2403us for the subprocess. With a verdict available inline, a
querying program that already cleared ECHO — every raw-mode prober,
including the colour probe behind the `gh auth login` report — is
answered in its own turn and can never be reordered.

The deferral stays for the genuinely cooked case, and the ordering
guarantee stays underneath it: hosts whose node-pty predates this patch
get no sync probe and fall back to the deferred path, which mixed
client/host versions make a live production path.

Reply routing is all-or-nothing: a payload needing neither containment
nor ordering stays on the host's own path, so a CPR answered during shell
startup cannot pass the daemon's post-ready flush gate and splice into
the buffered startup command.

Native side is fail-safe: a kernel that did not redirect would answer
from the master's own termios, whose ECHO defaults set, so the degraded
verdict is "echoing" — never a false "quiet". The JS half ships in the
pnpm patch while the binding needs a source build, so
ORCA_REQUIRE_NODE_PTY_ECHO_STATE=1 makes CI fail rather than silently
skip when it is handed an upstream prebuild.

Co-authored-by: Brennan <brennanb2025@users.noreply.github.com>

* fix(pty): keep the flush ordered under synchronous re-entry

Three defects found in external review of the reply-ordering work.

node-pty delivers onData inside the master write, so a query can be
answered while the queue is mid-flush. `flushPendingWrites` spliced the
array off before writing, so that reply saw an empty queue, took the
same-turn path, and landed ahead of entries the loop had not written yet
— reproduced as 01, 99, 02, 03. It now shifts one entry at a time so a
re-entrant reply queues behind the rest, bounded by the length at entry
so a re-entrant push cannot spin the loop.

An overflow flush can re-enter as far as teardown. `answer` did not
re-check `closed` afterwards, so it queued behind a closed delivery,
returned true, and the reply was never written and never reported.

The payload router's ownership comment overstated its guarantee. The
`any` semantics are deliberate — returning false after a constituent was
already written would have the caller re-write the whole payload and
duplicate it into the child's stdin — so the residual mixed-failure drop
is now documented rather than implied away.

* fix(pty): delete the reply-withholding scheduler

Orca answered a terminal query by withholding the write until a probe
proved the slave's ECHO bit was clear. That was the wrong mechanism, and
it is now gone: replies are written in the caller's turn and their echo
is contained on the output side, where it always was.

Withholding never removed an echo. The wait was bounded and always ended
in a write, so the output-side projections were doing the work the whole
time — including the readline rewrite, which happens with the tty already
raw and which therefore no reading of the ECHO bit can predict. What
withholding did add was an asynchronous write path, and that is what let
one reply overtake another and land in the next program's stdin (#15559),
what produced a re-entrancy inversion inside its own flush, and what four
rounds of regressions have lived in.

The last thing it covered was the verbatim echo of a `stty -echoctl` tty.
That shape is now projected directly. It starts with ESC, so it is
matched only when complete and never held as a partial: holding it would
take a bare trailing ESC from the query parser and an expired hold would
release it raw, so a query torn at its own ESC would never be answered.
Complete-match-only is what makes the shape safe to project at all.

Measured on a real pty: a cooked-mode master write is both echoed AND
delivered — ECHO copies the bytes without consuming them from the slave's
input queue, so a program arming raw mode with TCSANOW/TCSADRAIN (libuv's
setRawMode, hence every Node agent) still reads them. Only a TCSAFLUSH
switcher discards it, which it does on every terminal, none of which
gates a reply on termios state.

Deletes the pending-write queue, the async stty probe, the poll budget
and probe rate limit, the deadline-driven flush, and the answer/
answerInOrder split. Replies now leave in call order by construction.
No packaging, native or CI surface is touched.

* test(pty): restore stty-probe coverage and pin the duplicate-query retry

Archaeology on how withholding got here, and what its tests were really
protecting.

Deleting the ECHO probe took four tests with it that were not about the
probe at all: they cover createSttyProbe, which the shell-readiness
line-editor probe still uses — in-flight sharing, the per-platform stty
flag, and transient-versus-permanent failure latching. Restored against
the line-editor probe, which is now their only caller.

Also pins the property that answers the one case an immediate write
cannot serve. A program that queries while cooked and then arms raw mode
with TCSAFLUSH discards the reply with the rest of its input queue.
Nothing can prevent that from the terminal side, and no terminal tries.
What matters is that such a program re-queries after its own timeout: the
ingress declines to answer an already-answered slot but forwards the
duplicate downstream, so the renderer's emulator answers the retry, by
which point the program is raw. The retry path is the recovery, not
withholding.

* ci(pty): keep the fish real-PTY test in the shell-contracts lane only

Reverting pr.yml to main dropped the exclusion for the fish query-reply
test, which this branch keeps, so it would have run in the sharded lane
as well. Restores it to the shell-contracts include list and the shard
exclude list, and drops the parallelism expectations for the deleted
cooked-querier suite and the echo-state env guard.

---------

Co-authored-by: Brennan <brennanb2025@users.noreply.github.com>
2026-08-20 02:15:42 -07:00
Brennan Benson 21b66197be fix(worktrees): cover WSL distro history and bound the retirement backfill scan (STA-4472, STA-4473) (#14924)
* fix(worktrees): cover WSL distro history and bound the retirement backfill scan (STA-4472, STA-4473)

* fix(worktrees): bound outstanding retirement backfill listings, not just their rate

The scan deadline abandons a listing, it cannot cancel one: an unabortable readdir
keeps its libuv threadpool thread until the OS releases it. The failure backoff paced
retries but never counted the abandoned calls, so a mount that stayed wedged stacked a
new stuck thread on every lapse until the four-thread pool starved every other
filesystem user in the process. UNC reads were already bounded by the WSL gate's
permit accounting; plain SMB/NFS listings were not.

Cap the outstanding listings process-wide and serve the memoized failure once the cap
is reached. Recovery is preserved: a slot frees as soon as an abandoned listing settles.

* fix(worktrees): keep a late retirement listing, and stop deferrals penalising healthy repos

Three review findings on the scan bound:

- A listing that landed after the 15s deadline had its result discarded. Under the WSL gate
  that is the common case, not an edge one: the gate admits a single scan at a time and allows
  it 60s, four times this deadline. The namespace was then left unseeded on exactly the mounts
  this feature exists to cover. The answer is now kept when it arrives.
- A namespace deferred at the outstanding-listing cap never touched the wedged mount, so arming
  its backoff spread one bad mount's outage to repos on healthy disks. Deferrals no longer
  memoize; the next create retries as soon as a slot frees.
- The cap comment claimed it bounds threadpool starvation. It bounds this module's share only;
  the WSL gate and its close lane hold threads of their own. Comment corrected.

Also stub the WSL resolver in the UNC-root unit test, which otherwise shells out to wsl.exe and
boots the developer's distro on a Windows runner.

* fix(worktrees): only count stuck retirement listings, and fence the backoff on a monotonic clock

Two more review findings on the outstanding-listing cap:

- The cap counted every in-flight listing, not just the stuck ones, so it fired on healthy
  machines. Nested workspaces give each repo its own scan key, so a few first-time backfills are
  routinely in flight together; the third was rejected and its create then picked a name against
  an unseeded registry. Only listings that have outlived their deadline occupy a slot now, which
  is what the cap was always meant to bound — healthy scans finish in milliseconds.
- The backoff fence compared wall-clock times. The WSL gate this scan runs under deliberately
  avoids wall time for exactly this ('would misjudge stuckness across laptop sleep or NTP steps');
  a backward step pinned a namespace in its failure memo for the size of the step. Now monotonic.

A third finding — that a late listing writing into an entry a retry has replaced loses the answer —
was investigated and refuted. An entry is only ever read back through the map, and callers hold the
promise rather than the entry, so a write to a replaced entry cannot be observed. No guard added:
the test for it passed with and without one.

* fix(worktrees): gate retirement rescans per namespace, and keep a partial answer usable

Replaces the process-wide listing budget with a per-namespace rule, and stops a refused source
throwing away the sources that did read.

- A global budget was the wrong shape: one wedged mount spends it on its own retries (lapse,
  restack, lapse) and then every other namespace — including repos on healthy local disks — is
  refused for the process lifetime, which is a strictly larger blast radius than the wedge it
  replaced. A namespace now simply may not start a second listing while its own is still stuck,
  so a bad mount costs exactly one thread and nothing else is affected.
- Rethrowing a gate refusal abandoned the whole scan at the first source. For a WSL repo the UNC
  workspace root is listed first, so a stuck 9P route also discarded the plain, readable
  Windows-side bucket scan that needs no distro access — worse than the behaviour before the
  split. Discovery now returns what it read plus a "complete" flag; the names are used, and only
  the memoization is withheld so the hole is retried.
- An I/O failure was reported as a complete empty listing, so a transient EIO on a redirected or
  network home memoized "nothing is retired" for the process lifetime. Only ENOENT/ENOTDIR now
  count as a complete answer.

The recovery tests now release the stalled listing rather than leaving it pending forever: a
retry while the previous call is still stuck is precisely what stacks threads.

* fix(worktrees): keep the scan retryable when a WSL distro home will not resolve

Resolving a distro home shells out to wsl.exe, which returns nothing for a stopped or slow distro
(the call has a 5s timeout) or when wsl.exe is not resolvable from the Electron process. That case
dropped the distro bucket source silently and still reported the scan complete, so the empty answer
was memoized for the whole process lifetime.

That is the STA-4472 defect re-entering through the back door: the distro is exactly where a WSL
workspace's agent history lives, so a workspace whose directory is gone leaves its only surviving
evidence unread, and the next generated create reissues that cwd. It does not self-heal either —
the scan key is derived from the probe path, which is unchanged by a failed home resolution.

An unresolved distro now marks the scan incomplete, which serves the names that were found while
leaving the hole to be retried after the backoff.

* fix(worktrees): trace a WSL repo's Windows-side workspaces into the distro too

Distro discovery keyed only on the workspace root being a UNC path, but a WSL repo can legitimately
own workspaces under C:\. computeWorkspaceRootAsync mirrors the workspace dir into the distro only
when the distro home resolves at create time; when that wsl.exe call fails it falls back to the
drive path, and those workspaces stay on the Windows side.

The agent is still spawned through wsl.exe, so its cwd is the drvfs mirror (/mnt/c/...) and its
bucket lands in the distro's own ~/.claude/projects, where the host-home scan cannot see it. The
scan then reported complete and memoized the empty answer, so the name was reissued and the next
occupant inherited the previous conversation — the STA-4472 defect, in the one configuration the
UNC check does not cover.

Which distro to look in comes from the repo path rather than the workspace root, since that is what
still identifies the distro once the root is a drive path.

* revert drvfs-mirror discovery, and pin the "no agent state" classification

Reverts the previous commit. The drvfs branch traced a WSL repo's Windows-side workspaces into the
distro, but its production wiring cannot be pinned: the distro comes from parseWslPath(repo.path),
which short-circuits off win32, so no assertion on a Linux or macOS runner can reach it — deleting
the wiring line left every test green. Shipping an unpinnable branch is the exact unreached-module
shape this PR exists to close, and it is not worth it here: the branch only pays off in a narrow
race where getWslHomeAsync fails while the workspace root is computed and then succeeds seconds
later during discovery. Whenever the distro home resolves, the root is UNC and the existing path
already covers it; whenever it does not, the scan is already reported incomplete and retried.

Also adds the missing guard on the other side of the same classification: ENOENT and ENOTDIR mean
"no agent state on this machine", which is a complete answer. That is the common case for a fresh
or Codex-only install, and misclassifying it as incomplete would turn the one-time seed into a
60s-interval rescan for the life of the process. The expression had no test; it does now.

* test(worktrees): make the retirement backoff window a real assertion

The test that claimed to cover it settled the stalled listing and re-entered in the same tick, so
outstanding cleared only in a later microtask and the no-restack rule answered first. It was a duplicate
of the test above it, and the backoff clause it was meant to pin had no coverage at all: deleting
the clause left all twelve tests in both files green, so a regression that re-probes a wedged mount
on every generated create would have shipped.

Flush the microtask so the listing is genuinely settled, then assert both directions — the memo
still serves the failure inside the window, and the same call succeeds once the window lapses.

* fix(worktrees): stop trusting a UNC ENOENT, which is what a shut-down distro looks like

Windows reports an unreachable 9P route as ENOENT, so a distro that has merely been shut down is
indistinguishable from one that never held any buckets. wsl.ts already refuses to trust a UNC
ENOENT for the same reason, probing inside the distro instead.

The classification added earlier called ENOENT a complete answer, which is right for a local home
that simply has no agent state but wrong here. After a wsl --shutdown the cached distro home still
resolves, so nothing else marked the scan incomplete: the empty result was memoized for the whole
process lifetime and every later generated create in that namespace reissued names spent inside
the distro. That is STA-4472 again, reached by a different route.

ENOENT now only means "absent" off UNC.

* fix(worktrees): tell an absent distro directory apart from an unreachable 9P route

Distrusting every UNC ENOENT fixed the shut-down-distro hole but overshot: a distro where nobody
has run Claude genuinely has no ~/.claude/projects, which is the common case for Codex-only users
and for anyone running agents from the Windows side. Those namespaces could never report a
complete answer, so the one-time seed became a full rescan every 60s for the life of the process —
each one re-spawning wsl.exe and taking the single process-wide scan slot from transcript
discovery, on a path that runs on every composer open rather than only at create.

Probe the parent instead. If it lists, the child really is absent and the answer is complete; if it
does not, the route is down and the scan stays retryable. Both directions are pinned: reverting to
either of the previous behaviours turns a test red.

* fix(worktrees): walk up to a reachable ancestor, not just one level

The reachability probe checked a single parent, which only disambiguates when ~/.claude exists but
~/.claude/projects does not. The far more common shapes have the ancestors missing too: a distro
where Claude has never run has no ~/.claude at all, and a repo with no workspaces yet has neither
the workspace root nor its parent. In both, the one-level probe also got ENOENT and called the
route unreachable, which is exactly the 60s rescan loop it was added to prevent.

Walk up until a listing succeeds, bounded so a pathological path cannot hold the scan slot. One
reachable ancestor proves the route is up, so the ENOENT below it is real absence.

The test that was supposed to guard this had ~/.claude resolving, so the real shape was never
exercised — which is why the defect shipped green. Its fixture now leaves the whole chain absent
up to the distro home, and reverting to the one-level probe turns it red.

* docs(worktrees): record what gating retirement listings costs

The shared WSL filesystem gate admits one scan task process-wide, and its stuck-task check matches
scan against scan regardless of route. So retirement discovery now queues with — and on a wedged
distro can fast-fail — native-chat transcript discovery, which the ungated readdir it replaced
never could.

Gating is still right: the gate holds the only deadline and permit accounting these UNC reads get,
and without it a hung 9P route keeps a libuv thread outright. A dedicated lane would need a third
priority, which is a change to the gate rather than to this file. Writing the trade-off down so the
next reader does not have to rediscover it.

* perf(worktrees): probe reachability with stat, and record that the gate coupling runs both ways

The ancestor probe only asks whether a directory is there, but it listed it — enumerating a WSL
home over 9P, on the composer-open path, holding the single scan permit while it did. stat answers
the same question; the gate already supports the operation.

Also corrects the trade-off note added last commit, which recorded only the direction where
retirement discovery is the victim. Because the gate stuck-check matches scan against scan
regardless of route, the reverse is now true too and is the part this PR introduces: a retirement
listing wedged on one distro can fast-fail transcript discovery on a healthy one.

* fix(worktrees): drop imports the discovery extraction left unused

The rebase onto main kept main's import block, which still pulled readdir and
homedir for discovery code this branch moved into worktree-retirement-discovery.ts.

* fix(worktrees): drop the last import the discovery extraction left unused
2026-08-20 01:45:17 -07:00
Brennan Benson 3e079debec fix(sidebar): host-qualify discovery notice rows on multi-host projects (#15546)
* fix(sidebar): host-qualify discovery notice rows and collapse one checkout's twins

A project checked out on several hosts emits one discovery-notice row per
checkout, and those rows only named the project. A sidebar with paired remote
hosts therefore showed several identical "N hidden worktrees" buttons under one
project header, with no way to tell which machine each belonged to — or that
one of them was another machine's worktree inbox entirely.

Two causes, both fixed here:

- Notice rows carried no host context, unlike worktree rows, which have been
  host-labelled since STA-4343. Both notice rows now take a host label, applied
  per project (not per rendered section, since a card can land in the pinned
  fallback) and only when that project spans hosts. The label also lands in the
  review, expand, and dismiss accessible names, so the actions that write to a
  specific host's repo record say which host that is.

- One machine registered as a direct SSH target *and* paired as a runtime
  environment gives a single on-disk checkout two repo records with independent
  hidden-worktree state, so it emitted two rows for one directory. Repos now
  resolve to a (hostname, path) checkout key, and twins collapse to the record
  this client persists itself — its visibility state is the user's own and
  survives the paired runtime going away.

The key is deliberately conservative: an unresolved hostname, or a tunnelled
environment answering on loopback, yields no key and never collapses anything.
Renderer-only; no wire or persistence change.

* fix(sidebar): drop the machine-identity collapse, gate notice labels on host ids

Replaces this branch's second change after a plan review found it has no
precedent and eight concrete failure modes.

Deleted: the (hostname, path) checkout key that collapsed two repo records
believed to be one machine. Orca models a direct SSH target and a paired
runtime environment as different execution hosts everywhere else; that change
asserted sameness by resolving strings a user typed in two places. It also
dropped rows (a differing count vanished with the shadowed record), flipped
with the sidebar host filter, ignored port and user so a host and a container
on it could merge, tie-broke on repo-store order, was disabled in the one case
Orca can prove (a tunnelled pairing answers on loopback) and fired only on
coincidence, and left the visibility dialog showing state the sidebar had
hidden. Its module also carried a literal NUL byte, so git classified the file
as binary and the diff was unreviewable.

Kept, with two corrections: notice rows still carry a host label, but the gate
now counts distinct host ids rather than distinct label strings — two hosts
sharing one user-facing label is exactly when the rows are hardest to tell
apart — and membership is read from the unfiltered repo universe rather than
the host-filtered notice candidates, so a label no longer appears and
disappears with the filter.

Two hosts that share a label still render the same label. Disambiguating that
is a shared concern across worktree badges, host headers, and host-filter
options, and needs its own design; three verification passes each found a
different hole in doing it here. Follow-ups: general host-label collision, and
the repo-record duplication that produces the twin rows in the first place.

* fix(i18n): catalog notice host scope copy

* feat(sidebar): show each notice row's host with the project-on-host glyph

Notice rows on a multi-host project already carried a host label, but two
hosts can share one user-facing name, and the label truncates first in a
narrow sidebar. Each row now also carries its host's glyph.

Deliberately the same indicator worktree cards use (worktree-card-header):
a Server glyph, ServerOff when a paired runtime has no live status, and a
"Project on ..." tooltip naming the host — SSH and runtime keep their
distinct tooltip wording. Local hosts draw nothing, as on the cards.

The glyph is shrink-0, so unlike the text label it survives the sidebar
narrowing, and the row keeps an identifying mark either way.

Rows now carry the host id alongside the label, since the label alone cannot
select a glyph or its tooltip. Catalog entries for the new copy ship with the
change rather than relying on inline fallbacks.

* refactor(sidebar): draw notice-row hosts with the shared host glyph

Follow-up to the notice-row host indicator: use the one glyph vocabulary the
app already has instead of a second copy of it.

HostRowIcon — a monitor for this computer, a server for anything remote — was
private to the composer's run-target rows. Moved to a shared home and reused,
so the sidebar and the composer cannot drift apart. The run-target module
re-exports it, leaving its own call sites untouched.

Every notice row now gets a glyph, local included, so no row is the odd one
out; the tooltip still names the host and says when a paired runtime has no
live status. Same size and tone tokens across kinds, so no row reads as
decorated relative to its neighbours.

* fix(sidebar): make notice host glyphs accessible
2026-08-20 01:38:28 -07:00
Brennan Benson 4b2ed5ddd4 fix(terminal): apply pane padding on all four edges (#15544)
* fix(terminal): apply pane padding on all four edges

Move the configured inset onto xterm so the terminal fills its pane while the fit calculation accounts for both sides of each axis. Add a geometry golden that forces cell remainders and verifies dynamic padding without relying on renderer pixels.

* fix(terminal): normalize imported padding for fitting

* fix(terminal): align stored and fitted padding
2026-08-20 01:07:56 -07:00
Brennan Benson 5ca747dad0 docs(ssh): state the SSH execution boundary and pin the liveness vocabulary (#14971)
* docs(ssh): state the SSH execution boundary and pin the liveness vocabulary

Nothing under docs/ described how work splits between the client and an SSH
host, so agents and humans inferred it from error strings and got it wrong:
loss of contact was repeatedly reported as process death, which orphaned live
remote agents and cold-started duplicates over the same worktree.

Pins the vocabulary to the incumbent live/unverifiable/exited verdict from
unstopped-pty-verification so no synonym is introduced, records the one real
discriminator (all of a host's terminals drop together on link loss; one alone
means process exit), and lists the outstanding gaps with citations.

Tracked via the docs allow-list and linked from AGENTS.md, per the convention
in .gitignore.

* docs(ssh): cite the live restoreRequired site after it moved

The throw now lives in reattachSshPtySessionForSpawn; ssh-pty-provider.ts no
longer contains it. Caught by the worker fixing it, against a newer main than
the audit ran on.

* docs(ssh): require host evidence for liveness verdicts

* docs(ssh): keep boundary references stable

* docs(ssh): fence liveness evidence to its host identity

* docs(ssh): state replay and environment boundaries precisely

* docs(ssh): correct replay and platform boundary claims

* docs(ssh): describe headless runtime continuity accurately

* docs(ssh): distinguish authority from client metadata

* docs(ssh): describe pending fixes accurately

* docs(ssh): date the gap list and name the PR that closes each entry

The Known gaps section was accurate when written and becomes actively
misleading as its fixes land: it told a reader to go fix restoreRequired,
the missing unverifiable verdict, and the absent terminal-list host field,
three things now addressed by #14974, #14977 and #14973.

Mark the section as dated, require verification against current code before
acting on any entry, name the PR per entry, and move landed items out. Also
correct the two body claims that the landed fixes invalidated. The rules
above are durable; only this section rots.

* docs(ssh): make the boundary doc a durable ruleset, not an incident record

The Known gaps section was 18 of 93 lines enumerating specific defects from
one investigation, several already fixed by sibling PRs in the same batch. A
reference doc that needs a 'this section rots' warning is telling you the
section belongs somewhere else; those entries belong in issues.

Replace the six-row table of currently-lying signals with the method that
outlands any particular bug: ask whether the owning host produced the signal,
whether every PTY on the target went quiet together, whether the termination
event matches the current incarnation and generation, and whether a returned
status is actually a claim. Same for artifacts - state what ls-remote and a PR
head each do and do not prove, rather than listing which command is currently
wrong.

Nothing here goes stale when the open fixes land.
2026-08-20 00:47:22 -07:00
Brennan Benson 2e895da937 fix(browser): name the requesting frame and the permission in denial notices (#15542)
* fix(browser): name the requesting frame and the permission in denial notices

Two defects in the same notice, both found by the review of #15481 and left out
of it deliberately.

The notice named the wrong site. setPermissionRequestHandler passed
webContents.getURL(), which is the top-level document, so a permission request
from a cross-origin sub-frame was attributed to the embedder. Every
PermissionRequest variant carries requestingUrl, so all three call sites now use
it and fall back to the top-level document only when it is absent.

The notice also showed raw Chromium permission names. humanizePermission mapped
two permissions and returned the raw token for the rest. That now matters more:
#15481 granted ordinary storage-access and left top-level-storage-access denied,
making it the storage denial a user can still hit - rendered as its raw token.

The default still returns the raw token. Inventing prose for a permission nobody
has seen is worse than showing its real name.

Does not change any permission verdict, and does not fix Google sign-in (#15221).

* fix(browser): keep permission denial attribution accurate

Capture fallback URLs before asynchronous media permission handling and treat opaque requesters as unknown rather than blaming the top-level page. Clarify permission descriptions and cover origin normalization, navigation races, and mapped copy.
2026-08-20 00:22:59 -07:00
OrcaWin 471bc9d8ce Ship the WSL transcript helper with the Windows relay (STA-4831) (#15529) 2026-08-20 00:16:04 -07:00
Neil 9d06b3ba93 ci: stop docs-only commits from starting the skill-roundtrip matrix (#15474)
A cancelled Skill update round trip on a README merge painted main red
because push to main had no path filter. Share the PR path list on
push, and skip expensive PR Checks when every changed file is docs.
2026-08-19 23:33:05 -07:00
Brennan Benson 9a898a84cc fix(terminal): keep xterm's render pause latched for a pane with no layout box (#15555)
* fix(terminal): keep xterm's render pause latched for a pane with no layout box

resetWebglTextureAtlas() released xterm's paused-render gate for every pane of
a visible manager, including panes that are display:none (a collapsed sibling
of an expanded pane, a restore that stays display:none for its whole reattach).

forceRepaintThroughRenderPause exists for a pane that is already DOM-visible
while xterm's IntersectionObserver lags a frame. On a pane with no box it
paints the freshly cleared render model into nothing, and because the observer
only fires on a state change it never re-pauses the service. It also clears
_needsFullRefresh, which is the only thing that makes _handleIntersectionChange
repaint on reveal and flush the deferred _pausedResizeTask.

Latch instead for those panes: terminal.refresh() re-arms _needsFullRefresh and
xterm repaints from it on reveal.

* test(terminal): type the render-service repaint mock
2026-08-19 23:25:47 -07:00
Neil 4daace6251 fix(tabs): stop split workspaces multiplying columns on return (#15482) 2026-08-19 22:54:03 -07:00
Neil ef096d539d fix(terminal): refuse a cursor on a screen read, and correct the source docs (#15563)
Review follow-up on #15380.

The RPC accepted `cursor` and `screen` together. The CLI refuses the pair, but
terminal.read is reachable without it, and honoring both answered with rendered
lines carrying the stream's pagination metadata — two frames of reference in one
payload, which is the confusion `source` exists to remove. The guard beside it,
withVisibleSnapshotFallback, already declines to substitute rendered lines when
a cursor is present; the screen path now agrees, at the RPC boundary where every
remote caller passes. Nothing could previously send both, since `screen` did not
exist, so rejecting breaks no existing caller.

The command notes and the runtime comment both still described the fallback as
`source: stream`, left over from renaming that value to `screen-unavailable`
during implementation. The spec text is surfaced through `orca help` and the
agent-context schema, so a caller following it would test for a value the code
never emits. Both now describe all four states, including that an absent source
means the host predates the field.
2026-08-19 22:42:39 -07:00
Jinjing acbcb477a1 Auto e2e tests autofix scheduled ci 1h run 1 20260818T2143 (#15379)
* fix: update E2E tests for API changes and selector robustness

- Improve source control file locator specificity to avoid flakiness
- Fix board test to use correct worktree ID attribute
- Update removeWorktree calls to pass host ID parameter
- Simplify git status polling with timeout expectation

* fix: increase packaged-watchdog launch timeout and await git-status rows

Extract hardcoded 15s launch timeout to a 30s constant for better reliability under load. E2E test now waits for all git-status rows to render before asserting absence of status messages, preventing flaky passes when the list is still loading.
2026-08-19 22:39:48 -07:00
Neil c72a4eecdd refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) (#15391)
* refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786)

Orca needs to run code after the user's own zsh startup files. It bought that by
keeping ZDOTDIR pointed at its own wrapper dir for the whole of startup and
sourcing each user file by hand -- four generated files per transport, with a
fake ZDOTDIR live while /etc/zshrc ran. That single decision is the root of a
whole bug family:

- /etc/zshrc assigns HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history unconditionally, so
  history landed inside Orca's own dir (#11044), and an epilogue had to repair it.
- zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh emulation, so a
  user .zshenv or .zprofile ending in `emulate sh` hid every later wrapper file.
  The emulation degrade blocks and their forked $(emulate) probes exist for that.
- One wrapper dir shared by two installed builds could mix files from both, so
  every generated file had to redefine the helpers it called.
- The baked generation-time ZDOTDIR literal is unusable when a Windows-generated
  wrapper is sourced inside WSL via /mnt/c (#8003), so the runtime path had to be
  re-derived from %x.

The wrapper now hands ZDOTDIR back on its first lines and defers Orca's work to a
precmd hook that runs at the first prompt -- after .zprofile, /etc/zshrc, .zshrc
and .zlogin, every one of which zsh reads from the user's own directory exactly
as in an unwrapped shell. Each bug above stops being reachable rather than being
repaired, and their machinery goes with them: eight of thirteen exported blocks in
shell-templates.ts, both drifted discovery bodies (unifying them closes the
"reconciling the two is a follow-up" note the file carried), and the relay's
separate ORCA_USER_ZDOTDIR shape. Generated zsh drops from 819 lines across
twelve files to 143 in three.

Two things the design has to get right, both found by running it rather than
reasoning about it:

- Every function is defined ABOVE the source of the user's .zshenv. A user file
  ending in `emulate sh` puts the rest of the wrapper under sh parsing rules, and
  the first prototype died there with `parse error near '\n'` -- silently, leaving
  the pane unwrapped. Function bodies are parsed at definition time.
- ORCA_ORIG_ZDOTDIR is vetted, not trusted. The launch config only sets it when it
  resolved a usable dir, but a pane inherits its parent's environment too, so a
  stale value from an older build can arrive on its own and would point ZDOTDIR
  back at a wrapper dir. The ownership check Node applies now also runs in the
  shell, where that route is visible.

Orca also stops inventing a ZDOTDIR: where the user has none, ORCA_ORIG_ZDOTDIR is
absent and the pane ends with ZDOTDIR unset, as an unwrapped login zsh does.

Verified on real zsh over a real PTY -- necessary, because a precmd hook never runs
in a shell started with -c, so the existing `zsh -i -c` probes could not have
exercised this design at all. src/main/zsh-startup-hook-pty-harness.ts drives the
shell to a prompt and reports through a file rather than stdout, which a PTY echoes.

* test(shell): cover the relay variant of the zsh hook in a real shell

The relay writes its own variant -- no OSC 133, remote CLI bin dir instead of the
agent-teams shim -- and it had no live coverage. It used to carry a second ZDOTDIR
shape as well, which is how it drifted from the desktop template in the first
place; now the spec flags are the only difference, and this pins that.

* fix(shell): rebase the single-file hook onto content-addressed wrapper trees

#15285 landed content-addressed wrapper roots and a per-transport fileset module
while this branch was in flight. The fileset modules are now the single place the
tree is described, so 'only .zshenv' is stated once per transport and the
required-paths check follows from it rather than repeating the list.

* test(shell): point the mixed-build proof at the relay, the one fixed wrapper path

#15285 content-addressed the desktop and daemon trees, so two builds can no
longer write the same directory there and the scenario this file covers became
unreachable on those paths. The relay still writes a fixed ~/.orca-relay/
shell-ready, so that is where the hazard survives and where the proof belongs.

* fix(test): make the zsh PTY harness survive a startup that stops to ask

Two CI-only failures, both from driving a real PTY where the old probes drove a
pipe:

- A host whose global zshrc runs `compinit` over directories it considers
  insecure stops startup and ASKS. A pipe-backed `zsh -i -c` never saw the
  question; a PTY sits at it until the timeout. The harness now answers it.
  ZSH_DISABLE_COMPFIX does not help -- that is an oh-my-zsh convention and plain
  compinit ignores it, which I confirmed by reproducing the prompt locally.
- The PS1 line was typed at t=0, so on such a host the question consumed it as
  its answer. The harness now waits for the shell to fall quiet first, which
  also stops a slow prompt framework racing the same write.

Also merges a duplicate vitest import the native code-quality audit flagged.

* fix(test): stop the live-shell assertions assuming macOS host behaviour

Two of them hardcoded what my machine does rather than what Orca owes:

- LINEINIT was pinned to 'none'. A host whose global zsh config installs its own
  zle-line-init widget has one either way; the contract is that it looks the same
  wrapped as unwrapped, which the assertion beside it already states.
- The dropped-precmd_functions case asserted HISTFILE was no longer the scoped
  path. Whether the scoped value survives at all is the host's call: macOS
  /etc/zshrc overwrites HISTFILE so it does not, and a host with no such
  assignment keeps whatever the spawn env set. Now compared against an unwrapped
  pane given the same env, which is the real contract on both.

Also notes, where the emulation cases live, that they only discriminate on a host
whose system zshrc clobbers HISTFILE -- on CI's Ubuntu the load-bearing assertion
is ORCA_HISTFILE having been consumed.

* test(shell): re-pin the fixes the four-file wrapper was built for

Archaeology over the removed blocks: each existed for a bug, so each needs the
bug shown to be unreachable rather than just the code gone. Six restored or added,
each naming the change that introduced the behaviour.

- #8003, twice: the wrapper sourced from a relocated root, and from a non-ASCII
  (token-range) one. The old file baked its generation-time path in and had to
  re-derive the runtime one from %x to avoid using it; this one bakes nothing.
  Both runs assert ORCA_SHELL_FEATURES came back consumed, so 'the user's .zshrc
  loaded' cannot pass on a pane that never read the wrapper at all.
- #4667: user startup files must see their OWN ZDOTDIR while they run, or plugin
  and theme lookups resolve into Orca's dir. The old wrapper swapped ZDOTDIR
  around each source; this one never takes it away, and the values now have to
  match an unwrapped pane's.
- #1947: a user .zshenv that returns early.
- #15258: an inherited ZDOTDIR that is an Orca wrapper dir must be refused. CI
  proved this route is live -- the launch config only sets ORCA_ORIG_ZDOTDIR when
  it resolved a usable dir, but a pane inherits its parent's environment too.
- #11044/#11146: a nested Orca inherits neither cross-process channel and no
  ZDOTDIR of Orca's, which is what makes #11044's plain shape unreachable rather
  than repaired. Verified the child-env probe detects a real leak before trusting
  it to report the absence of one.
2026-08-19 22:37:42 -07:00
Neil fdd4091ebd fix(hooks): isolate lint-staged backups per worktree (#15388) 2026-08-19 22:37:02 -07:00
Neil d7a23c84a9 fix(pty): keep a CPR reply from overtaking a deferred colour reply (#15559)
A background-colour probe writes `OSC 11 ;? ST` then `CSI 6n` and reads
exactly one response, using the CPR as its sentinel: a non-OSC first
response means "unsupported" and it stops draining. #13309 routed live
cooked-echo-risk replies through the ECHO-probe deferral while CPR kept
the immediate path, so the CPR overtook the colour reply, the prober gave
up, and the stray `ESC ]` was left in the tty for the next program —
`gh auth login` died on it with an escape-sequence error.

Queue a reply that needs no echo containment behind ones that do, FIFO,
and only while something is actually deferred, so latency-critical
replies stay immediate on every other path. Windows is unaffected: only
posix-pty defers, so the queue is always empty there.

Also: an in-flight echo probe is already the write continuation, so
re-arming the timer for a queued reply would fork a second stty and throw
away the first verdict; and teardown now hands queued uncontained writes
to the pty best-effort instead of dropping bytes the caller was told were
sent.

Known scope limit, pinned by tests and tracked for follow-up: the
guarantee is FIFO among recognised query replies, not over every byte —
a reply coalesced with a keystroke, and ordinary typed input, still
bypass the queue. Both were unordered before this change too.
2026-08-19 20:46:12 -07:00
Jinjing d541982b9c Improve cmd j search usefulness (#15551)
* Show last active time for workspace tabs in cmd+j palette

Adds session-age formatting and activity tracking to help users find
recently-used tabs. Replaces host badge display with last-active timestamps
that reflect either agent activity or worktree PTY activity, whichever is
more recent.

* Improve cmd+j search ranking with direct fields and recency

Prioritize results matching direct fields (titles, content) over
container fields (worktree, branch, repo). Use tab focus time to break
ranking ties. Makes search more useful for quick navigation.

* Extract path flavor logic to cross-platform-path utility

- Remove local pathFlavor function in favor of shared cross-platform utilities
- Simplify buildExcludePathPrefixes to use relativePathInsideRoot and resolveRuntimePath
- Ensures consistent path handling for both local and remote roots

* Improve cmd+j search ranking with recency-based tiebreaking

Track lastFocusedAt on tab creation/focus and use it to break ties between equally-ranked search results. This surfaces recently-used items first, improving search utility. Also fixes hasDirectHit to check field matches directly rather than evidence metadata.
2026-08-19 19:46:45 -07:00
Brennan Benson a76a95d111 fix(terminal): make remote-host take-back release the phone-fit lock (#15473)
* fix(terminal): make remote-host take-back release the phone-fit lock

The remote-desktop branch of reclaimTerminalForDesktop was the one path that
rolled the presence lock back when its reclaim resize did not converge. On a
remote/SSH host that left the phone-fit banner stranded and made every
subsequent "Take back all terminals" click a no-op. Its sibling (active mobile
subscriber) released the lock but still reported the layout's `ok`, so the
desktop renderer skipped its post-take-back refit and focus.

Both now follow the guarantee the method already documents: an explicit desktop
take-back always drops the lock, and the trailing remote layout is best-effort.

* review: pin the driver flip and correct the applyMobileDisplayMode contract

- Tighten the held-branch driver assertion to the exact post-release state so
  it pins releaseDesktopTakeBack's flip rather than merely "not mobile".
- applyMobileDisplayMode's doc claimed reclaimTerminalForDesktop gates its
  transitions on the returned convergence flag. No branch does after this
  change; say so, so the gate is not reinstated.

Hooks skipped (machine load); oxfmt --check and oxlint verified clean manually.

* test(terminal): pin the converging remote take-back resize

The two take-back tests both force the reclaim resize to fail, so nothing
covered a take-back that converges. Deleting the `idle` driver flip left
every suite green while applyRemoteDesktopLayout no-opped on a still-mobile
driver — lock dropped, `true` returned, PTY stranded at the phone grid.
2026-08-19 18:13:57 -07:00
Brennan Benson 59c5624e14 fix(terminal): require a proven exit before retiring a subscription's lease (#15470)
isPtyKnownExited read a PTY record as exited whenever `connected` was false:

  if (pty) { return !pty.connected }

Its own leaf fallback, one line below, demands getTerminalState(leaf) === 'exited',
which is only true once lastExitCode is set. So the same function proves absence on
one path and infers it on the other, and the inferring path is the one that runs
whenever a record exists.

onPtyExit is the only writer of lastExitCode. The liveness sweep clears `connected`
with no exit code for every PTY behind a dropped relay, so that state is a lost
connection to a process that may still be running on the host — not an exit. Reading
it as one makes subscribeToPtyExit fire its listener synchronously at subscribe time,
which retires the lease and emits `end` for a live terminal. Mobile reads `end` as
"PTY gone" and rearms; after three attempts it stops and leaves the composer on
"Waiting for terminal…", which is where the 0.0.44 permanent lock comes from.

Use the runtime's existing three-valued discriminator so both paths demand the same
proof. 'unknown' now keeps watching, and the later real exit still fires the listener;
a subscription that outlives its PTY is still bounded by the connection abort.

Both callers are in subscribeToPtyExit — the subscribe-time fast path and the
registration-race re-check — and both want proven-exited, so neither changes shape.
2026-08-19 18:13:26 -07:00
Brennan Benson 6ac79ef0aa fix(workspaces): settle an orca.yaml trust prompt when the modal slot is taken (#15540)
* fix(workspaces): settle an orca.yaml trust prompt when the modal slot is taken

The app has one modal slot, so any openModal/closeModal evicts whatever
held it. A pending orca.yaml trust prompt owns the promise that quick
create awaits, and eviction dropped its resolver: that submit never
settled, and because the trust prompts are serialized on a module-global
chain, every later create/remove in the session silently did nothing.

Modal data can now carry an onModalDismissed callback that the slot
invokes when it evicts an entry; the trust prompt uses it to resolve as
'skip', which is what dismissing the dialog already means.

* fix(workspaces): make trust prompt settlement one-shot
2026-08-19 18:12:29 -07:00
Brennan Benson 30bf2647fc fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it (#15472)
* fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it

A socket close or response timeout rejects an in-flight worktree.create as
delivery-unknown: the frame reached the wire, so the host may already have
built the worktree. The client only replayed connection-migration cutovers,
so every other ambiguity surfaced as a create failure for a create that may
well have succeeded. Replay on the same clientMutationId — which the host
already dedupes — after waiting for the transport to come back.

* fix(mobile): bound the ambiguous worktree.create replay by the host's dedupe window

The replay was bounded only by a retry count, but what makes a replay reconcile
instead of building a second worktree is wall clock: the host drops a settled
create's dedupe record 60s after it resolves, and past that the replay is just a
fresh create that the host's suffix loop happily duplicates — for a folder
workspace, into a second workspace with the very same name and no collision
check at all.

Two paths ran past that window:

- The request-timeout path. A silently dropped response frame leaves the socket
  alive, so nothing rejects until WORKTREE_CREATE_TIMEOUT_MS — ten minutes, with
  no bound at all on when the host actually resolved. This was previously the
  path that replayed *soonest*, short-circuiting the reconnect wait because the
  transport still looked healthy. Invert it: every path that reports a real drop
  has already left 'connected' by the time the rejection surfaces, so still being
  'connected' identifies the timeout and is now refused.
- The reported-drop path. Worst-case detection is a full liveness idle period
  plus the missed-probe budget before the client even learns the socket is dead,
  and the old 20s wait on top of that overran the record. Derive the wait from
  the watchdog constants and the TTL instead of hardcoding it, and anchor a
  single deadline at the first ambiguity so a second wait gets the remainder
  rather than restarting.

The TTL now has one definition shared by both processes, so the client asserts
its budget against the host's real window instead of a copied literal.

* fix(mobile): end the reconnect wait on a revoked pairing, and pin the wait's behavior

waitForRpcClientReconnected resolves only on 'connected' or the timeout, but an
'auth-failed' client never reaches 'connected' — so a create interrupted by a
revoked pairing sat out the full wait before surfacing the error it already had.
Treat auth-failed as a terminal answer on both the fast path and the listener.

The helper also shipped with no tests of its own: its already-connected fast path,
its timeout path, and the synchronous-notification-during-subscribe teardown were
only ever exercised indirectly through the retry suite, and neither RpcClient
implementation notifies synchronously, so that branch had no coverage at all. Add
a direct suite covering all of them, asserting listener and timer teardown rather
than just the resolved value.

Also give the fake-timer tests an explicit timeout. advanceTimersByTimeAsync
yields through real macrotasks between ticks while vitest's own budget runs on
real time, so on a loaded runner the default 5s is reachable — observed once as a
spurious timeout in this suite.

* fix(mobile): bound the ambiguous replay in wall clock, not timer time

The replay window was derived from the liveness watchdog's own budget
(idle + missed probes x probe timeout). That is a bound on how long the
watchdog takes to *fire*, not on how much wall clock passed. iOS and
Android suspend JS timers while the app is backgrounded, so across a
background cycle the socket dies silently and the pending create rejects
delivery-unknown minutes later with the timer-derived ceiling still
reading ~44s. The replay then lands well past the host's 60s dedupe
record and the suffix loop builds a SECOND worktree - for a folder
workspace, one with the very same name and no collision check at all.

Anchor the deadline on the watchdog's lastInboundAt instead: a wall-clock
stamp of a frame that really arrived, so it stays honest across a
suspension. Fall back to the send time when the transport can't vouch for
one (relay sessions run with idleProbeMs: null), which errs toward
refusing the replay.

Also restore the delivery-unknown discrimination test that the
still-connected guard had made vacuous, pin the still-connected guard
itself against a live inbound stamp, and pin the deadline against being
re-read from a fresher replacement session.
2026-08-19 18:10:39 -07:00
Neil 36d78e88af fix(agent-hooks): stop Antigravity's Windows hook from spawning PowerShell on every event (#15520)
Antigravity was the last agent posting hook status through Windows PowerShell 5.1. Every
hook event — roughly one every 2-6s during an active session — paid a ~300ms interpreter
cold start, which is what made the console the agent allocates for each hook last long
enough to be seen as continuous flashing.

Move the Windows POST to the shared curl.exe builder every other agent already uses, via
the `extraFormLines` escape hatch for the `hook_event_name` field Antigravity uniquely
needs. Measured on Windows 11: 326ms -> 134ms per event.

Because the curl line percent-expands its arguments, the script also needs
`setlocal DisableDelayedExpansion` (#9358/#9941) so a `!` in a pane key or worktree path
is not eaten as a delayed reference.

curl omits a `--data-urlencode name@-` field entirely when stdin is empty, so accept an
absent or blank Antigravity payload as `{}` at the ingest boundary — the POSIX script
substitutes `{}` before posting and PowerShell did the same, and without this a
payload-less event lost the status transition its `hook_event_name` still carried. Scoped
to that source; every other agent keeps rejecting a body it cannot parse.

Adds a cross-agent guard asserting the invariant the original drift violated: a managed
Windows .cmd hook posts through fully-qualified curl.exe and spawns no interpreter.
Generated under a mocked win32 platform so the POSIX CI legs guard it too.

Validated on a real Windows 11 host, not an emulated platform check.

Fixes #15117
2026-08-19 17:36:33 -07:00
Neil cb95582cea feat(release): build unsigned Windows artifacts for the dev channels (#15465) 2026-08-19 17:34:27 -07:00
Jinwoo Hong 0e5b348414 fix(repos): keep the desktop-owned manual project order authoritative across paired clients and SSH catalog publishes (STA-4850) (#15538) 2026-08-19 17:26:16 -07:00
Neil 9d1dfc314f fix(cli): resolve host names across both kinds, and stop ssh: answering empty (#15449)
* fix(cli): resolve host names across both kinds, and stop ssh: answering empty

`--host ssh:<id>` was never validated. An unknown target filtered to nothing and
returned ok:true with an empty list — the same silent wrong-machine answer that
unknown `runtime:` ids gave before they were rejected. And because SSH target
ids are machine-generated (`ssh-<timestamp>-<random>`) while the name anyone
actually knows is the label, this fired on the ordinary spelling rather than a
rare typo: every human-typed SSH name missed.

The two kinds of remote machine are also reached on different axes. A paired
Orca server is a connection (`--environment <name>`); an SSH target is a machine
the connected host reaches (`--host ssh:<id>`). A caller only knows "the machine
called X", so naming X on the wrong axis was the common failure and produced
either an empty answer or a dead-end "unknown environment".

Now: `ssh:` resolves labels as well as ids and rejects an unknown target with the
known ones listed; `runtime:` accepts the environment name as well as its id,
matching --environment, and canonicalizes to the id so stored host ids still
compare; and when a name misses on one axis but exists on the other, the error
says which and gives the exact flag. Candidates ride along in error.data so an
agent can recover without parsing prose.

`orca host list` is the discovery surface that was missing entirely — nothing in
the CLI listed SSH targets, so a caller told to use one had nowhere to look. It
prints this machine, the SSH targets registered on the connected host, and the
paired servers, each with the selector to use.

* fix(cli): give --environment the same cross-kind hint, and validate the ssh host on setup-create

Two gaps a follow-up survey found in the first pass.

`--environment openclaw` still dead-ended with a bare "Unknown environment"
while an SSH target by that name sat right there — the inverse of the case just
fixed, and the direction the report actually hit. The store's own error cannot
carry the hint: translateStoreError forwards code and message and drops data. So
the selector is resolved before the client is built, where the payload survives.
Only the explicit flag is asserted eagerly; an ambient ORCA_ENVIRONMENT stays
lazy, because failing local-only commands over stale background config would be
a regression.

`project setup-create` records independent metadata and, unlike the other setup
paths, is not covered by the runtime's ssh rejection — so an unknown target
persisted a row pointing at a machine that does not exist. It now resolves the
host. `local` and `runtime:` still pass through untouched: this is also the
provisioning path, where a runtime host legitimately may not exist yet when its
metadata is written.

`setup-existing-folder` and `setup-clone` deliberately keep the unresolved id.
The runtime rejects every ssh host for those operations regardless of whether it
exists, so resolving first would answer "no such target" and imply the command
would have worked with the right id.

* fix(cli): refuse an ambiguous host name instead of resolving the first match

Name lookup took the first match while the environment store itself refuses an
ambiguous name rather than guessing. That put the guess back, in the selector
whose entire purpose is to stop a command reaching a machine the caller did not
choose — and it applied to both spellings: two SSH targets sharing a label, and
two paired servers sharing a name.

Both now resolve to nothing and report every candidate with its id, so the
caller picks. An exact id still resolves past a colliding name, since an id is
never ambiguous.

Also pins the property that makes accepting a name safe at all: `runtime:<id>`
is a persisted token that lands in ProjectHostSetup.hostId and is embedded in
generated setup ids, so the name is canonicalized to the id before anything
downstream sees it. A test now asserts a name never reaches the wire.

* fix(cli): fall back to the older ssh listing so an old host is not read as having no targets

Hosts predating ssh.listTargetSummaries still answer ssh.listTargets, and both
are served by the same summariser. Swallowing the method_not_found made such a
host indistinguishable from one with no SSH targets registered, which would
reject a target id that is valid there — a new-client/old-host regression on a
path that previously passed the id through unvalidated.
2026-08-19 17:20:21 -07:00
Neil 516d91e6bc Update pull_request_template.md 2026-08-19 17:18:32 -07:00
Brennan Benson fab6e0d6e7 fix(mobile): scope optimistic workspace removal to the deleted host (#15424)
* fix(mobile): scope optimistic workspace removal to the deleted host

A worktreeId repeats across hosts, so filtering the list on the bare id also
removed the identically-named workspace belonging to the other host. Match on
(worktreeId, hostId) through a named helper so the rule is testable.

* fix(mobile): key host worktree rows consistently
2026-08-19 17:12:44 -07:00
Neil a61b39a9a6 fix(runtime): stamp a runtime's own project setups as local, and report remote status about the remote (STA-4792) (#15376)
* fix(runtime): stamp a runtime's own project setups as local, and report remote status about the remote (STA-4792)

Two independent frame-of-reference bugs, both from code describing one machine
while labelled as another.

#15366 — projectHostSetup.* persisted the caller's host id verbatim. Those
`runtime:<environment-id>` ids are minted by the calling client's own pairing
store, so they name a machine only relative to that client. A client sending
one is addressing this runtime, and runtimes do not proxy these calls onward,
so the host it names is us. Storing the client's spelling made one machine look
like a different host to every other client, hid its rows from them, and
defeated the (projectId, hostId) duplicate check — two laptops paired to one
server each created their own setup for the same checkout. Re-spell it as
`local` at the RPC boundary. Rows written earlier keep their old stamp; readers
already project `local` back to `runtime:<their-id>`, so the client-visible
model is unchanged and no ids are rewritten.

STA-4792 defect 4 — `status --environment <name>` hardcoded app.running:false
to mean "no desktop on THIS machine" while every other field in the same object
described the target, including a desktopWindowStatus echoed straight from it.
The result contradicted itself and read as "that run was headless" when the
remote GUI was up. `app` now describes the target, keyed off the one window
status that requires a live renderer, and the result names its own subject so
the frame can't be misread again. The remote pid is not knowable, so it stays
null.

STA-4792 defect 2 gets a regression test rather than a fix: routing already
made the client remote, which is what stops a Windows destination being joined
to the local cwd. The test pins the exact reported invocation.

* fix(status): share the remote app projection with the SSH host passthrough, and name the version gap on project host setup

Two review follow-ups.

The SSH host passthrough answered `app.running: true` unconditionally for the
Orca host a caller reached over SSH, claiming a desktop app even for a headless
`serve`. That is the same defect as the paired-server path, one transport over,
so the projection moved to shared and both now answer the question the same way.

`--host runtime:<id>` routes project commands to a paired server, which means a
client can reach a server that predates project host setup without meaning to.
That answered a raw `method_not_found`, which reads as an Orca bug rather than a
version gap; the CLI now names it the way the desktop already does.

Reverted a third change: making the persistence duplicate check treat `local`
and `runtime:*` as one machine. That assumption holds at the RPC boundary, where
a `runtime:` host means the runtime being addressed, but not in the store, which
also records independent provisioning metadata for machines that are not itself.
An existing test covers exactly that, and it was right. The duplicate
convergence therefore stays bounded to rows written after the normalization.
2026-08-19 17:12:17 -07:00
Brennan Benson 7fa23438f4 fix(mobile): keep a desktop take-back from being undone by a passive viewport report (#15539)
A phone that stays subscribed after 'Take back this/all terminals' re-phone-fits
the PTY and re-takes the presence lock on its next terminal.updateViewport, which
iOS forces on app resume and on every reconnect. updateMobileViewport consulted
only mobileDisplayModes, which reclaimTerminalForDesktop resets to 'auto' before
returning, so the take-back had no suppression left. Treat an in-force desktop
take-back the same as the existing desktop display mode: record the viewport,
apply nothing.
2026-08-19 16:52:58 -07:00
Brennan Benson 6415511a82 feat(remote): add a positional file read to the filesystem provider (#15517)
* feat(remote): add a positional file read to the filesystem provider

Following a growing remote file means re-reading it from the top on every
poll: the relay exposes only whole-file reads, so tailing an append-only log
over SSH costs O(size) per tick.

Adds fs.readFileRange plus a rangedReadVersion capability, and an optional
readFileRange on IFilesystemProvider -- matching how lstat/
supportsQuickOpenSearch already declare degradable capabilities.

Three deliberate choices:

- The relay loops until the requested length is satisfied or the file truly
  ends, and REJECTS an over-cap request rather than clamping it. A clamped
  read is indistinguishable from EOF, so a caller advancing a cursor by
  bytesRead would silently skip data.
- Bytes cross the wire base64-encoded. A range boundary can split a UTF-8
  sequence at either edge, and a utf-8 round trip would substitute U+FFFD and
  shift every subsequent offset.
- The provider throws a typed FileRangeReadUnsupportedError against an older
  relay instead of quietly falling back to a whole-file read. A tailing caller
  issues several reads per snapshot, so a per-call fallback is quadratic;
  callers probe supportsFileRangeRead once and snapshot instead.

The response is validated before use -- a byte count disagreeing with the
payload would shift every downstream offset while looking like success.

Terminal-artifact reads/writes move to their own module, mirroring the relay's
existing fs-handler-terminal-artifact split; the provider was at the max-lines
ceiling and this was the cohesive piece to extract.

* fix(remote): size the ranged read to what the relay writer can deliver

The 4 MiB cap was justified against MAX_MESSAGE_SIZE (16 MiB), but that is
the frame DECODER bound. Responses are gated by the writer's admission
budget: a frame over DISPATCHER_CONTROL_QUEUE_MAX_BYTES (1 MiB) is demoted
to the legacy-response lane, which is refused once the producer queue passes
2 MiB. A 4 MiB window is ~5.46 MiB of base64, so it was never admissible --
it came back as an opaque ResponseOverCapacity (-33008), which is neither of
the PR's typed errors, and above ~1.4 MiB the outcome depended on unrelated
queued traffic. Cap at STREAM_CHUNK_SIZE (256 KiB), the house per-frame
budget for file bytes, which stays in the control lane unconditionally.

Also:
- Hoist the cap and offset validation into src/shared/file-range-read.ts so
  the client rejects an out-of-contract request locally instead of paying a
  round trip for an error that does not survive the wire as a type.
- Validate filePath in the relay handler; a missing one threw a TypeError
  out of expandTilde despite the comment claiming hand-validated params.
- Collapse the two fs.getCapabilities probes onto one cached fetch per
  multiplexer. They read one document, so probing per feature spent an extra
  round trip per connection and duplicated the eviction logic.
- Reuse readFullStreamChunk instead of a second copy of the short-read fill.
- allocUnsafe the window; only subarray(0, bytesRead) escapes, so a tailing
  poll no longer memsets the whole window per call.
- Plain methods for readFileRange/supportsFileRangeRead rather than
  constructor-assigned arrows; both are unconditional, unlike downloadFolder.

Tests: cover the dispatch path and fs.getCapabilities (neither was
exercised), param validation at both boundaries, EOF at and past the end,
and a full-cap read over a real RelayDispatcher. The transport guard fails
at 4 MiB with the real -33008.

* test(remote): pin the ranged-read cap to real control-queue headroom

The cap comment claimed a full-cap window stays in the control lane
"unconditionally" and the guard test only asserted one frame fits the
lane, so a raise to 384-768 KiB stayed green while two concurrent
full-cap responses would already overflow the shared control queue --
which for a response closes the client. Pin the two-deep headroom and
state the real bound, including that widening the cap is a wire change
against a host still advertising rangedReadVersion 1.

Also cover the two behaviours the suite claimed but did not exercise: a
regular file answers a full-cap read in one syscall, so the fill loop
was untested (both mutations of readFullStreamChunk stayed green), and
the merged capability document made the abort-does-not-evict guard
load-bearing without any test reaching it.

* fix(remote): harden ranged-read validation and retry
2026-08-19 16:45:42 -07:00
Brennan Benson d4a8da9fdc fix(browser): scope a native cookie import's clear to the domains it imports (STA-4797) (#15375)
* fix(browser): scope a native cookie import's clear to the domains it imports (STA-4797)

A native import (Settings -> Browser -> Import Cookies -> From Google Chrome)
cleared the entire target partition's cookie jar, keeping only the
non-transplantable google.com family. Every unrelated site the user was signed
into in that partition was silently signed out, with no warning before and no
disclosure after. Importing three sites signed you out of every other one.

The stated rationale -- mixing stale and imported cookies makes sites reject the
session -- reaches only as far as the domains being imported. Beyond them a
clear has nothing to reconcile. The file/paste path already did the narrow
thing via replaceCookiesForImportedDomains; the two import paths simply
disagreed about scope, and the narrow one is the defensible shape.

The clear now covers only the domains the import writes, through one shared
predicate:

- browser-cookie-import-policy.ts: importedDomainScope() /
  domainIsInImportedScope() are exported as the single scope definition used by
  all three clears, so they cannot drift apart.
- browser-cookie-import-clear.ts: removeTransplantableCookies takes a required
  importScope. It is not defaulted -- a default would be the whole jar again.
  The scope test runs before the removal-URL derivation, so an unaddressable
  cookie parked in an unrelated corner of the jar no longer fails an import that
  was never going to touch it.
- The bulk clearData shortcut is gone, and clearData leaves CookieClearSession
  for the same structural reason 'set' already had. clearData clears by
  exclusion, so the only scope it can express is "everything except google.com"
  -- the defect. An include list is no better: it matches at the
  registrable-domain boundary, so it would still take host-only siblings the
  import does not replace, and a partial delete followed by a rejection would
  destroy them with no identity to restore from. The frozen per-coordinate plan
  is now the only removal path, and it covers the imported domains rather than
  the jar.
- browser-cookie-staged-image-clear.ts (new): the staged image is a copy of the
  live jar that replaces it wholesale on the next cold start, so its
  DELETE FROM cookies WHERE NOT (<google>) was a second whole-partition wipe.
  Narrowing the live clear alone would have re-erased the partition one restart
  later. It now clears to the identical scope, through the identical predicate.

The scope is named from the emitted plan, so the removal set is the write set.
Google stays exempt by policy (STA-3811), unchanged.

No wire or summary change: the summary's existing `domains` field already names
the imported domains, which is now exactly the scope that was cleared.

Tests: fixtures in this module start with an empty cookie jar, which is why a
full gate stack passed a session-erasing defect before. browser-cookie-import-
scope.test.ts uses a populated jar with a session for a site outside the import
set, and reads the staged file itself so the restart path is observed rather
than assumed. All three cases fail against pre-fix source. The real-Electron
partition test now seeds both an in-scope stale cookie (still removed) and an
out-of-scope live one (now survives).

* refactor(browser): drop the unreachable removal-URL failure branch (STA-4797)

Scoping the clear made the `Could not clear existing cookies` throw in
removableCookieEntries dead: a cookie whose domain does not normalize is now
skipped by the scope test above it, so nothing reaches the URL derivation with
anything but an already-parsed hostname.

Rather than leave a fail-closed branch that cannot fire — this module has been
misread before when a dead safety leg looked like a live one — the impossibility
is now structural. cookieRemovalUrl takes a normalizeCookieDomain output and
returns a string: `new URL` cannot throw on a host that already parsed as one,
and assigning pathname never throws. Both callers lose their null branch,
including the silent `if (url)` skip in replaceCookiesForImportedDomains, which
would have narrowed a removal plan without saying so.

The identically-worded throw in assertClearIdentitiesCoverRemovable is untouched
— that one is live and is what keeps the mutated set inside the restorable set.

* test(browser): re-anchor the native concurrency detector on the scoped clear (STA-4797)

#15095's detector read "has the second import started clearing yet?" off
clearData call counts. Scoping the clear removed the bulk clearData path, so
that signal is gone and the assertion measured nothing.

It now reads the same question off the removals themselves, which is strictly
more specific: the seeded jar holds a stale cookie for each import's own domain,
so `remove:old-a` present with `remove:old-b` absent proves the first import
cleared and the second has not — where a call count could not tell the two
apart. The completed run then pins the exact removal sequence, which also
records that each import clears only its own domain.

The seed had to move onto the imported domains for the same reason its own
comment already gave for not leaving the jar empty: under a scoped clear, a jar
holding only an unrelated site is the empty-jar case wearing a disguise -- the
clear returns having removed nothing and every assertion passes vacuously.

Mutation-checked: with the per-partition lock removed this test still fails, so
#15095's protection is intact and the re-anchoring did not hollow it out.

* fix(browser): merge staged cookie imports by domain scope (STA-4797)
2026-08-19 16:31:57 -07:00
Jinwoo Hong fcdbcf85d0 fix(terminal): keep a cold-parked pane's runtime-graph leaf while its PTY lives (STA-2854) (#15514) 2026-08-19 15:47:31 -07:00
Jinwoo Hong 7675da363e fix(remote): stop paired-tab resurrection, ghost agent rows, and frozen visible panes (STA-4593) (#15459) 2026-08-19 15:31:26 -07:00
Brennan Benson 0a853a5c0d fix(agent-hooks): stop backgrounded Claude sessions posting a stale pane key (STA-4769) (#15304)
* fix(agent-hooks): stop backgrounded Claude sessions posting a stale pane key (#9236)

A session started with `claude --bg` or `/background` runs in a worker under
the shared daemon, and that worker inherits the environment of whichever pane
first started the daemon — not the pane that dispatched it. ORCA_PANE_KEY there
names an unrelated pane, so the session's hooks overwrite that pane's sidebar
row, from any worktree.

It fails silently and successfully: the script re-sources the endpoint file, so
port and token self-heal and the POST lands, while the pane key has no refresh
path and stays wrong.

Measured on the wire against a throwaway listener: three sessions on one daemon,
and the one dispatched from pane B posted pane A's key and pane A's worktree on
SessionStart, UserPromptSubmit and Stop.

CLAUDE_JOB_DIR is set only in those workers — absent from all 68 live foreground
sessions on this machine — so it is the signal to decline. Declining is the only
option that exists: normalizeHookPayload rejects an absent paneKey outright and
AgentHookEventPayload.paneKey is a required string, so there is no "session with
no pane" representation to post instead. A backgrounded session genuinely has no
pane; attributing it to nothing is correct.

The Windows guard exits rather than jumping to the stdin drain: the drain parks
in more.com and a daemon worker is outside an Orca pane, which is exactly the
abandoned-stdin hang #11549 guards against.

* fix(agent-hooks): guard the Claude statusline against the same stale pane key

The statusline command IS invoked inside a backgrounded worker — measured, with
no client ever attached, and its ancestry terminates at the daemon rather than
at any pane:

  statusline pid=41746 <- claude bg-spare <- claude bg-pty-host <- claude daemon
  CLAUDE_JOB_DIR=/tmp/.../jobs/f1f9edd2

A second session dispatched from a different pane saw the first pane's
ORCA_PANE_KEY with its own correct session id, so this script is a live second
producer of the same misattribution the hook guard closes.

Windows uses exit /b 0 before stdin is owned, per the #11549 contract; POSIX
places the guard after capture, since exiting mid-write there surfaces as EPIPE
the agent can see (#8110).
2026-08-19 15:22:33 -07:00
Brennan Benson 15d2e31777 ci: bound the shell-contracts apt install so a stalled mirror cannot wedge the run (#15532)
* ci: bound the shell-contracts apt install so a stalled mirror cannot wedge the run

shell contracts wedges intermittently. It is not a lock, not a prompt, and not the
PR under test - it is download throughput with no wall-clock bound.

Measured on a passing run: apt-get update fetched 11.4 MB of index in 40s, then
apt-get install fetched 8.9 MB of packages at 65 kB/s taking 2m17s, while the
shell-contract tests the job exists to run took 14s. apt applies no wall-clock
bound to a stalled mirror, so when throughput drops below that already-poor
baseline the step runs indefinitely - observed at 12+ minutes and climbing while
every other job had passed.

The job also had no timeout-minutes, so it inherited GitHub's 6h default, and an
in-progress required check holds the whole run open and blocks rerun --failed.

Bounds both layers: timeout-minutes on the job (a passing run is ~4m30s), and
Acquire timeouts plus retries in apt.conf.d so a dead mirror fails fast while a
transient blip still passes. Written to apt.conf.d rather than onto the command
lines because pr-workflow-parallelism.test.mjs parses those invocations.

Does not make the job faster; the 3m38s of download is untouched. Scoping the
index refresh to just the PPA risks installing against a stale base index and
wants its own evidence.

* ci: bound the apt commands by wall clock, not per-connection timeouts

The first attempt at this set Acquire timeouts of 30s with 3 retries. That made
the wedge worse and the job's own timeout-minutes proved it: on this PR the
install step ran 14m26s and was killed by the 15 minute bound.

The log shows why. Acquire timeouts are per-connection, so a dead mirror costs
timeout x retries x every index file: 30s x 3 across roughly ten index files is
~15 minutes, which is what was observed. The azure archive mirror returned Ign
for every suite, apt fell back to archive.ubuntu.com, and that connection then
produced zero bytes for 14m26s.

So per-connection bounds cannot bound this step; only a wall-clock bound can.
Wraps both apt invocations in `timeout`, and drops Acquire::Retries to 1 so a
dead mirror fails once instead of multiplying. The update is already tolerant by
design, so bounding it just caps what a dead mirror costs before the install runs
against whatever index exists.

Also drops DPkg::Lock::Timeout: no lock contention was ever observed in these
logs, and an option added on speculation is not worth carrying.
2026-08-19 15:10:48 -07:00
Jinwoo Hong 174039a14e fix(workspaces): re-seed a terminal when an emptied workspace is opened (#15513) 2026-08-19 14:58:00 -07:00
Brennan Benson 9b8dbd8930 fix(mobile): keep a terminal lease alive when the handle lookup fails (#15463)
* fix(mobile): keep a terminal lease alive when the handle lookup fails

`waitForTerminal(..., 'exit')` rejects with `terminal_handle_stale` whenever the
handle cannot be resolved right now. The loudest source is
`record.rendererGraphEpoch !== this.rendererGraphEpoch` — every renderer graph
reload invalidates handles issued before it, on panes whose PTY is untouched.

All three terminal.subscribe branches wired that rejection straight to teardown
(`.catch(() => registration.releaseIfCurrent())`), so the host retired a live
lease and emitted `end`. Mobile reads `end` as "PTY gone" and rearms; after
MAX_REARM_ATTEMPTS = 3 it stops and leaves the composer on "Waiting for
terminal…", recovering only when a replacement handle arrives. A live PTY keeps
its handle, so no replacement ever comes: the pane is dead until the app is
backgrounded and reopened.

Demand proof instead. Retire on abort (this socket is going away) or when
isLeafPtyProvenAbsent says the process is gone; a real exit still resolves and
retires through the .then leg. Absence must be proven, never inferred from a
lookup that failed.

Reproduced live: a paired 0.0.44 client over LAN, fault injected on the handle
lookup, composer locked on "Waiting for terminal…" and STILL locked 97s after
the fault was lifted with the PTY alive and the handle unchanged.

Narrows one behavior #14992 pinned: a stale handle alone no longer retires the
owner. The ownership property is unchanged — once absence is proven, the owning
registration is still what retires. That test is updated, not deleted.

* fix(mobile): observe PTY exit independent of stale handles

* fix(mobile): stop subscribe setup when an exited pty releases synchronously

The runtime doubles for terminal.subscribe never stubbed subscribeToPtyExit,
so the new lifetime watcher threw and killed every subscription under test.

Also return early when the watcher releases synchronously: cleanup had already
run, so the stream handlers, view subscribers, and mobile presence registered
afterwards were never torn down.
2026-08-19 14:14:00 -07:00
Brennan Benson a3b3254885 fix(browser): grant storage-access so requestStorageAccess() stops rejecting (#15481)
* fix(browser): grant storage-access so cross-site frames can use their cookies

AUTO_GRANTED_BROWSER_PERMISSIONS omitted storage-access, and the installed
permission handlers deny anything absent from that set, so every
document.requestStorageAccess() in the embedded browser was refused silently.

Granting it unconditionally looks like a privacy hole, but that objection
assumes Orca blocks third-party cookies. It does not: nothing blocks or
partitions them and no Chromium switch touches cookie policy, so Electron's
default applies. A cross-site frame therefore already reads and writes its
unpartitioned cookies at the network layer, and denying the permission grants
no protection - it only breaks sites that take the API's failure path. Chrome
resolves requestStorageAccess() without a prompt under the same cookie policy;
Electron has no such fast path and forwards to the embedder, so the embedder
supplies it. The comment records the condition that would invalidate this.

top-level-storage-access stays denied. requestStorageAccessFor() is a separate
platform decision: Chromium consults Related Website Sets and has no
third-party-cookie auto-grant, and Orca has no such data source, so granting it
would invent a permissive answer to a restrictive question. A test pins it.

Does not fix Google sign-in - that was #15216. Sign-in completes with this
denial in place; this is an independent defect found while investigating it.

* test(browser): pin the denial notice storage-access used to raise

The suite proved the handler answers true but never pinned the symptom users
actually reported — the "asked for storage-access, and Orca denied it" notice.
The existing notified-list assertion runs before the storage-access request, so
a regression that re-denied it would have left that list untouched. Assert the
list after the new requests instead; it goes red pre-fix with 'storage-access'
present.

Drop the two vi.waitFor wrappers around the same requests. The request handler
is synchronous for every non-media permission, so the wait bought nothing and
imposed vi.waitFor's 1000ms default on a suite that allows 30s. The waitFor
guarding the media path is genuinely async and stays.

* refactor(browser): tighten the storage-access rationale and cover isolated partitions

Trim the grant's comment to the facts that change a reader's decision. The
mechanics of how the request reaches the embedder already live in the commit
message; what belongs at the call site is why the answer is grant, and why the
check handler must agree with the request handler — Electron builds neither
Chrome's activation gate nor its auto-grant, so a disagreeing check pushes
compliant sites onto the gesture path, where a rejection consumes the gesture.

Move the top-level-storage-access note below the set. It sat after the last
element with no trailing comma, so it read as a commented-out entry, and any
permission appended at the natural insertion point landed above it.

Cover the isolated-partition install path. createProfile and hydrateFromPersisted
call installBrowserSessionPartitionPolicies separately from the default-partition
path the persistence suite drives, and the two previous changes to this set each
added a matching isolated test. Verified red without the grant.

Rename the anti-detection case that claimed storage-access has a native denied
state; it is granted in production now, and the case really pins pass-through.

* docs(browser): correct the storage-access rationale

The revisit trigger was backwards. If Orca ever blocked third-party cookies the
grant would not become dangerous, it would become useless for cookies: Electron
builds no HostContentSettingsMap, so no STORAGE_ACCESS content setting is ever
written, and IsAllowedByStorageAccessGrant needs one. Point the tripwire at a
cookie or storage-partitioning control instead, which is the change that would
actually invalidate the reasoning.

The premise was also narrower than the grant. Third-party storage partitioning
is enabled by default and independent of cookie policy, and the same permission
lifts it for localStorage, IndexedDB, CacheStorage and friends via
StorageAccessHandle, which gates only on IsFullCookieAccessAllowed. So the frame
does not "already have" everything this grants. It stays the right answer
because Chrome grants the same permission under the same cookie policy, but the
comment should not claim a narrower blast radius than the change has.

* docs(browser): give the storage-access tripwire its consequence

Say what goes wrong, not just when to look. If Orca ever blocks third-party
cookies or gains a partitioning control, three separate gates stay shut in
Electron - the network-service grant check, the frame's trusted status, and the
STORAGE_ACCESS content setting that is never written - so the promise would
resolve while access stayed blocked. Sites follow the documented pattern of
reloading after a successful request, and on reload the check handler still
reports granted, so no gesture is needed to ask again. That loops.

Qualify the non-cookie clause: the no-arg call resolves undefined and touches
only cookies. It is the dictionary form that returns a handle, and since the
handler sees the permission name and never the call shape, one grant covers
both. Also give "check must agree with request" its reason.

Justify the isolated-partition test by the precedent it follows rather than by
a call-site divergence the shared mock cannot actually distinguish.

* docs(browser): scope the non-cookie clause to the handle

A live probe pinned down what the grant actually widens. The frame's ambient
window.localStorage and window.indexedDB stay partitioned before and after a
successful request; the unpartitioned view is reachable only through the handle
the dictionary form returns. Existing code in the frame is unaffected unless the
site explicitly calls through that handle, so say handle-scoped rather than
leaving a reader to assume the globals change.

* docs(browser): correct the isolated-test justification and the tripwire scope

"the isolated twin every other entry in this set already has" is false. Counting
occurrences in browser-session-registry.test.ts: fullscreen, clipboard-read and
clipboard-sanitized-write have none. Cite the pointerLock precedent the test
actually mirrors, which is the case directly above it.

Separate the two gates in the tripwire. The STORAGE_ACCESS content setting gates
cookies; the handle path is gated on IsFullCookieAccessAllowed instead, and a
live probe confirmed it works today. Saying "access stayed blocked" read as a
claim that the handle is backed by nothing, which contradicted the sentence
above it. Say cookie access, and say the handle survives.
2026-08-19 14:12:44 -07:00
GideonandBrennan Benson 0b8107a41f fix(browser): stop the auth-host UA write from cancelling redirects (#15221)
* fix(browser): stop the auth-host UA write from cancelling redirects

WebContents.setUserAgent() from will-redirect makes Chromium abort the
in-flight navigation (ERR_ABORTED) and replay the original request, so any
redirect crossing the Google auth-host boundary dies on a blank tab. Route
that write through the CDP override, which retargets navigator.userAgent
without touching the navigation.

Fixes #15216

* fix(browser): recover auth UA override state

* fix(browser): keep the viewport UA override on the session identity

The auth-host switch writes the Firefox UA through WebContents.setUserAgent on a
direct navigation and then moves to the CDP override, and nothing restores that
WebContents UA. sendViewportUserAgentOverride read it back as its base identity,
so a tab with a viewport preset republished the Firefox UA on every ordinary host
afterwards, with sec-ch-ua still saying Chrome. Read the profile's session UA
instead, which is the stable base identity at both call sites.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-19 13:06:54 -07:00
Brennan Benson 73f7767edd feat(sidebar): name the host when a delete batch collides on one id (#15423)
* feat(sidebar): name the host when a delete batch collides on one id

Two hosts can publish the same worktreeId, so a batch confirmation showed two
rows with identical names and paths and nothing to tell them apart. Label each
row with its execution host, but only when the batch actually contains a
same-id collision — an unconditional chip is noise.

* fix(sidebar): qualify colliding delete targets by saved host

* fix(sidebar): preserve delete host collision scope
2026-08-19 01:33:29 -07:00
OrcaWinandm4air 4fc8b65792 docs: add WeChat group 8 as fallback when group 7 is full (#15466)
Provides alternative community group with QR code when primary group
reaches capacity. Updates documentation in both English and Chinese.

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-08-19 00:57:49 -07:00
Brennan Benson 391385fd5c fix(workspace-cleanup): require a host-qualified key for every candidate row (#15422)
The virtualizer keyed rows by bare worktreeId, so two hosts sharing an id
collapsed onto one key in the confirm-remove dialog. getRowKey is now a required
prop supplied host-qualified by each caller, making omission a compile error
rather than a silent fall back to the colliding id.
2026-08-19 00:29:11 -07:00
github-actions[bot] 41c4d8e8a8 Update README downloads badge 2026-08-19 06:28:03 +00:00
OrcaWinandm4air 90a292eec2 Fix WSL stall tests to reset gate state and fake performance timer (#15414)
Reset persistent WSL transcript filesystem gate state in beforeEach to
prevent prior test stalls from quarantining subsequent tests.

Fake the performance timer used by the route quarantine clock so tests
don't block on real time. Update affected tests to wait out the back-off
window rather than advancing by zero time.

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-08-18 23:20:24 -07:00
Neil 1ca752a7a4 test(e2e): keep the native Hangul reproduction harness (#15438)
* test(e2e): keep the native Hangul reproduction harness

This is the spec that reproduced #15299: it drives a real ibus-hangul
engine through a real compositor and asserts the bytes reaching the pty.
It is the first setup here that can exercise an input method end to end,
and three IME issues this week were unreproducible without one.

It does not run in CI, and the header says so rather than implying
coverage. It needs a compositor session CI does not have, and this repo
already carries native IME specs that are skipped everywhere and were
mistaken for protection they never gave. The run recipe is in the header
so the next person does not rebuild it.

Recorded there too are the five things that decide whether a run is real
or a silent false negative - nested rather than headless, an unused
display, a session script that does not exit, forcing the window
visible, and sending Escape before the byte reader starts. Each cost a
failed attempt, and four of them are what defeated an earlier try.

Keys and expected text are environment-tunable so other IME issues can
reuse it unchanged.

Refs #15299

* test(e2e): record three more silent-false-negative traps in the native IME harness

A Hanja candidate-selection run on the same rig hit all three. Each produced an
empty or misleading event log that reads as "the IME ignored the key" rather
than as a broken harness, which is the failure mode this header exists to
prevent.

The panel one is the least obvious: a session whose ibus-daemon runs with
--panel=disable never draws a lookup table, so any run that depends on seeing
candidates measures nothing while appearing to work.

Refs #15299
2026-08-18 23:15:32 -07:00