Commit Graph
178 Commits
Author SHA1 Message Date
Neil f975035809 refactor(ipc): split preflight and SSH registry out of the ipcMain modules (#15927)
* refactor(preflight): split agent detection out of the ipcMain registration

First of the IPC extractions the revised design requires. `src/main/ipc/preflight.ts`
mixed 285 lines of agent/tool detection with 35 lines of `ipcMain.handle`
registration, and the runtime calls that detection during normal operation
(`orca-runtime.ts:573`, plus the preflight RPC methods). So the runtime dragged
`ipcMain` into its graph to reach pure logic.

Detection moves to `src/main/preflight/agent-detection.ts` — named for what it
contains, per AGENTS.md. `ipc/preflight.ts` keeps only the handler registration and
re-exports the domain module so existing importers are unaffected. The runtime and
its RPC methods now import the domain module directly.

Ratchet baseline 36 → 35: `src/main/ipc/preflight.ts` is no longer reachable from
the runtime. The gate detected the improvement and refused to pass until the
baseline tightened, which is the behaviour it was built for.

Verified: 2 files / 1,187 tests pass across every suite touching preflight;
`pnpm typecheck` clean; `oxlint` clean.

* refactor(ssh): split the SSH target registry out of the ipcMain module

Second IPC extraction, and by far the biggest win: this removes **eight** modules
from the runtime's Electron graph, taking the ratchet baseline 35 → 27.

The runtime needed five thin accessors from `src/main/ipc/ssh.ts` —
`connectRegisteredSshTarget`, `getRegisteredSshState`, `listRegisteredSshTargets`,
`listRegisteredRemovedSshTargetLabels`, `getActiveMultiplexer`. Each is a one-line
read over module-level state. Importing them dragged in `ipcMain`, `powerMonitor`
and a `BrowserWindow` accessor — and, transitively, `ipc/pty.ts` (8,031 lines),
`ssh-browse`, `ssh-passphrase`, `ssh-relay-deploy`, `ssh-remote-cli-host-passthrough`,
`wsl-hook-relay-launch` and `user-data-path`.

`src/main/ssh/ssh-target-registry.ts` now holds that state plus its accessors.
`registerSshHandlers` populates it; the runtime reads it. The indirection is kept
deliberately: SSH providers register after construction and may reconnect, so
callers must resolve the current generation rather than freeze one.
`ipc/ssh.ts` re-exports all five, so non-test importers are unaffected.

`connectRegisteredSshTarget` still throws `ssh_handlers_not_registered` when no
handler layer registered — a headless host must fail loudly rather than report a
target as unreachable, which would read as `exited` (see ssh-execution-boundary.md).

Verified: 9 files / 59 tests across the ssh, automations and trust-preset suites;
orca-runtime.test.ts 1,183 pass; `pnpm typecheck` clean; `oxlint` clean.

* refactor(host): resolve the app root through the port in fork-reachable modules

`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.

`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.

Ratchet baseline 27 → 25.

Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.

* test(ssh): mock the SSH target registry alongside the ipc/ssh mock

Thirty-eight suites mocked `vi.mock('./ssh')` for `getActiveMultiplexer`. That
factory went inert when production started importing the accessor from
`../ssh/ssh-target-registry`, so the real module loaded and the assertions drifted.

Adds a companion registry mock returning the same stub, plus a
`sshTargetRegistryModuleMock` builder beside the existing `sshModuleMock` so the
shared harness stays one place. No assertion changed.

Found by a full-suite run: the targeted ssh/runtime suites were green while
30 tests in ipc/worktrees and ipc/repos were not.

* refactor(runtime): read app paths and the packaged flag through the port

`orca-runtime.ts` is the last module in its own graph that imports `electron`
directly. Nineteen of its uses were `app.getPath` (12) and `app.isPackaged` (7) —
exactly what the AppEnvironment port already covers.

Also removes a dead `const { app } = require('electron')` inside
`getOrchestrationDb`. It was left unused once the path came from the port, and it
is precisely the dynamic-require pattern `plain-node-entry-guard.ts` exists to
catch, sitting in the runtime's own constructor path.

What still binds `orca-runtime.ts` to Electron is now three sites, not nineteen:
`new Notification(...)` (one), `BrowserWindow.fromId` (one), and the
`ipcMain.on('terminal:tabCreateReply')` renderer round-trip — which is the browser
tab path, and the same one that would hang a headless host for ten seconds.

Two suites drove `electronMocks.app.isPackaged` directly; they now install a fake
AppEnvironment reading the same mutable field, so their per-test toggles work
unchanged and no assertion moved.

Verified: 376 files / 4,717 tests across src/main/runtime; typecheck and oxlint clean.

* test(serve): add the built-artifact terminal round-trip acceptance smoke

"The server started" proves almost nothing. Terminal creation dispatches into
OrcaRuntimeService, and without an installed headless PTY controller that path
falls through to a renderer reply that never arrives and times out after ten
seconds. A boot probe, a port bind, and a `host.platform` call all pass against a
server whose terminals are dead — which is exactly the gap the design doc's own
boot proof was retracted for.

This boots the BUILT `out/main/index.js --serve`, parses its ready payload, pairs a
real client over the advertised endpoint, lists worktrees, creates a terminal, runs
a command through the PTY, asserts the output comes back, and asserts clean
shutdown. It drives nothing but the public pairing + RPC surface, so the same
script is the acceptance gate a future Node-only backend must pass unchanged.

The sentinel invokes `process.execPath` rather than `echo`, because the shell
differs per platform and node does not.

Verified both directions: passes against the real server, and fails with an
actionable message when the command produces no output — a smoke that cannot fail
is worthless.

* fix(ssh): fail loudly when the multiplexer resolver was never installed

`getActiveMultiplexer` resolves through a resolver that `ipc/ssh.ts` installs at
module scope. A process that never loads the SSH layer — which is the whole point
of the Node-only backend — would get `undefined` from every call.

`undefined` already means something specific here: "not connected". So a missing
resolver and a disconnected target were indistinguishable, and a host with no SSH
layer would quietly report every target as not connected. That is the
unverifiable-reported-as-exited conflation `docs/reference/ssh-execution-boundary.md`
exists to prevent — the doc is explicit that absence of contact is never evidence
of absence of the thing.

A missing resolver is a wiring error, not a connection state, so it throws, matching
what `connectRegisteredSshTarget` already does for unregistered handlers.

Verified: 432 files / 4,759 tests across ipc, ssh, preflight, automations and trust
presets; typecheck and oxlint clean.

* refactor(pty): stop faking a BrowserWindow for the headless PTY path

`registerHeadlessPtyRuntime` passed `registerPtyHandlers` a stub object cast to
`BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send`
was a no-op — a window-shaped thing that lied about being a window, purely to
satisfy the type. Adversarial review named it as the same "looks fine, silently
returns a lie" pattern this codebase rejects elsewhere, and it is the shape that
keeps `electron` on a path that otherwise needs none.

`registerPtyHandlers` now takes `BrowserWindow | null`. An absent renderer is
semantically identical to a destroyed one — all 42 call sites already guarded on
`isDestroyed()` and skipped — so `src/main/ipc/pty-renderer-surface.ts` states that
directly: `isRendererGone`, `sendToRenderer`, `rendererWebContents`. The compound
`isDestroyed() || webContents.isDestroyed()` guards collapse into one predicate.

`isPtyWriteEventFromMainWindow` becomes null-tolerant and fails closed: with no
renderer no sender can legitimately match, so every write is rejected. Those
handlers cannot fire headless today, but failing closed is the right answer if that
ever changes.

This is the precondition for installing a PTY controller without Electron, which is
what a Node-only backend needs and what `terminal.create` actually calls.

Verified: 129 files / 2,473 tests across ipc/pty, providers and orca-runtime; the
built-artifact acceptance smoke still passes end-to-end (boot → pair →
terminal.create → sentinel → close), which is the check that matters most here
since this changes the headless PTY path itself; typecheck and oxlint clean.

* refactor(pty): read app paths and the packaged flag through the port

Follows the fake-window removal. `ipc/pty.ts` had nine `app.*` reads — all
`getPath`, `getVersion` or `isPackaged` — which the AppEnvironment port already
covers. The `BrowserWindow` import was also dead after the null-window change.

What still binds this file to Electron is now `ipcMain` (75 uses, all handler
registration) and `powerMonitor` (2). That is a clean statement of the remaining
job: split logic from registration, the same shape already applied to preflight
and the SSH registry.

Test wiring: the shared `pty-ipc-suite-environment` beforeEach installs a fake
AppEnvironment that reads through the existing `vi.mock('electron')` app object
rather than freezing values — suites toggle `app.isPackaged` mid-test to exercise
dev-mode spawn paths, so the port has to observe the same mutable field. One edit
in the shared harness covers every pty suite.

Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact
acceptance smoke passes; typecheck and oxlint clean; ratchet unchanged at 25.

* refactor(pty): inject the ipcMain surface so the PTY module loads without Electron

This closes the round-3 blocker: "the doc never says how orcad installs
setPtyController without Electron."

`registerPtyHandlers` owns the `RuntimePtyController` that `terminal.create`
actually spawns through — the thing a Node backend needs and cannot get from the
provider thunks. The module was otherwise host-agnostic already; the only thing
pinning 8,031 lines to Electron was a static `ipcMain` / `powerMonitor` import used
purely to register renderer handlers that no headless host will ever receive.

`src/main/ipc/pty-host-bindings.ts` makes those surfaces settable, defaulting to
no-ops. Unlike AppEnvironment and SecretStore, the default does NOT throw: a host
with no renderer legitimately has nothing to register against, so not registering
handlers nobody can call is correct rather than a hidden downgrade. The desktop
installs the real objects in `attach-main-window-services` before its handlers run.

Also converts the remaining electron import to a top-level `import type`. oxlint's
`no-import-type-side-effects` caught that inline `type` specifiers still leave a
side-effect import — precisely the "type-only is not enough if esbuild still emits
require('electron')" trap a reviewer flagged.

**`src/main/ipc/pty.ts` now bundles with zero `require("electron")`.** A Node entry
can call `registerPtyHandlers(null, runtime, …)` and get a working PTY controller.

Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact
acceptance smoke passes end-to-end — which is the check that matters, since this
changes how every PTY handler registers; typecheck and oxlint clean.

* fix(pty-bindings): drop two unused eslint-disable directives

CI runs oxlint with unused-disable reporting; the two
`@typescript-eslint/no-explicit-any` suppressions I added were never triggered by
any enabled rule, so they failed static analysis as dead directives. The `any[]`
rest args stay — they mirror electron's own IpcMain signature, and narrowing them
would reject the real object at the desktop call site.

Verified with the exact CI invocation: `oxlint --format github` reports 0 warnings,
0 errors across the repo.

* fix(pty): install the host bindings per process, not per window

A real regression my own change introduced, caught by the SSH docker E2E
(`paired-startup-exec-readiness` — "recovers startup exec through a headed paired
desktop owner"). It reproduced on rerun, so it was not a flake.

`setPtyHostBindings` was called inside `attachMainWindowServices`, i.e. when a
window attaches. But `registerHeadlessPtyRuntime` (index.ts:3163) calls
`registerPtyHandlers` on the serve path *before* any window exists — so those
handlers registered against the no-op default and never reached the real `ipcMain`.
A paired desktop owner then attached to a runtime whose PTY handlers were wired to
nothing.

The bindings describe the *host*, not the *window*: an Electron main process always
has `ipcMain`, whether or not a window is open. Installing them beside
`setAppEnvironment`/`setSecretStore` at the top of bootstrap fixes both paths.

Verified: 128 files / 1,290 tests; the built-artifact acceptance smoke passes;
typecheck clean; `oxlint --format github` (the exact CI invocation) reports 0/0.

* feat(orcad): de-electron the runtime core and add the Node entry + build gate

**`src/main/runtime/orca-runtime.ts` — 41,048 lines — no longer imports electron.**
Its last three sites go through `runtime-desktop-surface.ts`: a native notification,
the authoritative-window lookup, and the one `ipcMain` channel used by the
renderer-backed tab-create fallback. All three are unreachable without a renderer —
`createTerminal` already takes the background branch when no window exists (#10333) —
so a Node host installs none and the runtime relays notifications to paired clients,
which is the better destination anyway. Ratchet 25 → 24.

Adds `src/main/orcad/orcad-entry.ts`: Node host adapters plus a `startOrcad` that
constructs the runtime, installs the PTY controller via `registerPtyHandlers(null, …)`,
and serves RPC. It sets two defaults the constructor gets wrong for a headless host —
`canRecoverPersistentLocalPtys: false` (no daemon here) and
`getDesktopWindowStatus: 'blocked'` (a Node host can never be promoted to a desktop
window, which is what `'openable'` claims).

Adds `config/scripts/build-orcad.mjs`, which **currently fails, on purpose**: 25
modules still import electron (browser and speech clusters, plugins, jira/proxy,
filesystem-watcher, and four `require('electron').app` one-liners). It names them.

Two bugs found while building it, both worth recording:
- The first bundle looked clean and was not. `electron` was bundleable, so esbuild
  rewrote the metafile `path` to the resolved file under node_modules and a check for
  `path === 'electron'` passed while the package was in the bundle — it failed at
  runtime with electron's own installer message. The check now reads `original`, and
  electron is marked external so a residual import fails loudly instead.
- `jsonc-parser`'s UMD build breaks the bundle at load; aliased to its ESM entry, the
  same fix `build-relay.mjs` already carries.

Verified: desktop unchanged — the built-artifact acceptance smoke passes, runtime/pty/
provider suites green, typecheck clean, `oxlint --format github` 0/0.

* refactor(host): drop the last two require('electron') app lookups

`computer/sidecar-client.ts` and `ports/port-scan-command-client.ts` read the app
root through `require('electron').app` inside a try/catch. Both were already correct
under plain Node at runtime — they return null when it throws — but the literal text
fails the plain-Node entry guard regardless, which is why port-scan carried a comment
warning it must never become reachable from a fork entry.

Reading the AppEnvironment port gives the identical "no app root here" answer without
the text, so that warning is now obsolete and the comment says so.

Ratchet 24 → 22. Every remaining entry is a real coupling: the browser cluster (15,
which variant B does not ship), speech (2), plugins (2), and jira/proxy-settings (2,
needing an HttpClient port for Chromium session partitions).

Verified: 25 files / 209 tests; acceptance smoke passes; typecheck and
`oxlint --format github` clean.

* docs(orcad): record that the ratchet under-counts orcad's graph

The ratchet reports 22 electron importers; the orcad build reports 23. The extra is
agent-hooks/wsl-hook-relay-launch.ts, and the cause is a gap in the gate rather than
a rounding error: the ratchet measures what orca-runtime + runtime-rpc reach, while
orcad's entry also imports ipc/pty directly to install the PTY controller.

Once orcad ships it must become a ratchet entry point, or the two numbers drift and
the gate quietly stops covering the artifact it exists for.

* refactor(runtime): inject the browser commands factory

Drops 14 modules from the runtime's Electron graph in one change — the whole Chromium
browser cluster. Ratchet 22 → 8.

`OrcaRuntimeService` constructed `RuntimeBrowserCommands` as a field initializer, and
that construction is what pulled in `BrowserWindow`, `session`, `webContents` and the
cookie jars. Importing the class for its *type* is free; only building it costs.

So the class import becomes `import type`, and the instance comes from
`runtime-browser-commands-factory.ts`. The desktop installs the real factory at the
Electron entry. **All ~80 existing `this.browserCommands.*.bind(...)` delegations are
untouched** — a review round specifically warned that rewriting those was the
expensive, risky part, and this avoids it entirely.

With no factory installed, browser commands reject per call with `browser_unavailable`
rather than resolving to a stub that silently succeeds. The runtime already filters
browser capabilities out of `getStatus()` when no backend exists, so clients do not
offer the affordance in the first place.

Also corrects a stale comment in `pty-renderer-surface.ts` that still described the
fake window as present tense; it was deleted two commits ago.

Verified: 451 files / 5,513 tests across `src/main/browser` and `src/main/runtime` —
the entire browser automation suite; the built-artifact acceptance smoke passes;
`pnpm typecheck` and `oxlint --format github` clean.

* refactor(host): extract the plugin client list and port two app lookups

Ratchet 8 → 5.

- `listPluginsForClients` moves to `src/main/plugins/plugin-client-list.ts`. It needed
  only three `plugins/*` helpers, none of them Electron — it was colocated with
  `ipcMain.handle` registrations, so the runtime's `plugins.list` RPC dragged all of
  Electron in to call a function that reads a lockfile. Same shape as preflight.
  Dropping it also releases `ipc/plugin-marketplaces.ts`.
- `agent-hooks/wsl-hook-relay-launch.ts` and `speech/stt-service.ts` read `getAppPath`
  and `isPackaged` through the AppEnvironment port.

The five that remain are all genuinely Chromium and need the HttpClient port or a
watcher split, not another mechanical swap: `browser/cdp-bridge` (webContents),
`ipc/filesystem-watcher` (ipcMain), `jira/authenticated-request` and
`network/proxy-settings` (net + session partitions), `speech/model-manager`
(`net.request`, which honors app proxy settings that Node https does not — replacing
it is a behaviour change, not a rename).

Verified: 219 files / 1,922 tests across plugins, speech, agent-hooks and the runtime
RPC methods; the built-artifact acceptance smoke passes; typecheck and
`oxlint --format github` clean.

* refactor(network): resolve the default proxy session lazily

Ratchet 5 → 4.

`proxy-settings.ts` needed exactly one Electron value: `session.defaultSession`, as
the fallback when a caller does not pass `options.proxySession`. Callers could already
inject a session; only the default was hard-wired. It now comes from a settable
resolver, so the module loads under plain Node.

**A resolver rather than a Session, because a Session eagerly throws.** The first
attempt installed `session.defaultSession` directly in pre-ready bootstrap and broke
startup outright — `TypeError: Session can only be received when app is ready`. The
acceptance smoke caught it before commit. Deferring to first use is always after ready.

Behaviour with no session is not a degradation: there is no Chromium proxy config to
discover, so `resolveProxy` is skipped and the environment variables become the whole
answer rather than a fallback. Applying rules to a session that does not exist is
likewise skipped; settings are still honoured because outbound requests read the env.

This reaches past Jira — a review round noted `ensureElectronProxyFromEnvironment` is
also on the Claude HTTP path via `oauth-refresh.ts` and `rate-limits/claude-fetcher.ts`.

Verified: 48 files / 526 tests across network, jira and rate-limits; the
built-artifact acceptance smoke passes; typecheck and `oxlint --format github` clean.

* fix(index): merge the duplicate proxy-settings import

CI's code-quality lint (`oxlint --config config/oxlint-code-quality-native-plugins.json
--deny-warnings`) flags a module imported twice in one file. My earlier insertion added
a second `./network/proxy-settings` import beside the existing one.

Verified with CI's exact invocation: exit 0.

* refactor(network): add the HttpClient port and lift BrowserError out of cdp-bridge

Ratchet 4 → 2.

Two unrelated couplings, both of the same shape — a small thing living inside a
Chromium-heavy file.

`BrowserError` is a seven-line error class with no dependencies, but it lived in
`browser/cdp-bridge.ts`, which imports `webContents`. The runtime catches that type on
paths with nothing to do with CDP, so one import kept a Node host from loading the
runtime at all. Moved to `browser/browser-error.ts`; cdp-bridge re-exports it.

`jira/authenticated-request.ts` fetches through `net.fetch` and reads
`session.defaultSession`. `network/http-client.ts` makes both settable. This one is a
**named port rather than a silent fallback, because the fallback is not transparent**:
Electron's net follows Chromium session/proxy state, avoids undici's stale keep-alive
sockets after a VPN path change, and sends a Chrome user agent that Jira's XSRF check
depends on. A Node host gets `globalThis.fetch`, reads proxy config from the
environment, and sends Node's user agent. That difference is documented at the port.

`session.defaultSession` is read per call, not captured at install — it throws before
the app is ready, which is the mistake the previous commit made and the acceptance
smoke caught.

Test wiring: `jira/client.test.ts` installs the port *inside* `loadClientModule`, after
its `vi.resetModules()`, since the reset gives the module a fresh singleton.

Verified: 461 files / 5,616 tests across jira, browser, network and runtime; the
built-artifact acceptance smoke passes; typecheck, `oxlint --format github` and the
code-quality lint with `--deny-warnings` all clean.

* fix(http-client): register the Node fetch fallback with the call-site audit

`global-fetch-call-site-audit.test.ts` guards every global-fetch use, because the
global runs on undici where an unread response body can crash the whole process
(orca#8695). The HttpClient port's Node fallback is a new such call site and was
unregistered — the guard caught it in a full-suite run.

Registered with the reasoning, and the port's doc comment now states the body-safety
contract explicitly: it hands the Response straight to its caller and never inspects
it, so the consume/cancel obligation stays exactly where it already was — with the
caller, unchanged from when they called Electron's net directly.

Two comments elsewhere mentioned the global by name and tripped the line scan as false
positives; reworded to describe the behaviour rather than name the API.

Verified: audit passes; typecheck and `oxlint --format github` clean.

* fix(app-environment): read hasAppEnvironment through the realm slot
2026-08-22 21:34:39 -07:00
NeilandOrcaWin 98c03fe12f fix(win32): hide the console window for agent-browser and git helpers (#15887)
* fix(win32): hide the console window for agent-browser and git helpers

W1 routed most child processes through `runProcess`, which always sets
`windowsHide`. Six call sites still spawn directly, so each one opens a real
console window on Windows: it flashes and steals foreground. For the git status
poll, that is once per poll (#10488).

A ratchet now scans every file that imports `child_process` and fails on a call
without the flag. Its allowlist starts at the 76 files that still offend and can
only shrink — it doubles as the worklist for routing them through the chokepoint,
which is where the flag stops being a per-call-site decision at all.

Diagnosed in #14589; the SSH and cookie-import sites it also covered are already
fixed on main by the W1 migration.

Co-authored-by: OrcaWin <orcawin@users.noreply.github.com>

* test(wsl): stop the exec-mode guard scanning historical release checkouts

The cross-version e2e lane checks whole past releases out under
`tests/e2e/.cross-version-checkouts/`. The guard walked into them, so on any
machine that had run that lane it reported 21 offenders -- every one a copy of
shipped code we cannot edit -- and failed. Skip dot-directories; the >500-file
vacuity assertion still holds.

---------

Co-authored-by: OrcaWin <orcawin@users.noreply.github.com>
2026-08-22 00:18:02 -07:00
Denis Darii d5667376b0 feat(dashboard): add a keyboard shortcut to toggle the Agent Dashboard (#15353)
Adds a configurable, unbound-by-default `dashboard.toggle` action that toggles the Agent Dashboard (in-window drawer or pop-out, per the existing mode setting).

- Wired through window-shortcut-policy, main-window dispatch, browser-guest dispatch, preload, and the renderer IPC handler.
- Opening the in-window drawer reveals the sidebar first; closing leaves it alone.
- Gated on the `experimentalAgentDashboardPopout` experiment, and the Settings shortcut row is hidden while that experiment is off.
2026-08-21 21:12:12 -07:00
Neil b7e79b7ca6 fix(windows): one chokepoint for every child process (#15746)
* feat(process): add the Windows-correct child-process chokepoint

Six decisions have to be made every time Orca starts a child process --
console visibility, argument quoting, .cmd interpretation, binary
resolution, timeout policy, and how the tree is later terminated. POSIX
forgives all six. Windows punishes each differently, and made per-call
site across 172 files they were right in some and wrong in others.

runProcess/spawnProcess make them once:
- windowsHide unconditionally, shell:false unconditionally (shell:true
  concatenates argv unescaped and silently disables windowsHide)
- .cmd/.bat routed through cmd.exe /d /v:off /s /c with a verbatim line,
  because Node refuses to spawn them otherwise (EINVAL)

The encoding was derived by measurement on Windows 11, not from the
docs. An embedded quote is written "" rather than \" so cmd's naive
quote count stays even -- with \" the parity flips and every later &
| < > on the line stops being data. Measured before the fix, argv
["a b", 'c"d', "e%F%g", "h&i", "j^k"] arrived as
["a b", 'c"d', "e^%F^%g", "h"]: the & truncated the argument and
ran its remainder as a command. Each % is broken out of the quoted run
as "^%" because %VAR% expands even inside quotes.

The import-boundary test is a ratchet seeded at today's 172 files; it
only shrinks.

* fix(process): route the console-flashing spawn sites through the chokepoint

The ssh -G config probe fires on every connect and reconnect, and ssh.exe
is console-subsystem, so a GUI-subsystem parent gets a fresh visible
conhost that takes foreground -- keystrokes typed into an Orca terminal
at that moment go into the black box (#10488, #14543). Same for the
ProxyJump tunnel, the ProxyCommand cmd.exe wrapper, the font enumeration
and the DPAPI cookie decrypt.

Also stops spawning powershell by bare name: PATH under Electron is not
the user's, so where policy has pruned the System32 entry the spawn fails
and the font picker silently reports five hardcoded families rather than
an error (#11771).

Deletes system-fonts' 40-line bespoke execFileText -- timeout, output cap
and kill are the chokepoint's job now. Adds runProcessSync so the sync
callers have a compliant path; without one the ratchet could never
reach zero.

The three suites that mocked child_process directly now mock runProcess,
which is the point: how a process gets started is no longer each
module's business. Ratchet 173 -> 170.

* fix(process): do not report a deliberately killed child as timed out

runProcessSync inferred a timeout from signal === 'SIGTERM'. Measured:
a real timeout sets error.code ETIMEDOUT and kills with SIGTERM, but so
does anything else that terminates the child -- and those cases set no
error at all. Reading the signal alone reports a process someone stopped
on purpose as having timed out, which callers retry.

* refactor(process): hold the ratchet as data and migrate the pwsh probes

The allowlist and the adversarial argument corpus are read only by tests,
so they were production modules in name only; they move to __fixtures__.

pwsh.ts carried isTimeoutError() purely to reconcile two spellings of the
same event -- execFileSync reports a timeout as ETIMEDOUT, the execFile
callback as a SIGTERM kill with no code. runProcess reports one timedOut
flag, so the helper and the reasoning behind it both go.

Its sync probe also spawned without windowsHide, which flashes a console
and steals foreground on every cold cache read.

* refactor(process): migrate five more spawn sites onto the chokepoint

Each one deletes a hand-rolled promise/timeout/kill wrapper and stops
re-deciding console visibility for itself. Ratchet 170 -> 164.

Two things this surfaced, both kept:

runProcess now accepts string chunks as well as buffers. A stream someone
called setEncoding on emits strings, and concatenating those as buffers
throws inside a data handler -- where the rejection has nowhere to go and
the caller simply hangs rather than failing.

ProcessSpec keeps its AbortSignal. I had removed it as unused; the macOS
PAM preflight passes one through from its own caller.

ipc/app.ts is deliberately NOT migrated. Its probe spawns a three-stage
 pipeline detached so a timeout can reap the group with one
negative-pid SIGKILL; runProcess kills only the root, which would orphan
the plutil stages. Migrating it needs the chokepoint to own POSIX
process-group termination first -- the same guarantee job objects give on
Windows. Reverted and left on the ratchet.

* test(process): do not assert a POSIX signal on Windows

Windows has no signals, so the same deliberate kill reports an exit code
there and a signal on POSIX. What has to hold on both is that neither
shape reads as a timeout. Caught by running the suite on Windows.

(cherry picked from commit 0a6e9902a22a369a0e85e113ea8d87b726f82e1f)

* fix(process): settle a timed-out run even when the child ignores the kill

close only fires once the child is actually gone, so a child that traps
SIGTERM never emits it and the promise outlives its own deadline
forever. That is the same wedge shape just fixed for the process table,
and it is worse here: pwsh.ts and the snapshot reader both cache an
in-flight probe, so one unkillable child hands every later caller the
same dead promise.

After the deadline it now escalates to SIGKILL and settles regardless,
reporting timedOut with whatever output arrived.

(cherry picked from commit 78ac169197c4e6faee1b9310a7186029cc11acbc)

* fix(process): escalate an aborted child too, not just a timed-out one

The grace escalation I added covered the timeout path and left abort on
the old one, so an aborted caller with an unkillable child still waited
forever -- the same defect, one path over. The macOS PAM preflight is a
real caller that passes an AbortSignal.

Both paths now share one stop-and-settle, and the result reports
timedOut honestly: false when the caller aborted.

(cherry picked from commit 7e9523a9e31172bb8183661b56f04c3ab6a03d0d)

* fix(windows): stop percent escaping from forging an escaped quote

escapePercentForCmd ran as a post-pass over the quoted string, so it
inserted a quote wherever a percent was -- including straight after a
backslash. CommandLineToArgvW reads backslash-quote as an escaped quote,
so C:\Users\%USERNAME%\x arrived corrupted. That is about as common as
Windows paths get, and my 20-case corpus had no backslash-before-percent
entry to catch it.

Percent handling is now part of the quoting loop, where the backslash
run is known and can be doubled before the inserted quote. Two corpus
cases cover the shape.

The program path gets the same treatment. It was quoted but not
percent-escaped, so a launcher under C:\Users\%USERNAME%\ had its own
path expanded on the cmd hop.

quoteWindowsArgument no longer takes a boolean. Passing it to
values.map() handed map's index in as the flag -- which is how the first
version of this fix was written, and the corpus test caught it.

Separately: an AbortSignal that was already aborted never fires the
event, so runProcess ran the child to its full timeout for a caller who
had already given up.

(cherry picked from commit f7e2e56b1ee1f27ab6d1035dde4501b38f95b374)
2026-08-21 21:05:24 -07:00
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
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
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
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 9312c5cd9b fix(browser): serialize cookie imports per partition (#15095)
* fix(browser): serialize cookie imports per partition (STA-4601)

Two concurrent cookie imports on one partition can erase a session. Nothing
serialises imports per partition — neither the renderer IPC handler
(browser-session-profile-ipc.ts) nor the runtime RPC method
(orca-runtime-browser.ts) — and the clear lock covered only the clear itself,
so it was released before the writes and before any rollback.

Reachable interleaving:

  import A replaces cookies for its imported domains, releases the lock, and
  later fails a write; import B clears and writes; A's rollback then removes
  cookies B already wrote and reported as imported.

Path B has the same shape: A clears, B clears, then A writes its cookies on top
of B's jar.

Fix: one lock spans the whole live-jar transaction on both paths — the
clear/replace, the writes, and the rollback. withCookieClearLock becomes
acquireCookieMutationLock/withCookieMutationLock so path A can hold it across a
try/finally rather than a single callback.

Rebased onto #15030 (STA-4300), which rewrote this file: imports now write
through CDP identities, so path A's writes moved from an inline cookies.set()
loop into writeImportedCookies(), path B's into the same helper, and — the part
that matters for a lock — importValidatedCookies no longer holds the Electron
Session at all. It receives a CookieImportTarget that hides the Session behind
openWriteStore(), and openWriteStore() builds a FRESH adapter per call, so
keying the lock on anything reachable from it would serialise nothing. The
target therefore carries mutationLockOwner, set to session.fromPartition's
instance, which is the same object the native path locks on. That is what keeps
both paths on one key per partition.

Deliberately bounded: this covers the LIVE JAR only. Staging and cold-start
replay keep their existing semantics, so two other pre-existing hazards in that
subsystem are untouched and still open — the crash window between the clear and
the result, and a permanently-armed replay when replay keeps failing. Both need
the pending-image operations to become provable, which is a different change.

The lock is keyed per owner, so imports into different partitions still run
concurrently.

Mutation-proved against the rebased tree, every mutation structure-preserving
(brace/paren balance pinned) and every one re-run rather than carried over:
dropping path A's acquire reddens the file-import detector; keying path A's
acquire on a fresh object instead of mutationLockOwner reddens it too — the
lock-present-but-miskeyed shape this rebase risks; replacing path B's lock with
a passthrough reddens the native detector; doing both reddens both, which rules
out incidental serialisation; and neutering the cold-init probe's lock reddens
the new probe detector. That last detector is new: neutering the probe lock
previously left all 779 browser tests green, so the probe's protection was
unproven.

The concurrency suite's store stub also gained writeCookieIdentity and
getStoragePath. Without them the STA-4300 code throws a TypeError that
writeImportedCookies catches as a write rejection, so the imports would have
taken the failure path while the ordering assertions still passed. Each real
import test now asserts writeCookieIdentity was CALLED, and that cookies.set
never was.

src/main/browser: 72 files, 780 tests, green; typecheck, oxlint, type-aware
code quality, oxfmt and the max-lines ratchet all clean.

* fix(browser): serialize native cookie staging

* test(browser): pin native flush serialization

* test(browser): pin staged cookie replay ordering

* test(browser): remove vacuous staged replay detector

* test(browser): detect stale native staging images

* test(browser): guard cookie import ordering events

* test(browser): cover staging lock boundary
2026-08-18 15:32:13 -07:00
Brennan Benson 32ee3b0536 reland(browser): route every cookie-import write through CDP identities, and never clear what it will not write back (#15030)
* reland(browser): restore CDP-identity cookie-import writes (#14729)

Reverts the revert 3c8410d927 (#14942) to restore the reviewed bf6dc6fcba
tree. The defect that caused that revert is NOT fixed by this commit — it is
fixed in the commits that follow, so the delta a reviewer must scrutinise
stays small instead of hiding inside a 2000-line re-add.

Conflict resolutions, all keep-both:
- browser-cookie-import.ts: two hunks around the zero-import early return.
  #14683's undecryptable warning and decryptedCookies.length === 0 condition
  are kept alongside the reland's old-client gate and partitionSkippedCookies.
  The old-client gate stays above the early return, and so above any mutation.
- en.json: both string sets (#14683's undecryptable copy and partitionSkipped).
- crashpad-capture.test.ts: took HEAD wholesale; unrelated to this ticket.

getStoragePath, and its positive-write assertion moves from cookies.set to the
CDP identity store — adapted, not weakened, and now strictly stronger because
it also asserts cookies.set carries no imported user data. Every decryption
assertion is untouched.

* fix(browser): add registrableFamily, one definition of a cookie family

STA-4300, change 1 of the reland fixes. No behaviour change yet — this only
adds the helper the later commits derive every skip scope from.

Deriving "family" inline in several places is what let the removal scope and
the write set disagree in STA-4090 and STA-4170, so there is exactly one.

The IP check runs on normalizeCookieDomain's OUTPUT, never the raw string.
psl treats an IPv4 literal as a dotted DNS name — psl.parse('127.0.0.1').domain
is '0.1' — and Chromium accepts many spellings of one address. new URL() inside
normalizeCookieDomain canonicalises 127.1 / 2130706433 / 0x7f.1 / 010.0.0.1 and
a trailing dot to a dotted quad first, so isIP() then catches all of them.
Mutation-proved: moving the check ahead of normalisation reddens the five
alternate-spelling cases and nothing else.

Bracketed IPv6 needs its own branch because isIP('[::1]') is 0; without it the
value falls through to psl, which throws, which returns the host — the right
answer for the wrong reason.

Returns null for a bare public suffix: naming `com` as a family would preserve
an entire TLD from removal and silently turn an import into a no-op.

* fix(browser): plan every cookie's fate before any jar mutation

STA-4300, change 2. Adds planImportWrites — pure, no I/O — so the write set and
every removal scope can derive from one value instead of being computed twice.
Not yet wired into the import paths; that is the next commits.

TWO passes, and the second is not optional. Family-atomic skip is a property of
the whole input: with a readable mixed.example row BEFORE an unreadable
sub.mixed.example row, a per-row guard emits the readable one before anything
knows the family will be skipped. Pass 1 classifies and collects the skipped
families; pass 2 re-filters the provisional writes.

Mutation-proved with a faithful one-pass guard (consult skippedFamilies as you
go): the readable-before-unreadable case goes red and the unreadable-before-
readable case still passes. That asymmetry is why both orders are tested and
why only the first is the named detector.

Family closure is at the registrable boundary because that is the boundary the
removal scope actually expands to: importedDomainScopes turns an imported
mixed.example into descendant roots, so a readable apex cookie drags a skipped
subdomain's live session into the removal scope (STA-4300 §2b).

hasUnrepresentableSkip surfaces a skip whose family cannot be named. A family we
cannot name is one we cannot exclude from the removal plan, and clearing a
family we cannot protect is the P0 this ticket exists to stop — so the caller
refuses before mutating rather than proceeding and hoping.

* fix(browser): derive path A's removal scope from the write plan (STA-4300 §2b)

Change 3. importValidatedCookies now plans before it opens the jar, and the
replacement scope and the write set are the SAME array.

bf6dc6fcba filtered replacementDomains per exact cookie
(partition.status !== 'unreadable'). That is not sufficient:
replaceCookiesForImportedDomains expands each imported domain into its
descendant roots, so a readable cookie on mixed.example pulls sub.mixed.example
into the removal scope — and a skipped cookie living there has its session
removed with nothing written back. Same erasure as the P0, different path.

plan.writes is family-closed, and because path A passes the very same array to
replaceCookiesForImportedDomains and to writeImportedCookies, the two sets
cannot drift apart. That is stronger than keeping them in sync: there is only
one set.

Also here, both before any mutation:
- an unrepresentable skip (registrableFamily → null) refuses the import, because
  a family we cannot name is one we cannot exclude from the removal scope;
- the old-client gate now keys off plan.skips rather than re-deriving the
  condition.

Counters: partitionSkippedCookies is a BREAKDOWN of skippedCookies — the
unreadable rows plus their family-suppressed siblings — added in exactly once,
so totalCookies === importedCookies + skippedCookies keeps holding. Counting it
separately is how a summary silently stops adding up.

Full src/main/browser suite: 69 files, 729 tests, green.

* fix(browser): split path B into scan and emit, and never stage a skip (STA-4300)

Change 4 — this is the commit that fixes the shipped P0.

bf6dc6fcba did:
    decryptedCookies.push({ ..., partition })   // write set built HERE
    if (partition.status === 'unreadable') { ... continue }   // guard AFTER

so an unreadable row discovered late could not retract a sibling already
emitted, while removeTransplantableCookies cleared the ENTIRE jar. The mutation
set was the whole jar and the write set a strict subset of it, which is how a
mixed readable/unreadable source emptied a populated jar and repopulated only
part of it.

Now the row loop SCANS only. Nothing is emitted inside it — not decryptedCookies,
not domainSet, not a staging row, not the imported count. Between scan and emit,
planImportWrites closes the skip over whole registrable families, and emit walks
the plan. Each scanned candidate carries its raw source row because
buildChromiumCookieInsertParams needs it; a record holding only the derived
fields compiles and then silently cannot stage.

Also between scan and emit, both before any mutation:
- an unrepresentable skip refuses the import;
- any skipped family calls disableStaging. A staged image is a whole-database
  replacement on the next start, so it cannot express "preserve this family";
  main's existing memoryFailed arm then reports restart-fallback-unavailable.

Test contract change, deliberately stronger: "never stages a partitioned row
whose ancestor bit is unreadable" asserted that an image WAS registered and
merely omitted the row. That image would still have erased the preserved family
on replay. It now asserts no image is registered at all — plus a new paired case
proving a no-skip import still stages, so "disable on skip" cannot be
implemented as "disable always" unnoticed.

Honest note on detection: reverting the emit filter currently reddens only the
staging assertion, because every end-to-end fixture in this module still starts
with an EMPTY jar — where clear-then-write-all and clear-then-write-some are
indistinguishable. That is the blind spot that let this ship. The populated-jar
suite in the next commit is what actually detects the erasure.

Full src/main/browser: 69 files, 730 tests, green.

* fix(browser): never remove a family the import declined to write (STA-4300 I2)

Change 5, and the one that makes preservation observable.

removeTransplantableCookies takes preserveFamilies. removableCookieEntries
filters those families out, which keeps them out of the removal plan AND — since
the CDP snapshot is taken from that same list — out of the restore set, so a
preserved coordinate is never submitted to any mutation at all.

When anything is preserved, the bulk clearData path is not used. clearData
removes everything outside excludeOrigins, and its own comment concedes a
rejection may already have emptied part of the jar; a preserved family is
deliberately absent from the snapshot, so a partial delete followed by a
rejection would destroy it with no identity able to restore it. Handing a
dynamically derived preserve list to that primitive cannot be made safe, so a
skip-bearing clear runs the frozen per-coordinate plan — which already excludes
those families — as its primary path instead. Nothing is preserved on an
ordinary import, so that path keeps today's single clearData call unchanged.

New populated-jar suite. Every end-to-end fixture in this module starts with
cookies.get returning [], and against an empty jar "clear then write all" and
"clear then write some" are indistinguishable — which is why four independent
gates missed the P0. These fixtures start populated.

Mutation-proved, and this is the first detector in the change set that catches
the actual erasure rather than a proxy:
- drop the preserved-family filter          → 6 of 8 red
- use bulk clearData while preserving       → 6 of 8 red, including
  "leaves a preserved family untouched", which is the erasure itself

IPv4-literal and single-label host families are covered explicitly: psl reads
127.0.0.1 as the dotted DNS name '0.1', so a wrong family here would fail to
match the preserve set and erase the live loopback session.

Full src/main/browser: 70 files, 738 tests, green.

* test(browser): pin STA-4300 end to end in real Electron, both write paths

Ports the pre-implementation reproduction into the PR. Verified RED against
main's product code and GREEN with the fix, on both paths — I re-ran it both
ways rather than assuming.

Two assertions are INVERTED rather than deleted, and both are now stronger. The
repro proved the defect by showing cookies.set was called WITHOUT a partitionKey;
after the reland the import write path does not touch cookies.set for user data
at all, so seeing the imported cookie there is itself the regression. The
fixture's internal guard is inverted the same way.

Non-vacuity, asserted in-run rather than in a report: ok:true with a nonzero
importedCookies, the importer reaching its terminal step, the source really
carrying its partition fields, and a CONTROL cookie written via CDP *with* a
partitionKey and read back with it — so "partitionKey absent" cannot be confused
with "the oracle cannot see partitions". electronVersion proves Electron ran.

Fixture correction worth recording, because the original reproduction was
invalid on this path and I did not catch it on first read: readJsonCookiePartition
reads a NESTED partitionKey object, but the fixture wrote topLevelSite and
hasCrossSiteAncestor at the entry's top level, where the importer never looks.
The file/paste case therefore failed on main for a fixture-shape reason rather
than for the defect — and its own non-vacuity check validated what the fixture
wrote instead of what the parser reads, which is exactly how a check that looks
rigorous proves nothing. Only the native half was ever a real reproduction.

Partition keys use a schemeful SITE, not an origin: Chromium canonicalises
https://app.example.com to https://example.com, measured via CDP.

* fix(browser): fail closed on lossy cookie identity recovery

* fix(browser): preserve unreadable families before decrypt

* fix(browser): reject malformed cookie partition sites

* fix(browser): validate recovered cookie partition sites

* fix(browser): reject empty JSON partition identities

* fix(browser): fail closed and serialize cookie imports

* fix(browser): reject malformed CDP opaque flags

* fix(browser): roll back outcome-unknown cookie writes

* revert(browser): move import serialization out to STA-4601

Reverts only the concurrency-serialization half of f7c27b71ab so #15030 stays
scoped to the STA-4300 reland. That commit mixed two concerns; this removes one
of them and keeps the other.

REMOVED (moves to STA-4601, a separate pre-existing P1):
- acquireCookieMutationLock / withCookieMutationLock and the mutationLocks map;
  clear.ts goes back to withCookieClearLock
- the import-wide lock in path A (mutationOwner on the target, acquire/release
  around replace + writes + rollback)
- the widened lock around path B's clear + memory writes
- browser-cookie-import-concurrency.test.ts

KEPT (genuinely STA-4300 scope, not concurrency):
- partitionKeyOpaque handling in readJsonCookiePartition and the CDP snapshot.
  An opaque partition key cannot be represented faithfully, so it reads as
  unreadable and its family is preserved — exactly the rule this ticket adds.
  194e57a628's refinement of that shape stays too.

The concurrent-import interleaving is real and reachable (nothing serialises
imports per partition, and the clear lock is released before the memory writes),
but it predates this change and is not caused or worsened by it. It gets its own
PR and its own review rather than riding a P0 reland whose history is that every
additional fix introduced a new defect.

src/main/browser: 71 files, 772 tests, green. Electron oracle still passes on
both write paths and still goes red against main's product code.
2026-08-17 12:44:39 -07:00
Brennan Benson e16a22ef58 fix(browser): return a real PermissionStatus from the query override (#14684)
* fix(browser): shadow PermissionStatus state instead of proxying it

Rebased onto main after #14685 landed in the same file. Both changes coexist:
#14685's Firefox gating and narrowed promptPerms are preserved untouched, and
this change replaces only the query-override implementation.

The previous Proxy bound every callable property, which broke three observable
things:
- onchange assignment threw "Illegal invocation" — the native setter received
  the proxy rather than the branded target.
- Listeners were delivered with the real target and the NATIVE state, so on a
  real permission change event.target.state read 'granted' while the returned
  status still read 'prompt'.
- Method reads returned a new bound function each time, so name became
  'bound addEventListener', toString lost its name, and identity was unstable.

Separately, the notifications state was captured once as a string, so an
already-returned status went stale after requestPermission updated it.

Shadow only 'state' on the genuine PermissionStatus with a lazy provider. The
object the site holds IS the real one, so identity, brand checks, method
fingerprints and native event delivery survive with nothing to keep in sync.

Verified against real Chromium (Chrome 151) rather than only the vm stand-in,
because the stand-in cannot show whether defineProperty succeeds on a branded
instance: the instance is extensible, the prototype 'state' accessor is
configurable, defineProperty succeeds, instanceof survives, addEventListener
keeps its native name and referential stability, and onchange assignment works.

New tests live in their own file rather than merged into anti-detection.test.ts,
whose harness diverged on main. The fallback test targets 'camera' because
#14685 narrowed the intercepted set, so a name outside it never reaches the
fallback.

* test(browser): cover intercepted PermissionStatus identity
2026-08-16 20:35:34 -07:00
Brennan Bensonandmanuaudio 886dec1d2a fix(browser): report cookies an import could not decrypt (#14683)
* fix(browser): report cookies an import could not decrypt

Supersedes #13193, which reported only the Windows v20 case.

Nothing distinguished "decryption failed" from "no cookies present". A row that
would not decrypt was folded into the generic `skipped` counter, and a profile
whose rows all failed returned ok:true with importedCookies:0 and no warning —
a green "Imported 0 cookies from Google Chrome." The two situations produce
opposite result shapes and the worse one reported success.

Attribute the cause at the point of failure, while the version prefix is still
in hand, and surface it as one `cookies-undecryptable` warning carrying the
reason. Covers all three known causes rather than one prefix:

- app-bound-encryption: Chrome/Edge 140+ on Windows write `v20`, which only the
  writing browser can unwrap. The version gate is a FORMAT check (`/^v\d\d$/`),
  so v20 passed it and failed inside AES like corruption.
- linux-keyring-unavailable: getLinuxEncryptionKey derived the v11 key from an
  empty password when both secret-tool lookups failed, so it never returned null
  and the "Could not access encryption key" guard was unreachable on Linux.
- unknown: any other cause still warns instead of reporting success.

Deliberately not a hard failure on Linux: Chrome falls back to the "peanuts" v10
key precisely when no keyring exists, so those profiles still import. Pinned by
a regression test.

Refs #13192, #14181

* fix(browser): attribute decrypt failures exactly and gate CBC by version

Review-loop findings on the initial commit, all fixed here.

- CORRECTNESS: v11 rows were attempted with the v10 key when the keyring was
  unavailable. AES-128-CBC is unauthenticated, so a wrong key that yields valid
  PKCS#7 padding was accepted — roughly 1 in 256 per row. Garbage values were
  written into the jar as real cookies, and because those rows counted as
  successes the warning this PR adds could never fire. Key eligibility is now
  explicit per version rather than implicit in key ordering.

- CORRECTNESS: the CBC path returned an empty Buffer for a prefix-only value
  BEFORE checking eligibility. An empty Buffer is truthy, so an ineligible row
  counted as imported and reached the live-jar clear. Eligibility now precedes
  that branch and empty CBC ciphertext is rejected as malformed.

- ACCURACY: a named cause reported the TOTAL failure count, so one v20 row plus
  one corrupt row claimed both failed to app-bound encryption. Counts are now
  exact per cause, with the remainder reported separately and a tie falling back
  to 'unknown'. Exact-count approach carried over from #13193.

- The app-bound copy no longer dead-ends. It names the existing in-app file
  import without describing how to produce the file — Chrome has no native
  decrypted-cookie export, so concrete guidance would send users to an
  extension that can read their whole session jar.

- Direct prefix edge tests carried forward from #13193.

Repo-wide search found no second multi-key unauthenticated-CBC first-success
site, so this pattern was one occurrence rather than a class.

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

* fix(pr): preserve split worktree slice

Remove the unrelated rollback of the worktree-slice split and its forbidden max-lines baseline addition from this cookie-import PR.

* fix(browser): match the unknown decrypt reason explicitly

CI's type-aware code-quality gate flagged the reason switch as non-exhaustive:
the 'unknown' member was handled by `default:` rather than matched.

Matching it explicitly keeps the behaviour identical today and makes the gate
enforce the thing that matters — adding a new reason to the union now fails the
switch instead of falling silently into a generic message that would not
describe it.

This gate is separate from `oxlint` and is not covered by running oxlint on the
changed files, which is why it only surfaced in CI.

---------

Co-authored-by: manuaudio <manuaudio@users.noreply.github.com>
2026-08-16 18:43:11 -07:00
Brennan Benson 5e189d6081 feat(browser): add a WebAuthn account picker (#14687)
* fix(browser): prompt for WebAuthn account selection

* fix(browser): scope WebAuthn cancellation to session

* fix(renderer): keep WebAuthn render phase pure
2026-08-16 18:32:20 -07:00
Jinjing 3c8410d927 Revert "fix(browser): route every cookie-import write through CDP identities …" (#14942)
This reverts commit bf6dc6fcba.
2026-08-16 12:33:45 -07:00
Brennan Benson bf6dc6fcba fix(browser): route every cookie-import write through CDP identities (STA-4300) (#14729)
* test(browser): repro STA-4300 CHIPS partition downgrade on native cookie import success path

* fix(browser): route every cookie-import write through CDP identities (STA-4300)

* test(browser): cover partition fidelity on both import write paths (STA-4300)

* fix(browser): never stage unreadable cookie partitions (STA-4300)

* fix(browser): gate partition skips on client support (STA-4300)

* test(browser): anchor the client partition-skip capability assertion (STA-4300)

* fix(browser): skip Firefox CHIPS cookies whose Chromium identity cannot be rebuilt (STA-4300)

* fix(browser): tolerate a Firefox schema without originAttributes (STA-4300)

* docs(browser): correct a comment that still named the removed cookies.set write

* fix(browser): block lossy Firefox cookie import on old clients

* fix(browser): read Firefox CHIPS from schema flag
2026-08-16 11:46:45 -07:00
Neil c8fe5fc8c1 refactor(browser): split browser and browser-IPC modules under the max-lines budget (#14697)
The five oversized src/main/browser modules and src/main/ipc/browser.ts each
carried a file-level `eslint-disable max-lines` and ran 377-654 counted lines
against a 300-line budget. AGENTS.md calls for splitting rather than
suppressing, and config/max-lines-baseline.txt is a shrink-only ratchet, so
this removes all six suppressions and prunes their entries (341 -> 335).

Pure move, no behavior change. cdp-ws-proxy is decomposed into collaborating
objects rather than free functions because its state is genuinely
per-connection: every collaborator is a private readonly instance field built
in the constructor with live closures over `this`, so per-connection state
stays per-connection. Likewise the screencast pacer's isClosed/isStopping and
snapshot capture's getSeq are live thunks, not values captured at wiring time,
so guards inside already-armed timers still observe a later stop().

browser-guest-ui.ts is renamed to browser-guest-shortcut-forwarding.ts: after
the split it exports exactly one function, setupGuestShortcutForwarding, so the
old name no longer described its contents.

Also restores a single `webContents.debugger` read in the screencast path. The
extraction had left three reads where the original had one; the accessor is
stable today, so this is not a behavior fix but it removes a latent divergence.

Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green
(remaining failures are pre-existing load flakes in untouched files, each green
when re-run serially), no new runtime import cycles, and the IPC channel set
diffed identical before/after with all 23 handlers still trust-gated.
2026-08-15 17:59:29 -07:00
Neil 9367169888 refactor(tests): split every oversized test file off the max-lines suppression list (#14728)
* refactor(tests): split oversized test files off the max-lines suppression list

Every `*.test.ts`/`*.spec.ts` that carried an `eslint/oxlint-disable max-lines`
directive is now split into focused, behavior-scoped suites that fit the 800-line
test budget, with shared setup extracted into co-located `*-test-harness.ts` /
`*-test-fixtures.ts` modules (300-line budget). 83 files became ~930; the largest
output is 797 effective lines. `orca-runtime.test.ts` is intentionally untouched.

Test bodies were moved by scripted line-range slicing rather than retyped, so
assertions are byte-identical. The only permitted body edits were mechanical
rebinding where a shared value moved into a harness (e.g. `tmpHome` ->
`homes.tmpHome`).

Registries that enumerate test files were updated in lockstep:
- config/max-lines-baseline.txt: pruned 341 -> 258 entries (all 83 removed).
- config/reliability-gates.jsonc: 33 gates repointed at the split files, with
  assertionRefs split per file where a gate's coverage now spans several.
- .github/workflows/pr.yml: the real-zsh lane now lists the 4 split files that
  actually exercise zsh, so they keep running in the dedicated shell lane.

Also renamed agent-hooks `server-test-fixtures.ts` to `server.test-fixtures.ts`
so the global-fetch call-site audit keeps skipping it, and added `.js` extensions
to the CLI suites' dynamic harness imports (node16 resolution) to unbreak
`build:cli`.

Verification: full suite 52,449 passing vs 52,448 at baseline with zero
assertions lost; `pnpm lint`, `pnpm typecheck`, and `pnpm build:cli` all exit 0;
the terminal-pane e2e spec runs 31/31 headless.

* refactor(tests): split hook-idle arbitration suite that oxfmt pushed over budget

The pre-commit oxfmt pass reflowed pty-connection-hook-idle-arbitration.test.ts
to 811 effective lines, 11 over the test budget. Split the hook-completion side
effect and replacement-agent veto cases into their own suite; both files now sit
well under the cap and the 15 tests are unchanged.

* test: port upstream test changes into the split files after rebase

Rebasing onto main surfaced 27 tests that main had added to files this branch
deleted, plus edits to tests that had already moved. Taking the deletion side of
those modify/delete conflicts would have dropped that coverage silently, so each
upstream change is ported into the split file that now owns the behavior — for
example main's six orchestration mailbox tests land across orchestration-runs,
-send, and -check.

Also repoints `orchestration.notification-mailbox-consistency`, a gate main added
after this branch's gate remap, at those same three split files, and re-prunes
the max-lines baseline against main's (257 entries).

Verified: all 27 upstream test titles present; full suite 52,761 passing with the
only diff vs baseline being 12 tests main itself removed and 3 that moved from
skipped to passing; lint and typecheck exit 0.

* fix(test): flush pending continuations before tearing down terminal test globals

CI shard 5/16 failed on both Node 24 and 26 with `ReferenceError: window is not
defined` from pty-connection.ts, surfacing through
pty-connection-daemon-snapshot-replay.test.ts.

The reattach/settle chains `await` a real promise and then touch `window.api`.
Under fake timers those continuations cannot run, so they only become schedulable
once restoreTerminalTestGlobals() switches back to real timers — which previously
happened immediately before `delete globalThis.window`, so a late continuation
threw and failed the whole file. Flush async ticks in that window instead.

This is latent in the source rather than new: the pre-split 25k-line file kept
running other tests after these, which gave the chains time to settle before
teardown. Splitting the file moved teardown directly behind them.

* fix(test): keep an inert window after terminal test teardown instead of deleting it

The async-tick flush was not enough: the reattach/settle chain can resolve after
teardown regardless of how long we drain, so CI shard 5/16 still failed with
`ReferenceError: window is not defined` from pty-connection.ts.

A real renderer never loses `window`, so deleting it was the artificial part.
Swap in an inert proxy whose properties resolve to callables and whose calls
resolve to undefined, making a late `window.api.pty.*` call a harmless no-op.
The next test replaces it wholesale via installTerminalTestGlobals(), and no test
asserts that `window` is absent.
2026-08-15 00:54:20 -07:00
Brennan Benson 0f7707b146 fix(browser): align anti-detection identity signals (#14685)
* fix(browser): align anti-detection identity signals

* fix(browser): hide native Chrome global for Firefox
2026-08-14 22:58:48 -07:00
Brennan Benson 3035aa9211 fix(browser): restore replaced cookies through CDP identities (#14383)
* fix(browser): restore replaced cookies through CDP identities

Both remaining callers of the imported-domain replacement rolled back by
rebuilding cookies with cookies.set, which silently drops partitionKey.
The rollback in importValidatedCookies puts back the user's ORIGINAL
cookies that the import already deleted, so a CHIPS cookie came back as
an ordinary one and no restart recovered it.

Snapshot CDP identities before the first removal and undo through them,
the same machinery removeTransplantableCookies already uses. The store
type omits 'set' so the lossy reconstruction cannot be reintroduced, and
restoreImportedDomainCookies is deleted now that both callers are gone.

* fix(browser): skip the CDP rollback when nothing was replaced

restoreClearIdentities attaches the debugger before it iterates, so an
empty restore set would spin up a hidden BrowserWindow to put nothing
back. The old cookies.set restore was a no-op loop in that case.
2026-08-13 23:15:21 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
Jinwoo Hong cd6114ab7e fix(browser): acknowledge paired tab before navigation (#14402) 2026-08-13 22:40:58 -07:00
Brennan Benson e799259d3b fix(browser): fence the cookie-clear fallback to the pre-clear snapshot (STA-4170) (#14343)
* fix(browser): fence the cookie-clear fallback to the pre-clear snapshot (STA-4170)

The post-rejection fallback re-read the live cookie jar and removed everything
removable at that moment, while restore only ever covered the pre-clear
identity snapshot. A cookie that arrived mid-clear -- a login the user had just
completed -- was therefore deleted with no identity able to put it back, and a
later removal failure still reported "existing cookies were restored".

Fix the removal plan at the same point as the identity snapshot so the mutated
set can never exceed the restore set. Arrivals are not touched at all, which
matches what the successful bulk-clear path already does.

* test(browser): lock same-coordinate arrival rollback to the pre-clear value (STA-4170)
2026-08-13 14:51:27 -07:00
Brennan Benson 93334dc53f fix(browser): fail-closed cookie clear after a partial import wipe (STA-4090) (#14191)
* fix(browser): fail-closed cookie clear after a partial import wipe (STA-4090)

A rejected bulk clear plus a later per-cookie removal could permanently
delete cookies that were already gone. Snapshot partition-complete
identities before mutating, keep the bulk clear, and restore through CDP
so a failed import never silently drops existing cookies.

* fix(browser): preserve exact cookie clear identities

* fix(browser): abort unrepresentable cookie clears

* fix(browser): skip excluded-only cookie clears
2026-08-13 07:38:53 -07:00
Neil e97faa1650 fix(browser): sanitize reserved Windows download names (#14145) 2026-08-12 21:33:18 -07:00
Brennan Benson e5906837e4 perf(browser): clear cookies in one bulk call that excludes the Google family (STA-4065) (#14131)
* perf(browser): bulk clear cookies when an import excludes nothing (STA-4065)

PR #14086 replaced the bulk clear with one Electron cookies.remove() per
removable cookie so partitioned excluded cookies survive an import. That is
required whenever something must be preserved, but it also runs when nothing
is excluded, where a single clearStorageData is equivalent and far cheaper:
421ms -> 7.3ms at 1k cookies and 1764ms -> 20.1ms at 3k on Electron 43.

The fast path is gated on the same unfiltered cookies.get() the exclusion
predicate already reads, which an Electron fixture now proves returns
partitioned cookies, so "no exclusions in the snapshot" means "nothing in the
jar to preserve".

* fix(browser): protect cookie clear fast-path race (STA-4065)

* test(browser): keep the no-bulk-clear assertion honest after the clearData switch

The mocked session still exposed clearStorageData, so asserting it was never
called could no longer fail once the fast path moved to clearData.

* docs(browser): stop the fallback comment from asserting a rollback

The per-cookie path's rollback is being removed by STA-4061; the comment only
needs to say the fallback re-removes what survived.

* test(browser): keep the STA-4061 rollback fixture on the per-cookie path

The bulk fast path clears a jar with no excluded cookie in one call, which
would have made every assertion in the partition-rollback fixture vacuous.
Give it a live Google cookie so the partial-failure clear still runs, and
assert the bulk call count is zero so a future widening fails loudly.

* perf(browser): always bulk clear cookies, not only when nothing is excluded

The fast path only fired when the jar held no Google cookie, but this import
exists for Google, so a Google cookie is normally present and the common case
still paid one native remove per cookie. Measured on Electron 43, clearData
excludeOrigins preserves the whole google.com registrable family -- host,
leading-dot, subdomain and partitioned cookies -- so the bulk call is safe with
cookies to keep, and the gate was never load-bearing for preservation.

Drop the caller-supplied exclusion predicate so the preserved set has exactly
one source of truth: a predicate that disagreed with NON_TRANSPLANTABLE_DOMAINS
would have deleted cookies the bulk call is meant to keep. The per-cookie loop
stays as the fallback for a rejected bulk clear, carrying the same exclusion.

* fix(browser): re-read the cookie jar after a rejected bulk clear

clearData can empty part of the jar before rejecting, so the per-cookie
fallback was working from a snapshot taken before the attempt: it retried
removals for cookies that were already gone and could not see cookies that
arrived during the clear. Re-read on rejection only.

Also repairs three fixtures that mocked the session with clearStorageData
after the call moved to clearData. The missing method threw, the catch
swallowed it, and every one of those tests silently asserted per-cookie
behaviour while believing it covered exclusion. Records the registrable-domain
invariant at NON_TRANSPLANTABLE_DOMAINS, since excludeOrigins derives one
origin per entry, and extends the real-Electron fixture to prove http-only and
domain-scoped Google cookies survive alongside the partitioned one.
2026-08-12 20:40:47 -07:00
Brennan Benson bc2e30000b fix(browser): never resurrect cookies after a failed import clear (STA-4061) (#14132)
* fix(browser): never resurrect cookies after a failed import clear (STA-4061)

removeAllCookiesExcept rolled a partial clear back by rebuilding each removed
cookie with cookies.set. Electron's cookies.get() omits partitionKey and
cookies.set() drops it, so a non-Google CHIPS cookie that had been removed came
back as an ordinary unpartitioned cookie — silent, restart-proof auth-state
corruption on a code path that reports failure.

The exclusion comment already stated the invariant ("Electron cannot round-trip
partition identity"); it was only applied to the Google family. Extend it to the
rollback: a failed clear now stays failed, which is retryable, instead of
reconstructing cookies whose identity cannot be reproduced.

* test(browser): require partitioned cookie removal (STA-4061)
2026-08-12 18:53:42 -07:00
Brennan Benson fc7c2a1cd5 fix(browser): preserve partitioned Google cookies on import (#14086) 2026-08-12 13:18:10 -07:00
Brennan Benson 2249330acf fix(browser): bulk clear cookies during native import (#13966)
* fix(browser): bulk clear cookies during native import

* fix(browser): separate Google cookie import warning

* fix(browser): report encrypted Google cookie exclusions

* fix(browser): skip key lookup for excluded cookies

* test(browser): cover excluded-cookie key bypass

* perf(browser): keep plaintext key scan cheap
2026-08-12 00:33:50 -07:00
Neil 991a3fe963 chore(lint): update oxlint to 1.77 and enable no-op cleanup rules (#13901)
Enable eleven oxlint rules that simplify code without changing behavior, and fix
every existing violation. Each candidate was gated on measured cost rather than
assumption, so rules that regressed runtime performance or type checking were
dropped instead of suppressed.

typescript/no-redundant-type-constituents is the largest addition: 113 sites, no
autofix. Dead constituents are deleted. Where the redundant literal existed to
document intent (`string | 'all'`), it is preserved as `(string & {})`, which
keeps the autocomplete hint the original code was reaching for instead of
flattening it away. The rule also caught a broken import —
remote-shared-control-retirement-probe.ts pulled RuntimeStatus from
src/shared/types, which does not export it, so the type silently degraded to
`any`; no tsconfig covers that file, so tsc never saw it.

oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets
minimum-release-age=4320 and 1.78.0 is younger than that window.

Rules evaluated and rejected, with what disqualified each:
- prefer-string-raw: String.raw is a runtime call, not a literal (184x slower)
- prefer-string-replace-all: 26% slower
- text-encoding-identifier-case: ~5% slower, reproducible
- prefer-spread: [...str] is 110% slower than split('') and differs on surrogates
- no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors)
- prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks
- object-shorthand: rewrites source text asserted by a tracked reliability gate
- switch-case-braces: pushes ten files past max-lines, which cannot be suppressed
- no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs
- arrow-body-style: 115 violations have no fix, and it breaks max-lines
- newline-after-import: false-positives on the leading-semicolon ASI idiom

electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
2026-08-11 18:19:43 -07:00
Brennan Benson 686f5dca1a fix(browser): exclude Google cookies from imports - direct sign-in is the only Google path (STA-3811) (#13670)
* fix(browser): exclude Google cookies from imports (STA-3811)

Imports never write and never remove a google.com-family cookie, on any
path. Signing in directly inside Orca is the only Google session that
survives, so the live jar always beats anything an import could plant.

* fix(browser): roll back selective cookie clears
2026-08-11 16:39:17 -07:00
Brennan Benson f19ff5be68 fix(browser): keep one identity across hosts during Google sign-in (STA-3811) (#13667)
While the auth document is on screen the WebContents UA is Firefox, so its
cross-host subresource/XHR requests (gstatic, play.google.com, the sign-in
challenge endpoints) reached the header layer carrying the Firefox UA yet still
bearing Chromium client hints, which the else-branch rewrote to Chrome. That
paired a Firefox UA with Chrome client hints on every non-auth Google host — a
sharper cross-host identity tell than either signal alone, and a plausible
cause of the password-submit challenge greying out and stalling.

Strip client hints on any request already carrying the Firefox auth UA so the
UA and hint surfaces tell one Firefox story for the whole flow. Gated on the
same googleAuthOverride flag as the auth-host switch, so imported-native
profiles are unaffected and the clean-Chrome default for non-Google sites
(Cloudflare) is untouched.

Extends tests/tools/google-signin-ua-probe.cjs with app-current/app-fixed
modes that mirror the shipped code and log per-request identity; on the real
accounts.google.com load they show 18 firefox-ua-with-chrome-hints cross-host
mismatches before and 0 after.
2026-08-11 16:37:53 -07:00
Neil e671c64dea refactor(utf8): centralize byte and chunk boundaries (#13445) 2026-08-10 20:41:01 -07:00
Brennan Benson c03c01ad15 fix(browser): stop impersonating the source browser's UA on cookie import (STA-3514) (#12849)
* fix(browser): stop impersonating the source browser's UA on cookie import (STA-3514)

Delete the import-time UA synthesis entirely instead of patching its version
detection. The session now always keeps the engine-derived UA the registry
sets at startup (clean or native), for imported and non-imported profiles
alike.

Why deletion is the fix:
- The synthesis read CFBundleShortVersionString, which on forks is a
  marketing version — Arc imports presented Chrome/1.x and sites rejected
  the browser as ancient (STA-3514).
- Its stated purpose (keep Google-bound cookies valid) is handled by
  isGoogleSourceBoundCookie excluding those cookies since #736, and #12884
  measured that Google rejects re-transplants regardless of UA identity.
- #12608 measured the synthesized Chrome-shaped UA reaching
  /v3/signin/rejected while the engine UA reached account lookup.

Persisted userAgent/userAgentByPartition meta is no longer read; legacy keys
drop off on the next meta write. The #12811 gate existed only to catch bad
synthesized values, so it leaves with the synthesis.

* test(browser): catch source UA impersonation regression
2026-08-09 17:05:00 -07:00
Wooseong KimandJinwoo-H 84e7ca5212 fix(browser): reject Chromium product versions in cookie-import UA (#12811)
* fix(browser): reject Chromium product versions in cookie-import UA

Do not persist Chrome/1.x User-Agents when a fork (e.g. Arc) reports its
product CFBundleShortVersionString. Only engine-scale majors (>=70) are
advertised; otherwise fall back to Electron's default UA.

Preserves Chrome-shaped UAs for real Chromium engine versions.

Fixes #12726

* fix(browser): reject malformed Chromium version tokens in UA

Validate every numeric component before advertising Chrome/… so values
like 70.not-a-version fall back instead of polluting the UA. Also build
macOS app paths with path.join per coding guidelines.

* fix(browser): drop already-persisted Chrome/1.x UAs on session restore

The version gate stops new fork imports from writing Chrome/1.x, but profiles imported before it keep the broken UA in browser-session-meta.json and replay it on every launch, so affected users stay blocked with no in-app recovery.

Move the gate into browser-session-ua (the module that owns UA shape, and the one both callers can import without a cycle) and drop an unadvertisable persisted UA during restore so the profile falls back to Orca's own engine UA.

---------

Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
2026-08-08 02:22:25 -07:00
Brennan Benson 419151dddd fix(browser): present Firefox UA on Google auth hosts to end ~1h sign-in staleness (#12884)
Google binds a signed-in session to the browser identity that created it, and
rejected Orca's Electron/Chrome-shaped UA on accounts.google.com — so sessions
died at ~1h and re-copying cookies only reset the same losing clock.

Present a Firefox identity scoped to the Google/YouTube sign-in hosts at every
layer Google probes: the request headers (with sec-ch-ua* stripped, since real
Firefox sends none), the per-WebContents UA that auth JS reads, and the CDP
emulation override a viewport preset installs. All three must agree — a
disagreement between them is a sharper bot signal than any single wrong UA.

Native-UA profiles opt out at every layer, including popup child windows and
after a browser-native cookie import.

Validated live against a real account: freshness cookies issued and rotating on
a ~10-min cadence, session still signed in and self-renewing past 10 hours,
against a pre-fix baseline of death at ~1h.

Closes #11518.
2026-08-07 01:29:37 -07:00
Brennan Benson 38ba22ecd1 fix(browser): align cookie import safeguards (#12607)
* fix(browser): align cookie import safeguards

* fix(browser): preserve sessions on failed cookie imports

* fix(browser): bound single-label cookie replacement

* fix(browser): preserve host-only parent cookies

* fix(build): bundle cookie scope parser
2026-08-05 12:51:05 -07:00
Jinwoo Hong 9accd97bd9 fix(browser): stop an over-limit screencast frame from killing the paired runtime socket (#12680)
Opening any webpage in the remote browser dropped the paired runtime connection, and the client then retried forever without recovering.

Causal chain: the screencast travels host->client, a direction that admits up to 8 MiB. The host's encrypted channel rejects anything larger with close code 1013 "Outbound reply buffer overflow" — killing every subscription on that connection. The producer treats a false return as backpressure and retries the identical frame, which for an over-limit frame can never succeed. A permanent condition was being treated as transient.

Two changes:

1. A paired-runtime admission wrapper: an over-limit frame is dropped rather than handed to the transport, and reported as handled so the producer advances instead of retrying something doomed. The generic Chromium producer is untouched, so local browser behavior is unchanged.

2. The actual source of over-limit frames. Live frames are hard-bounded by maxWidth/maxHeight, but the navigation snapshot path ignored those bounds entirely, feeding capturePage device pixels straight into the encoder — capturePage's rect is CSS pixels while the bitmap is device pixels, so at deviceScaleFactor 2 a snapshot could be 4x the pixel area the live path is allowed to send. That path fires on page load, which is literally the reported trigger. Applying the caller's own clamp there makes the drop a backstop rather than the mitigation.

Dropping a frame is safe here because frames are complete standalone images, not deltas — each replaces the client image wholesale, so the next frame fully repaints. Disclosed in the PR: mobile web-view mode sends no viewport and takes the unclipped screenshot branch, where the drop guard remains the only protection; still strictly better than a 1013 that kills every subscription.

Verified by reverting in place: neutralizing the admission guard fails 3 oracles, with the integration test emitting the real [1013, "Outbound reply buffer overflow"] from an actual E2EEChannel — the production symptom, not a mock. Neutralizing the snapshot clamp fails its own oracle, re-proven after the test was relocated.

The second half of the report — never recovering without an app restart — is only partly addressed here and is now tracked as STA-3483: the browser stream restart arms a single 500ms retry and never reschedules, so any connection loss can strand the pane. Fixes STA-2970.
2026-08-05 01:26:38 -07:00
Brennan Benson 0ce108d935 fix(browser): add native-UA session profiles (#12608)
* fix(browser): add native-UA session profiles

* test(browser): add Google sign-in UA probe

* fix(browser): preserve native profile UA identity
2026-08-04 19:07:23 -07:00
Brennan Benson a20165a43d fix(browser): import __Host- cookies host-only so Chromium keeps them (#12166)
* fix(browser): import __Host- cookies host-only so Chromium keeps them

The cookie file/JSON import path passed a Domain attribute for every
cookie. Chromium rejects any __Host--prefixed cookie that carries a
Domain (the prefix requires host-only, path=/, Secure), so file import
silently dropped session cookies like GitHub's __Host-user_session_same_site
and users stayed logged out after importing.

Mirror the browser-native import path, which already shapes __Host-
cookies host-only: omit domain and force path=/ when the name is
__Host--prefixed. Add a regression test covering both a __Host- cookie
and a normal domain cookie in one import.

* test(browser): cover __Host- cookie payload constraints

* test(browser): preserve ordinary cookie paths
2026-08-02 18:53:22 -07:00
Neil c79b859758 fix(browser): prevent window.close guest crashes (#11910)
* fix(browser): prevent window.close guest crashes

* fix(browser): guard close before inline scripts

* fix(browser): preserve explicit window close policy
2026-08-01 03:08:11 -07:00
Jinjing aeda3c6e32 fix(browser): resolve grab mode race conditions and teardown (#11661)
- Serialize grab mode operations to prevent concurrent state changes
- Wait for guest registration with timeout when page hasn't registered yet
- Tear down armed overlay when disabling before selection starts
- Distinguish injection-failed from not-ready errors in UI
- Track arm generations to cancel stale operations after page switches
- Add localized error messages for each failure reason
2026-07-30 21:00:36 -07:00
5165cd1e19 fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348) (#11351)
* fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348)

The browser pane's renderer-path Find handler is a window-global
capture-phase keydown listener, but it armed on `isActive` (the active
tab within its own group) rather than on whether its split holds focus.
In a terminal+browser split, the browser was therefore `isActive` even
while the terminal held keyboard focus, so it swallowed Cmd/Ctrl+F and
opened find-in-page in the browser instead of find-in-terminal.

Thread a focused-split signal (`isFocused`) from BrowserPaneOverlayLayer
— derived from `activeGroupIdByWorktree` — down to the Find handler and
gate the listener on it. This mirrors how terminal leaves already gate
global shortcuts via `focusedGroupId` in TabGroupSplitLayout. Floating
browser panels omit the prop and fall back to `isActive`, preserving
their behavior. The IPC path (webview guest focused) is unchanged; it
only fires when the guest genuinely has focus.

Not platform-specific: the chord resolves through `keybindingMatchesAction`
(Mod -> metaKey on macOS, ctrlKey elsewhere), so the same path is fixed on
macOS, Linux, and Windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(browser): preserve Find before split focus settles

* fix(browser): handle stale focused split IDs

* fix(browser): route guest Find to source page

* test(browser): wait for split address bar

* test(browser): focus split before Find routing

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-30 14:17:26 -07:00
2b88931b93 Bug floating workspace shortcuts route to main w (#10433)
* fix(floating-workspace): route panel shortcuts to the floating panel, not the main window

Floating-workspace close/index keyboard shortcuts leaked to the main
window behind the panel. Route them through the floating panel across all
four keydown layers via an atomic focus signal, panel-owned indexed
switching with a tri-state outcome, an event-target-aware close guard, and
a floating-scoped guest IPC bridge.

Changes A-E and findings F2/F3/F4/F6/F7/F8/F9/F11/F-adv/F-dl/F-feas.

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: feelgom <littlestork4@gmail.com>
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>

* fix(review): clear stale floating-panel reclaim intent on panel close

The module-singleton reclaim intent (F3) is armed at an emptying-close but only
consumed by the visibleFloatingItemCount->0 effect. If a concurrent tab-create
keeps the panel from reaching 0, the intent stays armed and could survive to a
later empty-panel mount and steal keyboard focus. The !open release effect now
clears it (defense-in-depth), matching the outside-pointerdown/window-blur paths.

Flagged by 4 review personas (correctness, adversarial, julik-races, maintainability).

Co-authored-by: Orca <help@stably.ai>

* test(floating-workspace): cover L1 index-chord yield and deferred-close reclaim-arm timing

Two additive R2-review tests for the #10288 floating-workspace shortcut
routing change set:

- createMainWindow: assert L1 yields the initial indexed-switch chord
  (tab-index and worktree-index) to the floating panel without
  preventDefault or dispatch, and contains held-key auto-repeats in main
  (preventDefault, no dispatch). Closes the untested Change B (F4) path.

- FloatingTerminalPanel: assert an emptying, panel-owned close whose
  closeTerminalTab defers/cancels (onClosed never fires) leaves the
  reclaim intent unarmed, so no later empty-panel mount can reclaim focus
  for a close that never happened. The prior mock fired onClosed
  unconditionally, so this arm-timing (F3) branch was uncovered.

Co-authored-by: Orca <help@stably.ai>

* fix(review): resolve round-1 findings F-1..F-6

- F-1: re-derive panel emptiness from live store at arm time; clear stale
  reclaim intent on repopulating create so an unrelated later close can't
  consume it and steal keyboard focus from the main workspace.
- F-2/F-5a: single-source the panel's non-creation shortcut claims via
  matchFloatingWorkspacePanelShortcut(); shared isTerminalPaneCloseChord()
  predicate for L2/L3; App.tsx gate + both FloatingTerminalPanel call sites
  now call the SSOT so index/rename/max-min ownership can't drift.
- F-4: L2 keydown gate is event-target-aware (matches L1 yield) so an
  L1-yielded chord is still consumed during a transient panel blur.
- F-5b: export clearReportedFloatingFocusCache() + reset it in test setup.
- F-5c: split floating-workspace-item-actions.ts into focus-reclaim +
  guest-bridge modules (AGENTS.md file-naming).
- F-6: trim verbose design-code comments to single-line WHY.

Co-authored-by: Orca <help@stably.ai>

* fix(floating-workspace): remove finding reference labels

These internal review labels (F1–F7) and change identifiers were used during development and are no longer needed in the code.

* fix(floating-workspace): preserve reclaim for deferred dirty closes

Dirty editor closes defer to the save dialog and complete asynchronously. The
reclaim-arm check must survive the queue and execute when the file leaves—
otherwise the next Cmd/Ctrl+T misses the floating panel entirely. Also resolve
browser guest page ids to their owning workspace for correct routing.

* perf(floating-workspace): single-pass shortcut match and stable listeners

Three hot-path cleanups with no routing behavior change:

- Match each keydown once. App.tsx's yield gate now calls one
  matchFloatingWorkspacePanelChord instead of scanning the creation table
  and the chrome table separately, and the panel splits dispatch into
  resolveFloatingPanelShortcut + applyFloatingPanelShortcut so the surface
  keydown preflight shares its resolution instead of re-matching.
- Pin the window-capture and guest-bridge listeners to [open] by reading
  the live closures (tab order, activate, close helpers, dispatch) through
  a ref, so a tab switch or reorder no longer re-subscribes them.
- Cache the per-tab TerminalPane ref callback so a parent render stops
  detaching and re-attaching every pane handle.

Creation chords stay target-gated and chrome chords stay ungated, matching
the two matchers the combined one composes.

Pre-commit hook bypassed: config/oxlint-react-doctor.json fails to parse
against this worktree's stale node_modules (oxlint 1.71.0 / react-doctor
0.2.10 vs the pinned ^1.75.0 / 0.9.1) for any file. oxlint, oxfmt --check,
tsc, the max-lines ratchet, and the targeted vitest runs were run manually.

* fix(floating-workspace): keep TerminalPane ref callback identity stable

The per-tab ref callback cache deleted its own entry on detach. After a
same-id remount (key is tab.id + generation) React detaches the old element
*after* the new render already read the cache, so the delete dropped the
entry that render had just written — every later render minted a fresh
identity and forced React to detach/re-attach the pane, the churn the cache
existed to prevent.

Move the cache into terminal-pane-handle-registry.ts: detach clears only the
handle, attach re-arms the cache entry, and dead tab ids are pruned from an
effect keyed on the live tab list. Unit-tests cover attach/detach identity
stability — FloatingTerminalPanel.test.tsx's React mock discards effect deps
and ref identity, so component tests can't catch this class of bug. Also
softened the combined-matcher comment: App.tsx's old `||` already
short-circuited, so that call site buys drift-safety, not fewer scans.

Gates: tsc (web), oxlint, oxfmt --check, max-lines ratchet, 332 focused
vitest tests. Pre-commit hook bypassed: config/oxlint-react-doctor.json
fails to parse against this worktree's stale node_modules (oxlint 1.71.0 +
react-doctor 0.2.10 vs the pinned ^1.75.0 / 0.9.1) on untouched files too.

* fix(floating-workspace): pure registry init for react-doctor

Replace null-guarded ref mutation during render with useState lazy init so
CI check:react-doctor:changed stops failing on FloatingTerminalPanel.

* fix(floating-workspace): drop unused registry type import

Satisfies oxlint no-unused-vars after pure useState registry init.
Local pre-commit react-doctor config fails on stale node_modules; CI has current plugins.

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: feelgom <littlestork4@gmail.com>
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
2026-07-28 14:24:15 -07:00
Neil badf91101b fix(quality): enforce performance-safe lint baseline (#11074)
* fix(quality): clear safe existing lint findings

* fix(quality): keep lint cleanup allocation-free

* fix(quality): enforce performance-safe baseline

* test(terminal): drain deferred confirmation cleanup
2026-07-27 20:54:02 -07:00
+21 b31e66ed48 fix(browser): survive a transient Windows lock during cookie import (#10697)
* fix(browser): retry a transient Windows lock on the Chromium cookie snapshot copy

The snapshot attempt loop only reacts to a `false` return, so a throwing
copy escaped it entirely. On Windows, AV/EDR briefly opens a file literally
named "Cookies" with FILE_SHARE_NONE, which turns an otherwise-fine
copyFileSync into `EBUSY errno -4082 syscall=copyfile` and aborts the whole
import (#9355).

Route the main-database copy through the existing
`copyFileWithWindowsRetry`, already used for the same AV window in #1507.
It is a no-op off Windows (maxAttempts=1), so POSIX still fails fast and
ENOENT is never retried on any platform.

* fix(browser): degrade the cookie-import staging DB instead of aborting the import

Staging exists only to back the cold-restart replay for cookies the
in-memory path rejects, but three points in it were fatal to the whole
import (#9355):

- the staging copy from the live partition DB — also a file named
  "Cookies", so the same AV/EDR handle blocks it;
- opening/PRAGMA-ing that staged file;
- both were unguarded, so a throw escaped to the catch-all and returned
  `ok: false` even when every cookie could load in memory.

Each is now non-fatal and diagnosed. Two invariants keep the degraded path
honest: `imported++` moved out of the staging insert so the summary counts
importable cookies rather than staged rows, and `setPendingCookieImport` is
never called when staging is unavailable — registering a path that was never
written would replay a missing or partial DB over the live partition on cold
start.

* fix(browser): stop a degraded cookie import from replaying a stale staged database

Review round 1 found the staging-degradation path could leave an older
pendingCookieImports entry registered while the import rewrote the live
session, so the next cold start replayed the stale DB over fresh cookies.

- add clearPendingCookieImport so a degraded import retires the old entry
- degrade staging on BEGIN/insert/COMMIT failure instead of aborting the import
- discard the staged cookie copy on every non-registering path
- pin the stagingAvailable guard, which previously survived mutation

Co-authored-by: Orca <help@stably.ai>

* fix(browser): report a degraded cookie import honestly instead of as a clean success

Making the staging failure non-fatal introduced a silent-loss path: the import
clears the live jar before loading cookies, so when staging was unavailable AND
Electron rejected cookies, the user lost their old jar, got none of the new
cookies, and still saw "Imported N cookies".

Adds an optional `warning` to the import summary, set only on that degraded
branch, and routes every cookie-import toast through a shared emitter that
raises a warning toast instead of an unqualified success.

Also closes three test holes found in review:
- clearPendingCookieImport had no direct tests; deleting the wrong partition key
  survived all 10 registry persistence tests. Now covered, mutation killed.
- The staging-insert-failure test was vacuous (memoryFailed === 0 suppressed
  registration on its own). It now forces a memory failure.
- No test pinned the success-path clear; removing it survived. Now covered.

* fix(github): resolve owner/repo through SSH Host aliases (#10284) (#10361)

* fix(github): resolve owner/repo through SSH Host aliases (#10284)

Expand OpenSSH Host → HostName via ssh -G before classifying github.com
identity so PR merge works when origin is git@alias:owner/repo.git.
Transport URLs stay unchanged so IdentityFile selection is preserved.
Do not long-negative-cache indeterminate ssh -G failures.

* fix(github): harden SSH alias resolution

* Update README downloads badge

* fix(persistence): fsync state writes so a rename is actually durable (#10631)

* fix(persistence): fsync state writes so a rename is actually durable

`Store` wrote `orca-data.json` to a temp file and renamed it. rename() is
atomic for readers but says nothing about durability: without an fsync the
directory entry can reach disk before the data does. After power loss or a
hard crash the file can come back holding the previous state or, worse,
zero bytes — and `JSON.parse('')` throws, so an empty file takes the
full corrupt-file path rather than degrading.

This is the same empty-file symptom as #1158 from a different cause. That
issue fixed a logic path that persisted empty state and added the .bak ring
as a safety net; the ring also catches this, which is why it went unnoticed.
Recovery costs up to an hour of tabs/layouts/session state (backups are
throttled to >=1h spacing), and a user in their first hour has no backup
slot yet, so they land on defaults indistinguishable from a fresh install.

Both write paths now fsync the temp file *before* the rename, then fsync
the containing directory. Directory fsync is best-effort by design: Windows
cannot open a directory for fsync and some filesystems reject it, so it is
swallowed. The file fsync is the load-bearing part and works everywhere.

Measured cost on a 3 MB payload: ~0.2 ms per write, against a 1s debounce.
The async path does not block the main thread.

The syscall-order test mocks `node:fs` and counts fsync targets at the
module boundary, asserting ['file', 'directory'] — proving the ordering
rather than inferring it from reading the implementation, since a fsync
after the rename would still pass every content assertion.

* test(persistence): make the syscall proof platform-aware and actually prove the order

Two problems, both found from CodeRabbit's Windows observation.

The assertion hardcoded ['file', 'directory']. Directory fsync is
deliberately best-effort — Windows cannot open a directory for fsync and
some filesystems reject it — so on Windows the helper swallows the failure,
only the file fsync is observed, and the test fails. The expectation now
probes the real platform instead of assuming, keeping the guarantee tight
where directory fsync works rather than dropping it everywhere.

Worse, the test did not prove what its name claimed. Moving the fsync to
*after* the rename still passes: the file is fsynced either way, and only
fsyncs were recorded, so the correct and broken orders produced an identical
log. Mutation-testing the "before rename" claim is what surfaced this — the
mutation passed.

The rename is now recorded in the same sequence, since it is the boundary
the ordering is defined against. Re-running the same mutation fails, so the
ordering claim is now backed by the test rather than asserted in a comment.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(window): stop burning macOS GPU on an invisible blur effect (#8482) (#10682)

Co-authored-by: Orca <help@stably.ai>

* feat(sidebar): distinguish and filter CLI-created workspaces (#10712)

* perf(relay): stop snapshotting the whole pending-PTY map every drain tick (#10670)

* perf(sidebar): share one worktree-keyed agent orchestration index (#10678)

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): recover unreliable relay connections (#10709)

* fix(mobile): recover unreliable relay connections

* test(mobile): use valid raster preview fixtures

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* release: v1.4.157-rc.0

* docs(relay): trim the pending-output drain comment (#10714)

Co-authored-by: Orca <help@stably.ai>

* fix(history): quarantine unreadable recovery generations. (#10713)

* fix(history): quarantine unreadable recovery files

* fix(history): preserve mixed recovery generations

* fix(history): reanchor reconciled live sessions

* fix(history): serialize final checkpoint queue

* fix(history): drain sleep shutdowns before disconnect

* fix(history): restore legacy wide sessions

* fix(history): preserve malformed mixed logs

* fix(history): preserve malformed log tails

* refactor tests to reduce file size

* refactor(history-recovery): extract freeze helper and improve test robus

- Extract takeRecoveryFreeze to eliminate duplicated freeze-and-clear pattern across five call sites
- Skip permission-mode tests on root CI containers (chmod 0o500 doesn't block root writes)
- Replace fixed sleep with deterministic wait for queued exclusive checkpoints
- Distinguish ENOENT (missing) from corrupt in history metadata reads
- Add ceiling-dimension restore test and torn-tail exclusion assertion
- Wrap chmod operations in try/finally to prevent leaked permissions from masking test failures
- Add .catch() to checkpoint promise to prevent unhandled rejections from finally re-throws

* test(history-recovery): consolidate checkpoint assertions

Wait for both the checkpoint call and set clear atomically to avoid
a timing race where the spy fires before the set is cleared.

* fix(persistence): unbreak main by expecting the new 'cli' card property in fresh defaults (#10722)

Co-authored-by: Orca <help@stably.ai>

* fix(sidebar): stop worktree drag from spazzing when cards resize mid-drag (#10725)

* fix(sidebar): make Cmd/Ctrl+1-9 match the rendered card order when the sidebar is closed (#10693)

* fix(native-chat): wrap question text and option descriptions instead of truncating (#10025)

* fix(mobile): pop to home when leaving a host so the back chevron animates backward (#9723)

* fix(i18n/zh): correct technical literals and clear sense errors (#10048)

* feat(speech): add Korean streaming zipformer STT model (#9893)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(source-control): add copy relative path (#9018)

* fix(gitlab): stop refresh button overlapping dialog close X (#9445)

* fix(gitlab): stop refresh button overlapping dialog close X

The GitLab item dialog's SheetContent renders its own close (X) at
absolute right-4, but the header refresh button sat at the header's
px-5 right padding and overlapped it. Reserve pr-10 on the header so
the refresh button clears the close X, and lift it -mt-1.5 so its icon
aligns with the close X on the same line.

* fix(gitlab): integrate sheet controls into header

---------

Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* fix(sidebar): raise selected workspace contrast in dark mode (#8321)

* feat(editor): toggle Word Wrap from file tab actions and Alt+Z (#10086)

* feat(editor): toggle Word Wrap from file tab actions and Alt+Z

Long single-line and structured files wrap by default and misalign.
Surface Word Wrap on the editor more-actions menu for normal file tabs
(diff already had it) and add editor.toggleWordWrap (Alt+Z) so users can
unwrap without opening Settings.

Closes #9974

* fix(editor): toggle diffWordWrap for diff surfaces on Alt+Z

CodeRabbit: Alt+Z previously always flipped editorWordWrap, leaving
diff panes out of sync with the markdown actions menu.

* test(editor): verify word wrap shortcut routing

Cover editor/diff setting callbacks and the cross-platform Alt+Z binding.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(repo-icon): detect Tauri and WebP icons (#7942)

Expand repository icon auto-detection to conventional Tauri and public/icon paths with PNG/WebP magic and dimension validation. Bound SSH probing while preserving candidate priority and PNG-only user uploads; SVG remains rejected.

* Add bulk tab closing to mobile long-press sheets (Close Others / Left / Right) and complete the desktop tab context menus (#9323)

* Add Close Tabs to the Left and complete Close Others across tab menus and mobile long-press sheets

* Fold the per-sheet Close action into the bulk-close module (session route max-lines)

* fix(mobile): preserve pinned tabs during bulk close

---------

Co-authored-by: Tom de Bres <tomdebres@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* feat: implement Cmd+Enter as commit shortcut in Source Control (#9773)

* feat: implement Cmd+Enter as commit shortcut in Source Control

* test: add unit tests for commit shortcut and tooltip formatting

* fix: address review feedback on modifier keys and test coverage

* test: split mac and windows/linux shortcut and keydown tests

---------

Co-authored-by: Andres Van Reepingen <andres.vanreepingen@datacamp.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* Add SenseVoice speech-to-text model (Korean/Japanese support) (#7436)

* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(settings): show a way back to local accounts when a remote server owns provider-account scope (#8188)

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* feat(speech): add Parakeet TDT-CTC 0.6B JA voice model (#8207)

* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(speech): add Parakeet TDT-CTC 0.6B JA to the speech model catalog

* test(speech): cover stt-worker-model-config file resolution incl. single-file models

* feat(speech): decode Parakeet TDT-CTC JA via sherpa-onnx nemoCtc offline recognizer

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: xsacdw <xsacdw@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(terminal): stop switch bold flash and Windows lag (#10692)

* fix(terminal): stop bold flash on worktree switch

Worktree hide disposes WebGL and falls back to xterm's DOM renderer.
On reveal, resume ran after paint and flushed backlog against DOM first,
so the first frame used heavier CSS-AA glyphs before WebGL settled.

Resume in useLayoutEffect and reattach WebGL before backlog flush so the
first painted frame stays on the GPU path. No cold-park policy change.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): fit WebGL grid before backlog flush on resume

Adversarial review: resume-before-flush alone wrote TUI backlog onto the
transient DOM↔WebGL one-column-off metrics window. Order is now
resume → fitAllRevealedPanes → flush on heavy reveal and window wake.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): latch viewport intent before WebGL wake resume

Adversarial review: wake path synced intents after resume/fit, which can
re-latch a pinned viewport as followOutput. Capture before reattach and
drop the post-resume re-sync on heavy reveal (outer path already latched).

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): complete visibility bookkeeping before PaneManager exists

useLayoutEffect runs before the passive lifecycle creates PaneManager, so
the mount-visible path never set hasCompletedVisibleResume. The first
intra-worktree hide then wrongly suspended WebGL. Bookkeep completion
even when managerRef is still null (extracted helper for max-lines).

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): re-sync pin geometry after resume backlog flush

Keep the pre-resume intent latch (reattach must not re-latch pins as
followOutput), then re-sync after flush with preservePinnedAtBottom so
scrollback trim updates absolute pin lines before enforce.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): drop same-tick post-flush intent re-sync

flushTerminalOutput only queues terminal.write and returns before parse, so
a same-tick re-sync read pre-parse resume/fit geometry and could overwrite
pre-resume pins. Keep pre-resume latch + enforce only.

Co-authored-by: Orca <help@stably.ai>

* fix(test): expect default worktree card properties to include cli

#10712 added 'cli' to DEFAULT_WORKTREE_CARD_PROPERTIES, but the fresh
default-profile assertion still omitted it and fails verify.

Co-authored-by: Orca <help@stably.ai>

* perf(terminal): retain Windows WebGL across worktree hides

* perf(terminal): bound retained WebGL contexts

* fix(terminal): harden retained WebGL lifecycle

* fix(terminal): preserve healthy WebGL on wake

* fix(terminal): preserve reveal recovery ordering

---------

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): clear native-chat composer optimistically at send time (#10226)

* fix(mobile): clear native-chat composer optimistically at send time

Over relay the send RPC round trip is visible and a lost ack (or a
relay/direct cutover) could strand the sent prompt in the composer
forever: the unconfirmed-send deadline dropped its tracking entry, so a
late transcript echo could never clear the draft.

Clear the draft at send time and restore it only on a definite
rejection. holdUnconfirmedSend now only manages the delivery-unconfirmed
notice; it no longer touches drafts.

* fix(mobile): isolate question answers from composer drafts

* fix(cli): bound orchestration ask timeouts (#10689)

* fix(cli): bound orchestration ask timeouts

* fix(cli): harden remote timeout boundaries

* fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail (#10729)

* fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail

Windows renderer OOM F0BKR84AHEH (0xE0000008) arrived with a 30-entry
breadcrumb ring in which two `terminal_safe_fit_retry_exhausted` bursts
consumed 26-90% of the slots. Every hidden pane is `display:none` -> 0x0 ->
unmeasurable, so one post-reload reattach wave exhausts the retry budget once
per mounted pane inside ~60ms.

The bursts were also uninterpretable: `pane.id` restarts at 1 per PaneManager
and there is one manager per tab, so 34 identical `paneId: 1` crumbs cannot
distinguish one pane looping from 34 panes firing once. Coalesce the crumb by
name and carry the live-pane census on the payload instead, so the count
survives without costing 34 ring slots.

Same treatment for WebGL diagnostics, which were worse off: context-loss and
atlas-reset crumbs only reached a DevTools-only ring (`window.n()`), so a
renderer that dies takes them with it. That bundle had three GPU-process deaths
in the 65s before the renderer OOM and zero WebGL evidence - absence of
instrumentation, not absence of the event. Mirror them into the crash report,
coalesced per kind so a routine atlas reset cannot mask a context loss.

Evidence-only: no behavior, rendering, or lifecycle path changes.

Co-authored-by: Orca <help@stably.ai>

* perf(pane-manager): count panes without materializing public views

The census runs on the crash path; getPanes() allocates a full ManagedPane
projection per pane just to read .length.

Co-authored-by: Orca <help@stably.ai>

* test(crash-reporting): pin the fit-retry burst against the 30-entry ring

Reproduces the F0BKR84AHEH ring loss directly: 10 pre-crash crumbs plus a
34-crumb per-pane burst. Uncoalesced, the burst takes all 30 slots and zero
pre-crash crumbs survive; coalesced, it takes one slot, all 10 survive, and
the pane count rides on the payload instead of on the crumb multiplicity.

Co-authored-by: Orca <help@stably.ai>

* fix(crash-reporting): name the WebGL census the same as the fit-retry census

The context-loss crumb spread getLivePaneCensus() raw, so one ring described
one measurement two ways: managers/panes here, livePanes/livePaneManagers on
the fit crumb. Spreading also meant renaming the census return keys would
silently reshape the crumb. Name the fields at the call site and pin them.

Co-authored-by: Orca <help@stably.ai>

* fix(crash-reporting): keep a hot coalesce key from being the first LRU eviction

The suppression path returned before the delete-then-set that re-anchors
recency, so a key hit continuously never moved from its original insertion
slot and became the first eviction candidate — the inverse of the LRU's
stated intent.

`renderer_error` keys carry message+stack identity, so one noisy render loop
mints unbounded distinct keys. Within a single 30s window that churn evicted
the `terminal_safe_fit_retry_exhausted` key mid-burst, un-suppressing it and
re-arming the exact ring flush the coalescing exists to prevent.

Re-anchor position only; `recordedAt` is left alone so the suppression window
still expires on schedule rather than renewing on every hit.

Found while adversarially probing the LRU claim in #10729's own description,
which asserted these keys "cannot evict live keys".

* fix(crash-reporting): report the newest census of a coalesced burst

The suppression path wrote nothing to the ring, so a coalesced burst froze
its FIRST event. Panes mount progressively, so pane 1 exhausting alone
legitimately measures livePanes: 1 -- and the 33 later crumbs, each carrying
a truer census, were dropped. A 34-pane wave was recorded as `livePanes: 1`
with no count: the exact "one pane looping" misread that coalescing by name
was introduced to prevent.

The existing burst test missed this because it fed a constant census on
every crumb, making frozen-first and newest-wins indistinguishable.

Stash the newest payload and fold it into the ring entry the key already
owns: still one slot, now reading livePanes: 34 + suppressedSinceLast: 33.
Resolution is deferred to snapshot time -- sanitizing per suppressed hit of
a 1459/min crash loop measured 2194 ns/op vs 185 ns/op deferred.

Two follow-on defects fixed alongside: an expiring key dropped its pending
payload (it loses its only handle on the ring entry), and resolving the
re-emitting key's own old slot double-counted a burst.

---------

Co-authored-by: Orca <help@stably.ai>

* fix(speech): download verified model artifacts directly (#10735)

* perf(renderer): give owner-routed settings a stable identity (#10743)

Co-authored-by: Orca <help@stably.ai>

* perf(agent-status): validate hook payloads without the JSON round trip (#10752)

* fix(gpu-fallback): make the crash window rolling, not launch-anchored (#10707)

* fix(gpu-fallback): make the crash window rolling, not launch-anchored

Software-rendering fallback only ever considered GPU child crashes in the
first 30s after launch: `if (msSinceLaunch > this.windowMs) return`. Session
12e6ee64 crashed the GPU child 4 times (242s / 920s / 926s / 946s since
launch). The last three span 26.0s — inside windowMs, exactly threshold — but
every one was rejected because the burst began 920s in. The renderer died of
process OOM (0xE0000008) 39s later.

GPU work is demand-driven, so the first heavy compositing often happens
minutes into a session. What distinguishes a broken driver from normal
Chromium churn is that the crashes *cluster*, not when the cluster starts.

Keep a sorted array of recent crash times pruned to windowMs behind the
newest, and engage when the count reaches threshold.

Measured against real field telemetry (341 distinct win32 launches with >=1
GPU crash, from process_gone_suppressed breadcrumb trails): the rolling
window engages on 2/341 launches (0.59%), one of which is 12e6ee64. Max GPU
crashes in any single launch is 4. The closest non-firing sequence
([0, 29531, 55136, 74178] — consecutive gaps that each fit the window but
never put 3 inside it) is pinned as a regression test.

Also destroy the Windows tray before app.exit(0) on this path, matching the
app:relaunch IPC handler — app.exit skips before-quit, and this can now fire
deep into a session rather than only in the first 30s.

Mutation-tested: 6 mutants (launch-anchored gate, dropped pruning, dropped
monotonic clamp, cutoff </<=, threshold >=/>, dropped engaged latch), all
killed by the suite.

Co-authored-by: Orca <help@stably.ai>

* fix(gpu-fallback): ask before restarting

---------

Co-authored-by: Orca <help@stably.ai>

* feat(sidebar): add a filter to hide detached-HEAD workspaces (#10786)

Adds "Hide detached HEAD" alongside the existing sidebar filters, wired
through the same pipeline as Hide CLI-created: sidebar list, Cmd+J
empty-query list, workspace board, active-filter badges, Clear/Reset
Filters, and persisted UI state.

The predicate reuses getWorktreeGitIdentityDisplay so the filter targets
exactly what the card renders a Detached HEAD badge for. Requiring a real
head (not just an empty branch) keeps folder workspaces and
SSH-synthesized rows — which carry both empty — out of the filter.

Activating a hidden detached workspace clears the filter, matching the
existing reveal escape hatch for automation- and CLI-created workspaces.

Splits the filter-state describes out of visible-worktrees.test.ts into
sidebar-filter-state.test.ts to stay under the max-lines budget.

Co-authored-by: Orca <help@stably.ai>

* feat(daemon): add daemon_lifecycle replaced/retired telemetry event (#10058)

* feat(daemon): add daemon_lifecycle replaced/retired telemetry event

Implements STA-2376.

Adds track('daemon_lifecycle', {transition, reason, live_session_count_bucket, version_skew?}) covering 'replaced' (unhealthy_resolver / stale_bundle / different_app_path / failed_health_check at daemon-init launcher sites) and 'retired' (died_respawn at the adapter respawn closures). Enum-only + .strict() + bucketed counts keep paths, versions, and raw counts off the wire; preserve-path transitions emit nothing. Cross-platform and SSH-safe; no-op in non-official builds.

Test plan: affected vitest (158) green; typecheck/lint clean except pre-existing unrelated failures.

* fix(daemon): prevent false lifecycle telemetry

* test(daemon): restore once-ness on respawn reason assertions

Keep STA-2376 reason checks without dropping concurrent-respawn
coalescing coverage that prevents double died_respawn telemetry.

* fix(daemon): emit replaced telemetry on runtime unhealthy_resolver respawn

CodeRabbit: adapter-driven macOS resolver replacements forked a new daemon
without a lifecycle event. Emit trackDaemonReplaced (not retired) so field
diagnosis of #7936 covers the runtime path without mislabeling it as death.

* fix(daemon): stop double-counting resolver replaces; drop redundant version_skew

Three telemetry-correctness fixes to the STA-2376 daemon_lifecycle event.

1. The runtime macOS resolver respawn double-counted. doRespawn() disconnects
   but never kills the daemon, so the ensureRunning() that follows re-enters
   createOutOfProcessLauncher, which re-detects healthy + resolver-unhealthy +
   0 sessions and emits the replace itself. The closure emitted a second one.
   It also emitted before the outcome was known, so a resolver that recovered
   mid-flight (or a session appearing) left a 'replaced' on the wire for a
   daemon the launcher went on to preserve. The launcher's emit is gated on a
   confirmed kill, so it is the correct sole emitter; this reverts the emit
   added in 1e60ca87a4. The reason plumbing stays -- it is what keeps a
   resolver respawn from being mislabelled died_respawn.

2. version_skew carried no information and lied to one cohort. It was present
   iff reason === 'stale_bundle' and always true, so it was a deterministic
   function of reason. isDaemonStaleForCurrentBundle also returns true when the
   pid file has appVersion: null -- a replace-once heuristic for pre-marker
   builds, where no version comparison happened at all -- so the field asserted
   skew for exactly the upgrade cohort the event exists to illuminate. Dropped
   from the schema, emitter, and call site, along with the dead branching.

3. track() is now failure-isolated in both emitters. Both call sites sit on the
   daemon launch/respawn path, where a throw costs the user every terminal.

Tests: once-ness (toHaveBeenCalledTimes) on every emit assertion -- the old
toHaveBeenCalledWith-only assertions passed under a doubled call; a regression
guard that the resolver respawn closure stays silent; and a throwing-client
test. Note the unit tests mock DaemonSpawner and never invoke the launcher, so
no test could observe the double-emit; the once-ness assertions bound each
emitter within its own seam.

Known limitation, unchanged: a wedged-but-alive daemon (#8689) can still report
died_respawn from the adapter and failed_health_check from the launcher -- the
app cannot distinguish wedged from dead at that point.

* fix(daemon): attribute the runtime resolver replace so it is not lost

Round-2 review found the previous commit over-corrected. Removing the emit
from the respawn closures was right about the premature emit but wrong about
where the event would come from instead.

doRespawn() does not kill the daemon, but it does drop its only authenticated
client, and that is enough: the last fully-authenticated disconnect sets
retirementRequested, and reevaluateIdleShutdown -> beginIdleShutdown runs with
no grace timer, unlinking the token and PID files. So by the time
ensureRunning() re-enters the launcher, the daemon is already gone --
killStaleDaemon finds no PID file, confirmedReplacement stays false, and the
gate suppresses the emit. Net effect of the previous commit: zero events for a
runtime macOS resolver replacement, the common case.

The double-emit round 1 found was real but narrow: it needs a daemon holding
non-alive sessions, which keeps host.listSessions() non-empty so isIdle() is
false and the daemon survives the disconnect to be killed by the launcher.

Fix: the adapter attributes the reason rather than emitting it, and the launch
it triggers consumes the attribution and reports it. One emit point, exactly
one event, correct reason -- whether the daemon self-retired or survived to be
killed. The attribution is one-shot so a later unrelated launch cannot inherit
it, and it is preferred over the launcher's own inference, which would
otherwise mislabel this as failed_health_check.

Also: suppress died_respawn while a manual restart is in flight. runRestartDaemon
kills the daemon while the outgoing adapter is still live and undisposed, so a
pane respawning on its synthetic exit billed a user action to the crash bucket.

Tests: a regression test that drives the closure and the launcher across the
seam the DaemonSpawner mock normally hides, with killStaleDaemon returning
false to model the self-retired daemon, plus the one-shot assertion. Verified
load-bearing by mutation (neutering the handoff fails it). Also reset
killStaleDaemonMock's implementation in beforeEach -- mockClear left a previous
test's mockResolvedValue in place, which silently disarmed the
confirmedReplacement gate for every test after it.

* fix(daemon): let a proven replacement reason outrank the attributed one

Round-3 review. The attribution was preferred unconditionally, so a launch that
independently proved a different cause reported the wrong one: resolver
unhealthy at the adapter check, daemon survives the client drop (non-alive
sessions keep isIdle() false), resolver recovers milliseconds later, and the
launcher then proves a stale bundle or a different app path and kills on that
basis -- but the event said unhealthy_resolver. A proven kill is grounded in
post-kill truth, so it now wins; the attribution covers only what the gate
cannot see, a daemon that self-retired leaving nothing to kill and no reason to
infer. Every other case is unchanged: self-retired still reports
unhealthy_resolver, and the surviving-daemon case reaches the same reason
through the launcher's own inference.

Also pins the invariant that makes the module-scoped one-shot safe -- the write
reaches the launcher with no await in between -- at both the write and the
consume, since the DaemonSpawner mock lets tests drive the two halves with an
arbitrary gap and would not catch an await being introduced.

Corrects the beforeEach comment from the previous commit: there was never a
plain mockResolvedValue on killStaleDaemonMock in this file, so it did not
silently disarm the gate for later tests. mockReset is still right -- it drops
an unconsumed *Once queue, which mockClear does not.

Tests: a guard that the launcher's proven reason wins over the attribution,
verified load-bearing by mutation (restoring the old ordering fails it).

* fix(daemon): don't let the residual health bucket absorb the resolver reason

Round-4 review caught a regression from the previous commit. Letting any
confirmed kill outrank the attribution was too broad: failed_health_check is
not an identification, it is the residual bucket that also absorbs wedges and
crashes, so preferring it discards the more specific reason the adapter already
established.

This is the likely shape of the incident, not a corner case. The dead macOS
login session that fails the resolver probe also fails the PTY spawn probe, so
checkDaemonHealth returns pty-spawn-unhealthy rather than healthy. The resolver
branch is then never evaluated, and with zero live sessions the degraded
preserve does not apply either, so the launch falls through to
failed_health_check and kills a daemon that survived the adapter's disconnect
(non-alive sessions keep it non-idle). Before this commit that reported
failed_health_check and dropped unhealthy_resolver -- burying the flagship
signal in the catch-all.

Rank by how well each reason is evidenced instead: a confirmed kill that
positively identified the daemon wins, the attribution beats the residual
bucket, and the residual bucket is still reported when there is nothing better.
Round 3's motivating case (a proven stale bundle must not be billed to the
resolver) is unaffected and still covered.

Tests: a guard for the pty-spawn-unhealthy shape, verified load-bearing by
mutation (removing the residual-bucket exclusion fails it).

* test(daemon): cover the confirmation path production actually uses

Round-5 review. No runtime defect, but the three identified reasons were only
ever proven through a mechanism the field never takes.

confirmedReplacement is `(await killStaleDaemon(...)) || cleanupResult.cleaned`,
and the two halves are disjoint in practice. unhealthy_resolver, stale_bundle,
and different_app_path all require health === 'healthy', so cleanup reaches the
daemon over RPC, shuts it down, and unlinks its pid file -- leaving nothing for
killStaleDaemon to find. Production therefore confirms exclusively via
.cleaned. Every test confirmed exclusively via killStaleDaemon, because the
net.connect stub always errors, so cleanup always returned cleaned:false.

The consequence was a silent trapdoor: simplifying the gate to
`confirmedReplacement = await killStaleDaemon(...)` stops all three identified
reasons from ever emitting in the field, and the whole suite stays green.
Confirmed by flipping the killStaleDaemon default to false -- five tests fail,
none of which are meant to be about the kill.

Adds the missing case: cleanup confirms, the kill finds nothing, and
different_app_path is still reported once. Verified load-bearing by mutation --
dropping the .cleaned half now fails this test specifically.

Scoped the new test's mocks to *Once so the identity override cannot leak into
the packaged-bundle test that follows, and hands probeSocketExists back on the
way out since beforeEach only mockClear()s it.

* test(daemon): cover the manual-restart retirement guard

Round-6 review returned land-ready with one note: removing the !restartInFlight
guard from both respawn closures left the whole suite green. That is the same
silent-trapdoor shape the previous commit closed for the .cleaned gate, on a
guard this PR introduced, so it gets the same treatment.

The guard matters because runRestartDaemon tears the daemon down while the
outgoing adapter is still live and undisposed; a pane respawning on its
synthetic exit reaches the death path for what was a user action, and would
bill a manual restart to the crash bucket.

Drives the death from inside the restart's ensureRunning so restartInFlight is
genuinely set by the code under test, rather than asserting against a flag the
test poked itself, and then repeats the respawn after the restart settles to
show the suppression is scoped rather than permanent. Verified load-bearing by
mutation: removing the guard fails this test and nothing else.

* test(daemon): close the two surviving telemetry mutations

Round-7 review returned land-ready with two test-only gaps, both found by
mutation and both the same trapdoor shape as the last two commits.

The manual-restart guard exists in two respawn closures and only the first was
covered. That is the wrong half: the restart installs its own adapter, so from
the second restart onward the copy in runRestartDaemon is the one that actually
runs in the field, and it could be deleted with the suite green. The test now
drives a second restart through the adapter the first one installed.

The privacy-invariant test only built 'replaced' payloads, so .strict() on the
'retired' member was never exercised -- someone adding a field to
trackDaemonRetired after that .strict() was dropped would have reached PostHog
with the test still passing. It now runs the leak set over both transitions,
plus a sanity assertion that each base payload is itself valid so the
rejections prove the leak and not a malformed base.

Both verified load-bearing: neutering the second guard copy, and dropping
.strict() from the retired member, each now fail exactly one test.

* fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373) (#10065)

* fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373)

DaemonPtyAdapter.write() sends keystrokes via fire-and-forget client.notify().
When the daemon dies (retirement, crash, kill), the socket disconnects and the
notify is silently dropped — no rejection reaches withDaemonRetry, so the
dead-endpoint respawn never fires and the attached pane freezes. Only a
request/reply RPC (e.g. createOrAttach from opening a new terminal) detected
the death and forked a replacement.

DaemonClient.notify() now reports delivery; a dropped write to a still-active
session drives the shared respawn coalescer directly (reconnecting the
permanent client before releasing the temporary adoption lease, mirroring
withDaemonRetry's ordering), so the pane self-heals like the createOrAttach
path. Cross-platform + SSH-safe: no platform assumptions, pure adapter logic.

Complements (does not duplicate) #8426, which fixes the adjacent in-daemon bug
where a thrown node-pty write no longer marks the handle dead. That is
daemon-side; this is the app-side dropped-notify that never triggered respawn.

* fix(daemon): restore adapter state after dropped-write respawn

* fix(daemon): recover writes after endpoint respawn

* fix(terminal): remount panes after daemon death

* fix(daemon): recover sibling panes after daemon death, not just the written one

When a daemon dies, its dropped-write respawn only remounted the pane whose
write detected the dead endpoint. Sibling panes (alive at death but not typed
into) were left frozen: stale prompt pixels, silently-dropped input, no live
child, and no recovery even on later keystrokes — the exact STA-2373
frozen-typing symptom on non-triggering panes.

DaemonPtyAdapter now fans a write-unavailable signal out to every active
session when it recovers from a dead endpoint, emitted while the sessions are
still in activeSessionIds so the renderer's liveness gate still reads them
live. pty.ts forwards each to the existing pty:writeUnavailable channel, so all
panes remount + re-attach through the same path the written pane already used.

Adds a revert-sensitive regression test: with two sessions and only one
written after the daemon dies, the sibling must also be signaled to recover.

* revert(format): drop repo-wide oxfmt churn unrelated to STA-2373

A review pass ran `oxfmt --write .` across the tree, pulling seven files
with no bearing on the dead-daemon respawn fix into the PR diff. Restored
to origin/main byte-for-byte so the diff carries only the respawn change.

* fix(daemon): snapshot active sessions before the write-unavailable fan-out

A listener that kills a pane mutates activeSessionIds mid-iteration, which
can skip the very sibling the fan-out exists to reach. Matches the snapshot
fanoutSyntheticExits already takes.

* fix(daemon): re-arm dead-endpoint recovery on every daemon death

The respawn-storm latch was only released once every awaiting session
rebound. Background sessions have no mounted pane, so nothing ever calls
createOrAttach for them and they hold the awaiting set non-empty forever
— latching the fan-out off after the first death and silently making the
whole fix one-shot. Re-arm on the disconnect event instead, which fires
once per established connection, so the storm guard still holds within a
single incident.

* fix(daemon): route the write-unavailable fan-out through the pty router

Main subscribes on the routed provider, and DaemonPtyRouter is the live
localProvider whenever a legacy daemon socket exists — the common case
when an in-place update bumps PROTOCOL_VERSION with terminals running.
It forwarded write but not onWriteUnavailable, so the fan-out reached no
listener and only the written pane recovered: STA-2373 unfixed, silently.

Also stop rejecting writes on adapters that cannot respawn. Legacy
adapters have no respawn, so the remount reattaches to nothing and
rebuilds the pane empty, losing scrollback the user could still read —
worse than the pre-existing silent drop. And guard the renderer's
write-unavailable handler on ptyId like its sibling data/replay handlers,
so a transport that rebinds without detaching cannot remount a healthy
pane.

* fix(daemon): route the write-unavailable fan-out through the degraded provider

DegradedDaemonPtyProvider is the live localProvider in degraded launch
mode and main subscribes on it, but it forwarded onData/onExit/onReplay/
onBackgroundStreamEvent and not onWriteUnavailable — so the fan-out
reached no listener and siblings stayed frozen. Same defect as the router,
one provider over.

The file sat at its max-lines ceiling, so make room by reusing one
combineUnsubscribes helper across the three places that already repeated
that loop rather than bumping the limit. Forward to the daemon adapters
only: the local fallback has no dead-socket problem.

* refactor(daemon): share the listener-fanout unsubscribe combination

Adding onWriteUnavailable to both provider wrappers left each file at
exactly 300/300 lines, so the next line anyone added would have broken
max-lines with no sanctioned escape hatch. Both already repeated the same
combine-unsubscribes loop, so lift it into one module: duplication drops
and each file gets its headroom back.

* fix(test): stop the fake emitter colliding with the private adapter emitter

DaemonPtyAdapter.emitWriteUnavailable is private, so declaring a public
member of the same name on a mock intersected with DaemonPtyAdapter
collapsed the whole type to never — one collision produced 54 typecheck
errors, taking out pre-existing assertions in both files too. Rename the
fake to triggerWriteUnavailable and declare onWriteUnavailable on
ProviderMock, which IPtyProvider does not carry on this branch.

vitest does not typecheck, which is why a red build sat behind a green
suite.

* fix(quick-commands): show pointer cursor on hover (#10223)

* fix(terminal): limit pre-paint WebGL resume to macOS (#10794)

Run terminal visibility transitions pre-paint only on macOS. Restore passive disposal and recreation on Windows/Linux, remove the Windows retained-context LRU machinery, and preserve the normal 128-context startup ceiling.

* fix(ssh): connect to Linux hosts that cannot compile node-pty (#10776)

* fix(ssh): connect to Linux hosts that cannot compile node-pty

node-pty ships no Linux prebuilt at any architecture, so it is compiled on
the remote. On a host without a C/C++ toolchain that build fails, and because
both native deps install in one npm command it also took down
@parcel/watcher — which does have a working Linux prebuilt — and failed the
whole connection. Every Linux image without build tools was unusable.

node-pty only backs remote terminals; files, git, and the editor do not need
it, and a missing native dep is already non-fatal further down the deploy. So
when the existing toolchain probe confirms the compiler is missing, reinstall
without node-pty instead of aborting. The manifest has to drop it too — npm
reconciles every dependency in package.json, not just the ones named on the
command line, so naming only @parcel/watcher still rebuilds node-pty.

If that reinstall also fails the actionable build-tools error is rethrown, so
a host broken for some other reason still reports the toolchain gap.

The relay's PTY error now names the fix rather than saying only that node-pty
is unavailable.

Verified on a stock Rocky Linux 10.2 aarch64 container (openssh-server, git,
nodejs, npm, no compiler): connect succeeds, /etc lists over SSH, node-pty is
absent while @parcel/watcher installs its linux-arm64-glibc prebuilt, and
spawning a terminal reports the install hint.

* fix(ssh): keep the node-pty skip path honest about platform and watcher

The PTY unavailable message named build tools unconditionally, but only Linux
compiles node-pty — the deploy-side skip is gated on linux and the toolchain
probe returns null on Windows. A Windows or macOS remote, where node-pty ships
prebuilds, was told to install make/g++/python3. Pick the remedy by the relay's
own platform.

The skip path returned before the install probe, so a @parcel/watcher that
installs but cannot require() (glibc below the floor) connected with dead file
watching and nothing logged. Probe before returning and warn; no rebuild, since
node-pty provably cannot compile on that host, and never fatal.

Also log the pty-less reinstall's own failure and attach it as cause — the
rethrown toolchain message is built from the original npm error, so an
unrelated retry failure (registry, ENOSPC, EACCES) was lost. The reinstall now
keeps the caller's resetDeps as well, so a repair reconnect still clears every
dep the probe found broken.

Tests: the skip-success fixture queued a chmod/probe/rebuild sequence
production never runs, and the surplus slots were absorbed by launchRelay's
readiness poll (1817ms vs 3-9ms for its peers). It now emits exactly the 12
execs production performs, and pins that no rebuild is issued. Adds the missing
negative case: a gyp-shaped failure on a host whose probe reports a complete
toolchain must still hard-fail rather than silently degrade.

* fix(ssh): hedge the node-pty remedy and keep repair resets on the skip path

* perf(terminal): bound the PR-link carry scan to the trailing window (#10741)

* fix(codex): treat non-Codex transcript as stale resume metadata (#10538)

When a pane is mislabeled agent:codex but still holds a Claude
transcriptPath, the Codex resume guard threw and blocked relaunch.

Only hard-fail when the path claims Codex's dated rollout layout
(sessions/YYYY/MM/DD/rollout-*.jsonl), under any home and without
requiring the file to exist. Paths that never claimed Codex provenance
return null so the pane can relaunch.

Keying on rollout shape rather than trusted-home membership matters:
returning null only declines to override CODEX_HOME, and the renderer
has already baked 'codex resume <id>' into the command. A real rollout
under an untrusted home would otherwise resume under whichever account
is selected — and once the session bridge hardlinks rollouts across
homes, codex would find that id and resume silently under the wrong
account. Trust is decided upstream by findTrustedCodexSessionResume.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>

Closes #10517

* fix(codex): cache weekly-only accounts when switching Codex accounts (#10467)

* fix(codex): cache weekly-only accounts when switching Codex accounts

refreshForCodexAccountChange snapshotted the outgoing account only when
this.state.codex.session was populated. Weekly-only plans report no session
window, so their snapshot was dropped and the account switcher's inline bars
rendered empty for exactly those accounts.

Accept a populated weekly window as well. #10136 made this reachable: before
duration-based classification, a weekly-only quota landed in the session slot,
so the gate happened to pass.

Claude is intentionally untouched; it has no weekly-only plan shape.

* test(rate-limits): pin that a windowless outgoing Codex account is not cached

The widened weekly-only gate had no test for its lower bound: replacing it with a bare truthy check on state.codex passed all 70 tests, which would cache an empty fetching placeholder and render a blank inline bar row in the switcher.

* release: v1.4.159-rc.0

* fix(terminal): kill floating, setup, and folder-workspace PTYs on tab close (#10810)

* perf(terminal): coalesce per-keystroke input stamps + add typing-latency self-diagnostic (#10784)

* perf(terminal): coalesce per-keystroke input stamps + add typing-latency diagnostic

A user reports keystroke-echo lag on v1.4.156 that vanishes in Ghostty on the
same machine, at a scale we cannot reproduce locally (nested worktrees, ~20
agents each). Static analysis across the 155..156 renderer diff found no
perceptible regression, so this adds the instrument to measure it where it
actually happens.

Diagnostic (`window.__orcaTypingDiagnostic`, dev-console only, no shipped UI):
reports keydown->paint percentiles from real typing plus a scale census —
agent rows store-total vs mounted-DOM, store listener count, worktree nesting
depth, the settings gating suspect paths, and the focused pane's agent and
buffer mode. Nothing attaches to the keystroke path until start(), so it does
not perturb the latency it measures.

Coalescing: recordTerminalInput wrote the whole lastTerminalInputAtByPaneKey
map on every keystroke, waking every zustand subscriber. Hibernation is a >=60s
idle timeout, so the leading edge of a burst writes immediately and the rest
collapse into one trailing flush. Imperative readers merge the pending stamp,
and a late flush never revives a pane key teardown deleted.

This write path is byte-identical in v1.4.155 and v1.4.156, so the coalescing
is a general perf win, not a fix for the reported regression — the measured
saving (~0.02ms/keystroke) is well below perception.

* fix(diagnostic): count React store subscriptions in the listener census

The census wrapped `subscribe` on the bound hook after `create()` had already run.
zustand's `useStore()` reads the INNER `api.subscribe`, and `create()` copies
subscribe onto the hook as a separate property slot — so patching the hook's copy
counted only the 16 imperative `useAppStore.subscribe()` call sites and missed all
~2.2k React hook subscriptions, i.e. exactly the ones that scale with agent rows.
The metric would have read a near-constant ~16 regardless of scale, which would
have made "latency tracks listener count" read as false no matter the truth.

The inner api is only reachable as the state creator's third argument, so the
counter now installs there and lives in the store rather than the probe.

Still per-subscribe (component mount), never per-setState: zustand notifies by
iterating its listener Set directly, so this never touches the keystroke path.

Tests pin both subscribe paths; reverting to the old wiring fails 3 of the 5.

* fix(ssh): stop remote terminals fail-opening to local PTY (#10778)

* fix(ssh): stop remote terminals fail-opening to local PTY

Docker SSH watcher isolation failed because an unhydrated remote worktree
spawned through the local daemon with a container-only cwd. Fail closed
while the SSH owner is still loading, ignore non-PTY mux notifications
before mapping params.id, and harden the Docker SSH e2e connect helper so
the repo connectionId is present before terminal activation.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): keep host-agnostic terminals off the hydration guard

The unresolved-owner guard also caught floating and inline setup terminals,
which have no repo row by design, regressing #10151. Scope it to repo-backed
worktrees and cover the SSH hydration window with a regression test.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): treat a local-stamped worktree as a resolved host

The hydration guard keyed off "no repo row", which also withheld spawn for a
local worktree whose own hostId already proves its host. Key it off "nothing
names the host" instead, and cover the local-stamped case with a test.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): recover parked panes when their host hydrates

Withholding the spawn stopped the wrong-host PTY but left the pane inert:
nothing bumped its generation once the repo row merged, so a remote terminal
still never opened. Remount PTY-less tabs when repos:changed resolves their
owner, and drop the e2e helper workaround that was masking this.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): scope hydration remounts to panes that actually parked

Keying recovery off "tab has no PTY" also matched tabs whose shell merely
exited, remounting them on every repos:changed. Track the panes that withheld
their spawn and consume each entry once, so recovery cannot churn a live
terminal or spin on repeated refreshes.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): cover the repos:changed parked-pane remount wiring

The recovery predicate was unit-tested, but nothing proved useIpcEvents
actually calls it — the existing suite stubs repos.onChanged as a no-op.
Drive the real listener and assert a parked pane is remounted only after
its host resolves.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>

* fix(memory): clarify Resource Manager accounting (#10821)

* perf(agent-status): scan Command Code transcripts backward from EOF (#10742)

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): quarantine input after a replaced PTY endpoint (STA-2373) (#10811)

Follow-up to #10065, which merged with this failure mode known and deferred.

Recovery from a dead daemon socket reattaches to a fresh shell. Keystrokes in
flight during the ~1.1s window are dropped, but everything typed after reattach
lands on the new shell, so the surviving tail of a half-sent line is submitted by
the user's own Enter: `echo hi; rm -rf x` arrives as `cho hi; rm -rf x` — zsh
fails `cho` and still runs `rm -rf x`. Before #10065 the whole line was lost, so
the executing tail is new.

Quarantine the remainder of the interrupted line instead. Keyed by tab, not pane:
recovery destroys the xterm being typed into and the successor pane receives the
tail. Armed only from the onWriteUnavailable path; a stalled-pipeline remount
keeps the same live shell, where quarantining would eat a real command.

Disarms on the line terminator (CR/LF/Ctrl-C, dropped too since that is the byte
that would submit the mangled line), a 700ms idle gap, or a 5s cap. The cap must
not be shortened: the tail itself takes ~2.5s to type, so a shorter cap fires
mid-tail and delivers the dangerous remainder to the fresh shell.

The onData check sits after the query-reply branch so CPR/DSR replies still reach
the shell.

Live A/B QA on macOS: the bug reproduces verbatim without the fix (marker file
created by the surviving tail) and is suppressed with it, with the next command
still working — proving suppression rather than a dead pane.

* perf(agent-status): keep the shared transcript reader's carry linear (#10777)

readLastTextFromTranscriptOnce re-joined its carry buffer on every block that
held no newline, so a transcript whose tail is one oversized line copied
O(line^2). It backs three readers — the Claude/Codex user prompt, the Command
Code assistant message, and the shared assistant-text reader — so every agent
that resolves turn text from a transcript paid it.

Same chunk-list carry the Command Code prompt reader already uses. Measured on
a transcript whose tail is one big line: 15.24 ms -> 8.87 ms at 3.9 MB, and the
gap widens with the line, which is the quadratic signature.

* perf(ai-vault): keep the JSONL line carry linear (#10783)

consumeCompleteJsonlLines re-joined its held-over partial line with every
stream chunk, so one oversized record — a large tool result — cost O(record^2).
It backs the incremental parse for every resumable agent transcript, so the
whole AI Vault corpus paid it.

Hold the pieces in a list and join once, when a newline finally arrives.
Measured on a transcript with a single oversized record: 2.13 ms -> 1.17 ms at
1 MB and 68.22 ms -> 4.35 ms at 8 MB, with byte-identical output. A transcript
of ordinary records never reaches the branch.

* perf(git): read both diff blobs concurrently (#10781)

* perf(git): read both diff blobs concurrently

The diff loaders awaited their two sides in series, so the second `git show`
could not start until the first had returned. The reads are independent, so
that was pure added latency on every diff the review panel opens: ~47 ms
sequential vs ~24 ms concurrent, a saving of ~23 ms per diff.

Covers the merge-base, commit, and staged loaders, plus the unstaged path where
the working-tree read is independent of the index->HEAD chain. The unstaged
left chain itself stays sequential because its second step depends on the first.

The staged coalescing test asserted the sequential shape (one spawn, then the
next); it now pins the contract that actually matters — eight identical reads
still collapse to two spawns, one per side.

* test(perf): interleave the diff-blob benchmark arms

Running one strategy's whole batch before the other's lets cache warming, CPU
frequency drift, and background load correlate with the strategy being measured.
Alternate the arms per iteration, alternate which goes first, and report medians
so that drift stays common to both.

Also reject malformed env settings rather than truncating them — Number.parseInt
accepts "10foo" and 3.5.

Interleaved result confirms the original: 1.90x-2.03x, ~24 ms saved per diff.

* perf(mobile-sync): memoize the agent-status projection per entry (#10787)

* perf(mobile-sync): memoize the agent-status projection per entry

buildRuntimeMobileAgentStatusProjection re-serialized every live agent on every
status ping. setAgentStatus replaces one entry and re-spreads the map, which
defeats the reference-equality skip gate, so each ping paid for every other
agent's prompt, 20-entry history, and 8 KB assistant message to discover they
had not changed.

Memoize each row's JSON by entry identity, the cachedTabsProjection pattern
already used a few functions above. Per ping: 0.18 ms -> 0.014 ms at 8 agents,
0.88 ms -> 0.063 ms at 40. The output is byte-identical — joining pre-serialized
rows matches whole-array stringify, which the new test pins against a verbatim
copy of the old implementation.

* test(perf): stop inflating the projection benchmark baseline

The pre-fix arm stringified each row and parsed it back before stringifying the
array, a per-row roundtrip the original never paid. That made the baseline
artificially slow: the reported 5.9x-14.0x is really 2.1x-5.0x.

Share one row builder between both arms, and check equivalence after a ping as
well as on the cold call — a stale-row bug can only surface once the cache is
actually exercised, which the cold-path check could never catch.

* perf(startup): stop parsing qrcode and @linear/sdk at launch (#10788)

Both are reachable only from features most users never touch, but both were in
the main bundle's eager top-level require block.

@linear/sdk is the sharper case: linear-sdk.ts exists solely to load that ~2.6 MB
CJS bundle lazily, and a single value import in issue-relation-write.ts defeated
it for everyone. That file now imports the type and goes through the loader.

qrcode is only reachable from mobile pairing, and both call sites were already
async, so they take a dynamic import.

* fix(codex): rank the Codex resume rescan by selected account, not settings order (#10801)

The legacy Codex session-id rescan (used when a persisted record has no
transcriptPath) returned the FIRST trusted home holding a rollout with that id.
That home becomes the pane's CODEX_HOME — i.e. it picks the ACCOUNT — and the
list ended in per-account homes ordered by settings INSERTION order, so the
account was decided by whichever one the user happened to add first.

Ranks instead: selected account -> real system home -> shared runtime mirror ->
everything else by normalized path. Both ranking inputs are required (an
optional one would silently degrade to pure path order), and the selection
arrives as a thunk so the common provenance-present resume never stats the
ownership marker for a ranking it never runs.

The mirror needs its own tier: prepareLegacySharedCodexSessionResume's guard
only fires when the resolved home IS the mirror, and that guard is what
migrates the rollout into ~/.codex. Without it a system-default selection
silently resumed under an arbitrary account and stayed pinned there permanently
once the hook stamped a transcript path.

Reviewed over two independent rounds; every tier individually mutation-proved.
Live-validated in a real Orca dev build by reading the spawned PTY's actual
CODEX_HOME across three builds (head, base, and head-minus-the-mirror-tier).

Note: fixes none of #10757's user-visible symptoms on its own — it is a
correctness precondition. Verified on macOS only; Windows coverage is
fixture-only.

* fix(sidebar): make worktree drag reorder follow the card, not the pointer (#10845)

* fix(mobile): harden native chat send budgets, streams, and stop (#10814)

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* perf(source-control): share one path collator across the projection (#10850)

* lint: guard the two perf bug shapes we fixed repeatedly (#10851)

* fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841)

* fix(terminal): avoid flash while restoring parked terminals (#10871)

* Fix diff notes overlapping following lines (#7803)

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(terminal): clear search highlights when the find bar closes (#10872)

Closing Ctrl+F left one match highlighted until the window was minimized
and restored.

xterm's DecorationService keys its SortedList on `decoration.marker.line`,
but `SortedList.delete()` only records an index and defers compaction,
while `Marker.dispose()` sets `line = -1` — mutating that same sort key.
After the first disposal the array is no longer sorted, so the binary
search inside `delete()` can miss a decoration that is present. It returns
false, `onDecorationRemoved` never fires, and the decoration stays live and
keeps painting. Repaints don't help; they faithfully re-paint a live
decoration, which is why only a window cycle appeared to fix it.

`clearDecorations()` disposes the active match before the match
highlights, which is exactly the order that trips this.

Patch `delete()` to retry once after compacting pending deletions, on the
miss path only, so the common bulk delete keeps its O(log n) search and
deferred batching. A 3000-trial randomized differential against upstream
semantics shows no behavior change for well-ordered lists.

* perf(cli): load only the handler group a command dispatches into (#10883)

Co-authored-by: Orca <help@stably.ai>

* perf(ssh): normalize watch event paths once per fs.changed batch (#10881)

Co-authored-by: Orca <help@stably.ai>

* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) (#8549)

* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental)

Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed marketplace v0 with consent, provenance and
kill-list enforcement.

Theme, icon-theme and terminal-theme contributions are deferred to a
follow-up pass.

* fix(plugins): make unsupported marketplace listings unreachable by key

findPlugin() backs preview/install/previewInstalledUpdate via
requireListing(), so filtering only listPlugins() hid the catalog card
while leaving the dead install path reachable one click later.

* fix(plugins): fan Pi session-only status out to plugin subscribers

The providerSessionOnly early-return in applyNormalizedStatus emitted to
onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so
plugins subscribed to agent.status.changed silently missed every Pi
session_start event. Route both emit sites through one helper so a future
early return cannot drop the plugin tap again.

Co-authored-by: Orca <help@stably.ai>

* plugins: drop dead code and hoist duplicated trust-boundary patterns

Cleanup pass over the P1 diff, no behavior change:

- Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus
  the now-vestigial `directories`/`signal` plumbing in `collectFiles`.
- Delete `resolveContainedPluginDirectory` (no callers).
- Delete `plugin-content-load-pool.ts`; it reimplemented the existing
  `mapWithConcurrency`, whose index arg also removes the pairing wrapper
  in `buildPluginList`.
- Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into
  the install-lockfile module; 11 sites hand-rolled these identically.
- Point the new reliability gate at the PR instead of gitignored docs
  paths, matching every other gate's link form.

* fix(plugins): retry plugin state renames on Windows AV/EPERM locks

Six plugin write paths (lockfile, provenance, current pointer, kill
list, marketplace cache, staged install dir) did a plain rename, so an
antivirus or indexer holding the target open surfaced as a failed
install. The repo already retries this hazard for issue #1507, but only
through a sync helper; these paths are all async.

Adds one bounded async retry + atomic write used by all six, and trims a
consent-provenance header that restated its own JSX.

* test(plugins): cover the Windows rename retry path

The retry loop shipped untested: both existing cases hit the non-retry path,
and the temp-cleanup test passed identically with the `finally` removed.
Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke.

Co-authored-by: Orca <help@stably.ai>

* fix(plugins): pin bundled plugin resources to LF

Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived
as CRLF and verify-packaged-plugin-resources rejected it — the packaged build
could never pass on Windows. Reproduced locally: CRLF yields the exact CI
error, LF verifies clean. Files are already LF, so nothing renormalizes.

Co-authored-by: Orca <help@stably.ai>

* test: guard the bundled-plugin LF pin against a CRLF checkout

The byte-hash mismatch only surfaced in Windows packaging CI. Assert the
.gitattributes pin and that a CRLF tree is rejected, so a regression fails
on any platform instead of waiting for a packaged Windows build.

Co-authored-by: Orca <help@stably.ai>

* ci: trigger packaged-build check on bundled plugin resource changes

The launch tree is byte-hashed during packaging, but no trigger path covered
it — so the CRLF fix for that check would not have re-run the check. Add the
resources, verifier and .gitattributes paths that can break packaging.

Co-authored-by: Orca <help@stably.ai>

* perf(plugins): rebuild the panel frame only when its baked theme values change

The revision keys the panel iframe, so every bump destroys the sandboxed
frame and its in-panel state. It counted root attribute mutations, but
--workspace-sidebar-live-width is written every rAF of a sidebar drag, so
dragging with a panel open blanked it ~60x/sec. Compare the two values the
shell actually bakes in instead.

Co-authored-by: Orca <help@stably.ai>

* test: stop pinning a plugin name in the CRLF guard

The CRLF case rewrites every launch file, so the reported mismatch is
whichever plugin sorts first. P2 adds theme plugins that sort ahead of
orca-navigation-shortcuts, which broke the assertion there.

Co-authored-by: Orca <help@stably.ai>

* style: drop stray blank lines left by the rebase resolutions

Both sides of the agent-hooks and orca-runtime conflicts contributed a
trailing blank, which oxfmt rejects. Whitespace only.

Co-authored-by: Orca <help@stably.ai>

* test(plugins): stop the startup budget failing on machine load

P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite
parallelism, so the gate flaked. Widen it to catch an order-of-magnitude
regression instead; the no-worker/no-plugin-code assertions are the real
guarantee. Verified a 400ms regression still fails.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>

* fix(tab-bar): keep tab menu items on one line and give every item an icon (#10882)

* fix(remote): accelerate terminal recovery on resume/online (#8255)

* fix(remote): accelerate shared-control and pane recovery on resume/online

Narrow #8255 onto current main after #9774: fire pending shared-control
reconnect timers and pane recovery backoffs on system resume and browser
online, without replacing the per-pane recovery state machine or reconnect
banner UX.

* test(remote): cover online and occluded-resume recovery triggers

* fix(remote): centralize recovery acceleration

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(mobile): unblock iOS releases and prepare 0.0.36 (#10888)

* fix(mobile): block iOS uploads below the last shipped App Store version

The closed-train guard looked up each candidate version's own App Store
record, but a version only gets one once it is submitted for review.
0.0.34 reached TestFlight and was never submitted, so it had no record,
nothing looked closed, and the patch-bump walk stopped there — while
0.0.35 had already shipped. Apple rejected the upload after a 24-minute
build (90186 closed train, 90062 needs a higher CFBundleShortVersionString).

Fetch the highest closed version once and treat everything at or below it
as closed, comparing semver numerically so 0.0.10 outranks 0.0.9.

Also read appVersionState alongside appStoreState: the latter is
deprecated in App Store Connect API 3.3 and renames the shipped state to
READY_FOR_DISTRIBUTION, so reading only the old field would silently find
zero closed versions once Apple stops populating it.

* chore(mobile): prepare 0.0.36

app.json sat at 0.0.32 while 0.0.35 shipped on the App Store, because
release versions are resolved on the runner and never committed back.
Close the four-version drift so the checked-in version matches reality
and the iOS release no longer depends on the closed-train walk to find
an open version.

Bump Android versionCode 8 -> 9 in the same commit: the version is shared
between platforms, and shipping 0.0.36 with the code that already shipped
for 0.0.32 produces an APK that cannot install over the released build.

* fix(relay): back off overloaded assignments (#10894)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* release: v1.4.160-rc.0

* perf(agent-status): strip terminal control bytes by run, not per character (#10866)

* perf(agent-status): strip terminal control bytes by run, not per character

stripTerminalControl built its result with a per-character `+=`, allocating a
fresh string for every retained character. The Command Code status detector
calls it four times per PTY chunk — the scan text, the chunk-boundary variant,
and both previous-text lengths — so an agent pane paid that on every write.

Control bytes are sparse in real output, so copy the spans between them instead:
2.3x-2.6x from 5 KiB to 106 KiB chunks. Output is byte-identical, checked
exhaustively over every string up to length 4 across a 13-symbol control/unicode
alphabet plus 200k random strings (224,831 inputs, 0 mismatches).

* docs(agent-status): condense the run-copy rationale comments

Review feedback: both comments walked through the implementation. Keep one line
of non-obvious rationale each, per the repo's comment guidelines.

Co-authored-by: Orca <help@stably.ai>

* test(agent-status): correct terminal strip benchmark

* test(agent-status): bound terminal strip benchmark

---------

Co-authored-by: Orca <help@stably.ai>

* perf(usage): yield with setImmediate, not a clamped setTimeout(0) (#10892)

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): restore link hover after mouseleave (#10903)

* fix(terminal): restore link hover after mouseleave

* test(terminal): verify mouseleave listener cleanup

* test(terminal): assert link hover listener wiring

* Update README downloads badge

* fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459) (#10893)

* fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459)

Resource Manager decided a session was an "orphan" from the absence of a
renderer binding, then force-killed it with no prompt. Absence of a binding is
not evidence a session is idle — during restore the binding map is legitimately
empty, and deferred SSH sessions never appear in it at all. Live agent sessions
were destroyed this way, losing unrecoverable work.

Three gaps, one rule: only positive evidence authorizes destruction.

- `pty:listSessions` dropped `agentSessionOwners` at the IPC boundary, so the
  renderer could not see the one fact that proves work is running. It now
  reports `hasAgentOwner`, typed once in `shared/pty-listed-session.ts` so the
  main handler, both preload surfaces, and the renderer cannot drift.
- The binding index ignored `deferredSshSessionIdsByTabId` — sessions restore
  knows are live on an SSH host but has not reattached. No other binding source
  can see them.
- The bulk-kill handler filtered sessions separately from the button's count,
  so the set killed could differ from the set advertised. Both now call
  `selectUnboundDaemonSessions`.

The single-row kill path had the same defect: it skipped confirmation whenever
`bound` was false. `requiresKillConfirmation` now also holds for agent-owned
sessions, and snapshot-derived rows carry ownership across from the daemon list
rather than reporting `false`.

* fix(resource-manager): distinguish unprovable ownership from proven absence

Adversarial review of the previous commit found it committed the same class of
error it was fixing: it collapsed "no agent owns this" and "this provider cannot
tell me" into one boolean `false`, and both destructive paths read that as proof.

A daemon generation below the claim protocol, an older SSH relay, or the
in-process local fallback all list no owners for a session that may well have
one. `pty.ts` already encodes the rule at :613 — "only providers that serialize
claims may make listing absence authoritative" — and the new IPC row ignored it.
So after upgrading with a legacy daemon still holding a live agent terminal,
bulk cleanup would have destroyed it: exactly #8459, one layer down.

`hasAgentOwner: boolean` is now `agentOwnership: 'present' | 'absent' | 'unknown'`,
derived via `providesAgentSessionOwnerListings`. Only `absent` authorizes
destruction, so `unknown` protects and confirms.

Second defect, found independently by four review lenses: the deferred-SSH
bindings reached the bulk selector but not `mergeSnapshotAndSessions`, because
the merge call site re-listed the binding fields instead of reusing the object.
A deferred SSH session therefore rendered `bound: false`, and its single-row kill
skipped confirmation while bulk cleanup correctly spared it. The call site now
spreads `resourceSessionBindings`, and a parity test fails if any binding field
is re-listed inline — the drift itself is now impossible to reintroduce quietly.

The e2e ownership assertion was also weak: it checked only that a boolean
arrived. It now asserts the exact arm, and that the live local provider reports
`absent` rather than `unknown`, so a degenerate all-unknown implementation fails.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* feat(usage): price Claude 5 family and GPT-5.6 token usage (#10822)

* feat(usage): price Claude 5 family and GPT-5.6 token usage

Claude Opus 5, Sonnet 5, Fable 5 and Codex gpt-5.6 sol/terra/luna were
absent from the usage pricing tables, so their turns aggregated tokens
but reported no estimated cost.

Rates from Anthropic and OpenAI published pricing. Sonnet 5 gets no
long-context tier: Claude 4.6 and later bill the full 1M window flat.

Sonnet 5 uses the standard $3/$15 rate, not the $2/$10 introductory rate
that runs through 2026-08-31 — the table has no date dimension.

* fix(usage): price the bare gpt-5.6 alias and assert Opus 4.5 separately

OpenAI routes the bare `gpt-5.6` alias to Sol, but only the explicit
`-sol` / `-terra` / `-luna` IDs resolved, so alias-recorded sessions still
reported no cost. Match it exactly rather than by prefix so it cannot
swallow the tier IDs or a future cheaper variant.

Also split the Claude 5 shadowing guard into per-model breakdown
assertions and add the missing Opus 4.5 fixture the test name claimed.

* docs(usage): note Sonnet 5 uses standard, not introductory, rates

* fix(editor): save floating workspace markdown files (#10985)

* fix(worktrees): resolve a two-host project by the worktree's own host (#10634) (#10986)

* fix(worktrees): resolve a two-host project by the worktree's own host (#10634)

A project registered on both a local host and an SSH host permanently poisoned
every one of its workspaces with "Workspace identity is ambiguous across hosts.
Refresh projects and try again." Refresh could never help: nothing was stale,
both host setups were valid and intentional. The error survived restarts.

The ambiguity was manufactured. `resolveExactWorktreeRoute` starts from a
worktree that already carries exactly one `hostId`, then throws that away and
asks `resolveIndexedRepoOperationRoute` which host owns the *repo* — a question
with two right answers once a project spans hosts. Only the project spans hosts;
each worktree never does.

Route resolution now filters repo setups to the ones matching the worktree's own
host before looking for a transport, so a two-host project resolves as cleanly as
a one-host project. Genuine ambiguity still returns `ambiguous`.

Second half: the error escaped as an *uncaught renderer error* because passive
background paths — unread marking, activity bumps — called a helper that threw.
Those callers now degrade: `trySettingsForWorktreeOwner` returns null, the
passive update is skipped with a warning, and local state stays consistent.
Explicit user actions still surface the error.

* fix(worktrees): cover every passive path and warn once for ambiguous owners

Adversarial review found the routing fix sound but its coverage thin: only
markWorktreeUnread had an ambiguous-owner test, so restoring the throw in
clearWorktreeUnread or bumpWorktreeActivity would have reproduced the uncaught
renderer error with the suite still green. Both are now covered, verified by
mutation.

bumpWorktreeActivity also skipped silently where the other paths warned. It now
warns — but once per workspace, not per event: activity bumps fire on every PTY
event, so an unbounded warn would flood the console for exactly the users already
hitting this bug.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* perf(runtime): remove timer clamps from cooperative yields (#10908)

* perf(runtime): remove timer clamps from cooperative yields

Renderer paste and input loops can schedule more than a thousand zero-delay timer yields for a maximum-size payload. Chromium clamps nested timers to 4ms, adding seconds of idle wall time.

Use MessageChannel tasks in renderer runtimes and setImmediate in Node while retaining a timer fallback for tests and unsupported environments.

* fix(runtime): preserve pacing and release yield callbacks

Adversarial review found that concurrent producers could retain resolved callbacks until global quiescence. Route renderer yields by token and delete each resolver before resuming its producer.

Keep timer pacing in terminal paste and accepted-write loops where SSH and local PTYs do not provide drain acknowledgement. Use the shared scheduler for the OpenCode scanner.

* Implement robust orchestration primitives and connected-server workers (#9925)

* fix(gitlab): render item descriptions and comments with document markdown variant (#9161)

Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: parallelize PR checks and accelerate Vite builds (#10989)

* ci: parallelize and accelerate PR checks

* fix(ci): make accelerated checks runtime-safe

* fix(ci): address review findings

* fix(ci): retry transient Electron downloads

* test(ci): cover Electron download retry limits

* fix(terminals): negotiate explicit close intent for paired runtimes (#10129)

* fix(cmd-j): focus the destination workspace's own terminal after a jump (#10695)

Co-authored-by: Orca <help@stably.ai>

* fix(jira): render issue description/comment images with lightbox (#8938)

* fix(jira): render issue images and open them in a lightbox

Jira ADF media nodes were dropped when converting descriptions/comments
to Markdown, so screenshots never appeared in the Tasks drawer.

Download image attachments with authenticated Jira API access, embed them
as data URLs on issue/comment detail loads, and add a viewport-centered
lightbox. Closing with X/Esc only dismisses the preview, not the issue sheet.

* fix(jira): open comment images in the same lightbox as description

Jira issue comments still used compact markdown, so screenshots rendered
but could not expand. Use the document renderer for comment bodies, add a
regression test for the expand control, and sync MarkdownImageLightbox
locale keys.

* fix(jira): harden inline image handling

* fix(jira): harden inline image discovery, escaping, and downloads

Address PR review findings: correct media-attachment pairing, Server/DC
attachment lookup base path, markdown-safe external URLs, wider HTML
discovery with gated alt fallback, concurrent downloads outside the API
semaphore, and a main-process attachment data-URL cache with lower caps.

* fix(jira): Option A multi-same-name attachments and post-map media warns

Fix discovery so repeated alts (image.png) get distinct attachment ids,
flush resolution warns after ADF mapping using attachment-only stats,
clear attachment cache on clearToken with epoch-guarded singleflight,
and add Server comment path plus release-before-binary regression tests.

* fix(jira): simplify comment media request skip condition

Only needingCount determines whether to skip the attachment metadata
request — htmlIds alone cannot produce a download without needing
media. Add type annotation for mediaAttrs for clarity.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>

* fix(hooks): drain POSIX hook stdin without PATH (#10885)

Reviewed with an independent reproduction on macOS and Ubuntu 20.04. Fixed five test files that asserted the old literal strings, and switched the reader to `command -p cat` so it also survives hosts without /bin/cat.

* fix(terminal): disarm stale TUI modes when a pane confirms return to shell (#9608)

A TUI killed hard (SIGKILL, OOM, crash) never restores the modes it armed. When
its parent shell survives, the emulator keeps mouse tracking, focus reporting
and Kitty keyboard flags on: every pointer move over the pane lands as typed SGR
motion reports at the prompt, and the doomed process burns CPU parsing the
motion firehose while it lives.

Orca's existing mode cleanups all hang off dead-PTY paths (hibernation kill,
daemon reattach), so an agent dying under a live shell crossed none of them.

Fire POST_REPLAY_REATTACH_RESET at the pane-foreground-agent tracker's confirmed
return-to-shell transition, next to the sibling stale-title cleanup. That
transition is gated on a real foreground-process read rather than the bare
OSC 133;D, because a full-screen agent's nested command shells leak their own D
onto the main PTY. The write goes through the replay guard so xterm's auto
replies cannot leak to the shell as input.

The reused constant already excludes ?2004l, so the bracketed-paste protection
the live shell re-arms at its prompt is preserved.

* fix(release): restore the Windows inner-binary signature gate (#6487) (#10719)

* fix(release): restore the Windows inner-binary signature gate

electron-builder 26.9+ dropped the bundled 7zip-bin package, so the gate's
hardcoded node_modules/7zip-bin path stopped resolving in 1d2cd33c83. The
gate is fail-open, so it swallowed the error and 11 releases shipped with
no signature verification and an evidence artifact that looked clean.

Resolve 7za through app-builder-lib's toolset instead, and always record a
verdict so a degraded gate can't pass for a healthy one.

Refs #6487

* test(release): make the signing-gate structural tests assert executed code, not text

The round-2 harness matched /\bthrow\b/ and /\bcatch\b/ against raw block text, so
the word satisfied the assertion wherever it appeared. Downgrading the resolver
throw to `Write-Host "...would normally throw..."` — the exact silent fail-open
this PR exists to kill — left all 11 tests green.

Every span is now classified once (code / string / comment) by the same walk that
pairs braces, and assertions run against the string-and-comment-blanked view.
Blanking preserves length, so indices still line up across views.

Also re-anchors the catch-ordering test: `blockAfter(step, '} catch {')` picked
the first catch in the step, which stopped being the gate's own once the
persistence helpers grew theirs — moving the policy throw inside the try was
passing again.

Co-authored-by: Orca <help@stably.ai>

* test(release): pin the evidence filename the gate writes to the one the upload collects

The upload step is `if-no-files-found: ignore`, so renaming the evidence file on
one side and not the other ships a green run whose artifact silently omits the
verdict — the same silent-degradation class this PR exists to close.

Co-authored-by: Orca <help@stably.ai>

* fix(release): preserve 7za resolver failures

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(shortcuts): gate Cmd/Ctrl+N folder-workspace jumps on path status (#10748)

Reviewed with an independent reproduction, including a live-app check. Replaced tests that mocked the module containing the fix, and reused the existing workspace activator instead of adding a 4th copy of the dispatch.

* fix(pty): strip inherited Claude child-session stamps at spawn (#9961)

An Orca GUI or daemon launched from inside a Claude Code session inherits
CLAUDE_CODE_CHILD_SESSION / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_BRIDGE_SESSION_ID.
Every spawn path spreads the host's process.env, so each terminal Orca opens is
marked a nested Claude child and Claude silently disables transcript
persistence — real sessions stop writing on-disk history with no visible error.

Older-protocol daemons are deliberately preserved across upgrades and the
auto-updater relaunch inherits the previous app's env, so one contaminated
launch propagates through subsequent updates.

Orca never sets these variables, so an inherited value is always poison. Add a
deny constant plus an inherited-only filter merged into envToDelete at both pty
spawn call sites, keeping a stamp explicitly passed in args.env. Unlike the
agent-hook keys this is not gated on isDaemonHostSpawn, because the local
provider and the relay host spread their own process.env too.

Review fixes: mergePtyEnvDeletions is now variadic (the nested form passed a
`string[] | undefined` intermediate into a `readonly string[]` parameter and did
not typecheck); coverage extends to the runtime-controller spawn path, the local
provider, and the SSH route, whose exact spawn-options assertion had to be
updated because envToDelete is no longer ever undefined.

* fix(sidebar): spin the worktree dot while Claude Code is thinking (#9040) (#10684)

Co-authored-by: Orca <help@stably.ai>

* fix(cmd-j): restore focus when issue match routing declines (#11010)

* fix(markdown): render task continuations as text (#11008)

* fix(dashboard): stop the agent icon shrinking on long card titles (#11001)

A bare <svg> flex item shrinks with its row, so kanban cards and the
terminal dialog rendered a squashed ~9px agent icon whenever the worktree
name overflowed. Wrap both in the shrink-0 span every other surface
already uses.

* fix(runtime): refuse SSH hosts in project setup instead of acting locally (#10799)

* fix(runtime): refuse SSH hosts in project setup instead of acting locally

projectHostSetup.clone and .setupExistingFolder threaded executionHostId all
the way down but never used it for routing: cloneRepo runs a local mkdir plus
a local gitSpawn, and addRepo probes the path with existsSync/statSync. An
`ssh:` host therefore cloned and validated on the *local* machine and then
registered the result as living on the SSH host.

It only failed loudly here because the remote path did not exist locally. With
a plausible destination the clone succeeds and writes a setup record pointing
at the wrong machine.

Nothing legitimate sends `ssh:` to these RPCs: the renderer maps every ssh
host (including ephemeral-VM `ssh:runtime-ssh-*`) to the desktop IPC path,
which dispatches to addRemoteRepoFromPath/cloneRemoteRepo, and the IPC handler
symmetrically rejects `runtime:`. Only the CLI can reach here with `ssh:`.

Fail closed until the RPC learns to route through the SSH providers.

* test(runtime): make the SSH guard test observe the corruption it names

The test asserted `gitSpawn` was never called and no repo was registered, but
neither assertion could fail. `/home/brennan` is unwritable on macOS, so the
pre-guard clone died at `mkdir` before reaching `gitSpawn`, and
`/home/brennan/orca` failed `isGitRepo` before reaching `addRepo` — the exact
side effects under test were unreachable either way. `rejects.toThrow` also
aborted the test before those lines ran.

Use a real temp destination and a real temp git repo, await both calls via
`.catch`, and assert the side effects before the wording. With the guard
disabled the test now fails on `gitSpawn` being called once with a real
`git clone`, and on a repo registered stamped `executionHostId: 'ssh:openclaw'`
— the silent local-clone-recorded-as-remote defect itself. `gitSpawn` is
stubbed so a regression records the call instead of hitting the network.

Also document the SSH restriction on `project setup-existing-folder`, which the
guard now rejects. `setup-clone` already carried that note; its sibling did not.

* fix(browser): localize degraded cookie import warnings

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: grboy6770 <2000grboy@gmail.com>
Co-authored-by: ye4241 <6803102+ye4241@users.noreply.github.com>
Co-authored-by: Wooseong Kim <2222333+innocarpe@users.noreply.github.com>
Co-authored-by: SoonKim <sztolabs@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Ken Fukuyama <kenfdev@gmail.com>
Co-authored-by: SilvaViniF <108766450+SilvaViniF@users.noreply.github.com>
Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Anishek Kamal <anishekkamal@gmail.com>
Co-authored-by: BingZ <zzb@gxsmjx.com>
Co-authored-by: Tom <debres.bot@gmail.com>
Co-authored-by: Tom de Bres <tomdebres@users.noreply.github.com>
Co-authored-by: Andres Van Reepingen <andres.vanreepingen@student.ucll.be>
Co-authored-by: Andres Van Reepingen <andres.vanreepingen@datacamp.com>
Co-authored-by: xsacdw <251566507+xsacdw@users.noreply.github.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: reopard007 <reonce@gmail.com>
Co-authored-by: xsacdw <xsacdw@gmail.com>
Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Nemo <imshuazi@126.com>
Co-authored-by: nolainjin <nolainjin@gmail.com>
Co-authored-by: David Anderson <215816+akapug@users.noreply.github.com>
Co-authored-by: Vansh <77244334+vansh2408@users.noreply.github.com>
2026-07-27 16:40:29 -07:00
Neil 8b154d686c perf(runtime): remove timer clamps from cooperative yields (#10908)
* perf(runtime): remove timer clamps from cooperative yields

Renderer paste and input loops can schedule more than a thousand zero-delay timer yields for a maximum-size payload. Chromium clamps nested timers to 4ms, adding seconds of idle wall time.

Use MessageChannel tasks in renderer runtimes and setImmediate in Node while retaining a timer fallback for tests and unsupported environments.

* fix(runtime): preserve pacing and release yield callbacks

Adversarial review found that concurrent producers could retain resolved callbacks until global quiescence. Route renderer yields by token and delete each resolver before resuming its producer.

Keep timer pacing in terminal paste and accepted-write loops where SSH and local PTYs do not provide drain acknowledgement. Use the shared scheduler for the OpenCode scanner.
2026-07-27 12:21:14 -07:00
Fazal KadivarandCursor 34caad787c fix(browser): unstick Ctrl+Tab switcher when opened from a focused browser guest (#9966)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 23:48:07 -07:00
NeilandOrca aab112933e Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai>
2026-07-23 18:35:31 -07:00
Neil 8f40ddf328 fix(memory): bound OOM-prone accumulators (#10179) 2026-07-23 06:22:56 -07:00