mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
d1127b9939726bbdb557a14569dcf63db246c64e
178
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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) |
||
|
|
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 |
||
|
|
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. |
||
|
|
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) |
||
|
|
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. |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
3c8410d927 |
Revert "fix(browser): route every cookie-import write through CDP identities …" (#14942)
This reverts commit
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
0f7707b146 |
fix(browser): align anti-detection identity signals (#14685)
* fix(browser): align anti-detection identity signals * fix(browser): hide native Chrome global for Firefox |
||
|
|
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. |
||
|
|
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. |
||
|
|
cd6114ab7e | fix(browser): acknowledge paired tab before navigation (#14402) | ||
|
|
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) |
||
|
|
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 |
||
|
|
e97faa1650 | fix(browser): sanitize reserved Windows download names (#14145) | ||
|
|
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. |
||
|
|
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)
|
||
|
|
fc7c2a1cd5 | fix(browser): preserve partitioned Google cookies on import (#14086) | ||
|
|
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 |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
e671c64dea | refactor(utf8): centralize byte and chunk boundaries (#13445) | ||
|
|
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 |
||
|
|
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> |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
+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 |
||
|
|
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. |
||
|
|
34caad787c |
fix(browser): unstick Ctrl+Tab switcher when opened from a focused browser guest (#9966)
Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
aab112933e |
Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
8f40ddf328 | fix(memory): bound OOM-prone accumulators (#10179) |