mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
f97503580966560342db8cdc7fa5054efa9e1b39
220
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
|
||
|
|
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) |
||
|
|
3fca1d1648 |
fix(linear): unbound list-issues by default, surface truncation, bind cursor workspace (#15824)
Fixes STA-5076. list-issues capped at 50 by default and hard-clamped at 250, with hasMore buried under result.meta and no stderr warning for --json, so a page that stopped early read as a complete answer. Omitting --limit now walks Linear's pages until they run out (meta.limit is null), and --limit <n> is the only cap, paging past Linear's 250-per-request maximum to reach it. result.truncated sits next to result.issues and is set only when a cap actually held results back; human output prints "truncated: showing N". The read still has to fit the CLI's 60s RPC budget, so a 20s wall-clock deadline and a 200-page ceiling stop the walk early and report truncated with a continuation cursor rather than failing the command. Also: - issued --cursor values bind the resolved workspace, so call -> nextCursor -> call works without --workspace; raw Linear cursors still need one and now carry nextSteps - issued cursors whose payload smuggles back `all` or an empty workspace are rejected at decode, since either would widen the read past the bound workspace - JSON issue rows carry priorityLabel (none/urgent/high/medium/low), matching orca linear priority set - truncated and priorityLabel are optional on the wire, so a host that predates either is not read as "complete"; readers fall back to meta.hasMore - the truncation line prints the rows actually rendered, so a remote result with no meta.returned cannot print "showing undefined" |
||
|
|
471bc9d8ce | Ship the WSL transcript helper with the Windows relay (STA-4831) (#15529) | ||
|
|
a61b39a9a6 |
fix(runtime): stamp a runtime's own project setups as local, and report remote status about the remote (STA-4792) (#15376)
* fix(runtime): stamp a runtime's own project setups as local, and report remote status about the remote (STA-4792) Two independent frame-of-reference bugs, both from code describing one machine while labelled as another. #15366 — projectHostSetup.* persisted the caller's host id verbatim. Those `runtime:<environment-id>` ids are minted by the calling client's own pairing store, so they name a machine only relative to that client. A client sending one is addressing this runtime, and runtimes do not proxy these calls onward, so the host it names is us. Storing the client's spelling made one machine look like a different host to every other client, hid its rows from them, and defeated the (projectId, hostId) duplicate check — two laptops paired to one server each created their own setup for the same checkout. Re-spell it as `local` at the RPC boundary. Rows written earlier keep their old stamp; readers already project `local` back to `runtime:<their-id>`, so the client-visible model is unchanged and no ids are rewritten. STA-4792 defect 4 — `status --environment <name>` hardcoded app.running:false to mean "no desktop on THIS machine" while every other field in the same object described the target, including a desktopWindowStatus echoed straight from it. The result contradicted itself and read as "that run was headless" when the remote GUI was up. `app` now describes the target, keyed off the one window status that requires a live renderer, and the result names its own subject so the frame can't be misread again. The remote pid is not knowable, so it stays null. STA-4792 defect 2 gets a regression test rather than a fix: routing already made the client remote, which is what stops a Windows destination being joined to the local cwd. The test pins the exact reported invocation. * fix(status): share the remote app projection with the SSH host passthrough, and name the version gap on project host setup Two review follow-ups. The SSH host passthrough answered `app.running: true` unconditionally for the Orca host a caller reached over SSH, claiming a desktop app even for a headless `serve`. That is the same defect as the paired-server path, one transport over, so the projection moved to shared and both now answer the question the same way. `--host runtime:<id>` routes project commands to a paired server, which means a client can reach a server that predates project host setup without meaning to. That answered a raw `method_not_found`, which reads as an Orca bug rather than a version gap; the CLI now names it the way the desktop already does. Reverted a third change: making the persistence duplicate check treat `local` and `runtime:*` as one machine. That assumption holds at the RPC boundary, where a `runtime:` host means the runtime being addressed, but not in the store, which also records independent provisioning metadata for machines that are not itself. An existing test covers exactly that, and it was right. The duplicate convergence therefore stays bounded to rows written after the normalization. |
||
|
|
3c676ed13d | fix(ssh): validate hashed known_hosts fields through one strict base64 decoder (STA-4717) (#15345) | ||
|
|
24e662adc1 |
feat(ssh): verify host keys, and restore panes correctly across a reconnect (#14844)
* docs(ssh): design for real host key verification (STA-4319)
Today's ssh2 verifier records a fingerprint and returns true — every host key is
accepted, with no known_hosts consult and no change detection anywhere in
src/main/ssh/. Scope is per-connection, so exec, SFTP, port forwarding, the
watcher and relay deploy all ride that one unverified handshake, and the
ProxyJump path puts the final hop — the topology most likely to cross untrusted
network — on ssh2 specifically.
Decisions worth calling out:
- Read the user's known_hosts as a trust source but NEVER write to it. That file
is shared with every other SSH tool on the machine; appending means line
endings, permissions, concurrent writers and a corruption blast radius well
beyond us. Accepted keys go to our own per-target store. Reading theirs is also
the entire migration story: most developers already have their hosts there.
- Mismatch is scoped to the SAME key type. A host with only an RSA entry that
presents ed25519 is unknown, not changed. ssh2 negotiates ed25519 first, so
without this we would fire a change-of-key alarm at nearly every existing user
on their first upgraded connect — training them to dismiss the one warning that
is supposed to mean something. Flagged in review as the decision I am least
sure of; a downgrade-vector argument against it is being tested.
- Changed key hard-fails with no override button; recovery is a separate explicit
action, offered only when OUR store is what disagreed, because forgetting our
record cannot unblock a known_hosts conflict.
- Background reconnects deny rather than prompt. A dialog the user cannot place
in context only teaches click-through.
Two traps are documented because either would make the fix silently do nothing:
an async verifier returns a Promise, which ssh2 reads as truthy and accepts
immediately; and the existing test mock invokes hostVerifier with one argument
and ignores the return, so it would pass against a verifier that never decides.
Design only — no behaviour change. The doc is added to the tracked-reference
allowlist in .gitignore alongside the other docs/reference entries.
* docs(ssh): revise the host key design after security and migration review
Three things the reviews changed, kept visible rather than quietly edited out.
THREAT MODEL WAS WRONG IN THREE PLACES. Jump hosts are not the worst case — they
are already safe: shouldUseSystemSshTransport branches on exactly the inputs
resolveEffectiveProxy does, and attemptConnect returns after the system probe, so
ProxyJump goes through OpenSSH and is verified. Agent forwarding was overstated
(gated on the user's ForwardAgent). Credential theft was understated: any auth
error counts as agent fallback, so a MITM walks the user to the password AND
private-key passphrase prompts, and cachedPassword replays without prompting. The
relay claim was backwards — the attacker owns their own machine; the real impact
is the return direction, where they become the host our workspace trusts.
TYPE SCOPING IS A DOWNGRADE VECTOR WITHOUT ALGORITHM ORDERING. This was the
decision I flagged as least certain and asked to have argued both ways. OpenSSH
is safe only because order_hostkeyalgs() puts known types first and RFC 4253
gives the client's order priority. ssh2 negotiates ed25519 first regardless, so
an attacker who cannot forge the RSA key on file just presents ed25519 and gets a
friendly first-contact prompt instead of a hard failure. Keep scoping, but set
algorithms.serverHostKey to lead with the types on file — and add a sixth
outcome for 'unknown type, known host', which must never read as first contact.
SHIP THE DEFENCE BEFORE THE DIALOG. Startup restore fires eager connects for all
targets in parallel with a 15s timeout while a prompt would live 120s; ephemeral
VM targets present a new key every launch; paired-web connects run on the host
desktop, so the dialog opens on someone else's screen. Phase 1 is therefore no
modal at all: consult known_hosts and our store, match connects, unknown persists
with accept-new semantics, mismatch and revoked hard-fail. That is the whole MITM
defence with none of the migration risk.
Also folded in, verified live against OpenSSH 10.2p1: the without-port fallback
(bracketed lookup first, then bare, where the second pass can only yield match or
unknown — otherwise a bare line plus a non-default port produces a spurious
prompt); hashed entries hash the candidate form; multiple files union; a
cert-authority line does not match a plain key. IPv6 and bracket parsing moved
INTO scope — that is a parser requirement, not a scope call, and getting it wrong
produces the prompt-training harm the design exists to avoid.
* feat(ssh): parse and match OpenSSH known_hosts
The matcher half of STA-4319. No behaviour change yet — nothing calls this.
Hand-rolled because no maintained JS implementation exists, and written against
behaviour observed from OpenSSH 10.2p1 rather than inferred from the man page.
Three of those behaviours a reasonable reading gets wrong:
- A non-default port is TWO ordered lookups, not one candidate set: '[host]:port'
first, then bare host ('checking without port identifier' in ssh -v). The
fallback pass can only yield match or unknown — OpenSSH downgrades a wrong key
there rather than reporting a change. Collapse them and anyone holding a bare
line who connects off-port gets a spurious first-contact result; treat the
fallback as authoritative and they get a false change-of-key alarm.
- Revocation resolves in its own pass so the verdict cannot depend on line order.
Verified both orderings.
- A cert-authority line never matches a plain host key; it only validates
certificates. A normal line alongside it still decides.
Mismatch is scoped to the same key type, and a host known by a DIFFERENT type
returns unknown-type-known-host rather than plain unknown — an attacker who
cannot forge the key on file must not get a friendly first-contact result by
presenting another type. That outcome is only half the defence; the other half
(leading serverHostKey with known types) lands with the wiring.
47 tests from vectors executed against real sshd, including ssh-keygen -H hashed
entries. Each of six mutations reddens it: collapsing the passes, letting the
fallback report mismatch, dropping type scoping, resolving revocation in line
order, honouring an unrecognised marker, and skipping the blob/type agreement
check.
* feat(ssh): decide what to do with a presented host key
The policy half of STA-4319, kept separate from the ssh2 wiring so it is testable
without a handshake and injected rather than importing its sources, so a test
states its own trust state instead of writing files.
Phase 1 ships no dialog — a test asserts the decision is never 'prompt'. Startup
restore opens every previously-active target at once, ephemeral VM targets would
ask every launch, and paired-web connects run on the host desktop where the
dialog would appear on someone else's screen.
Ordering that matters: revocation outranks everything including
StrictHostKeyChecking=no, because a revoked key is a statement that this key is
known-bad rather than merely unrecognised. known_hosts is named before our own
store on a change, because its remedy (ssh-keygen -R) is the one that also
unblocks ssh and git — pointing at a remedy that cannot work is worse than none.
Two carve-outs with reasons: an ephemeral runtime target accepts WITHOUT
recording, since a fresh VM presents a new key every launch and a stored record
would accumulate per launch and eventually read as a spurious change; and when
ssh -G ran on the HOME-divergent path that suppresses /etc/ssh/ssh_config, an
unknown host is denied, because a site-wide policy may forbid it and being laxer
than ssh is the one outcome that is never acceptable.
Rejection text deliberately avoids 'authentication failed' and 'permission
denied': the reconnect ladder classifies on those substrings, so a denial phrased
that way is retried forever against a decision that will never change. Pinned by
a test.
* feat(ssh): build the host key verifier and the algorithm order that makes it safe
Still not wired into the handshake — that lands next. This is the piece that
turns a decision into an ssh2 callback, plus the half of the design that is easy
to forget because it lives in a different config field.
The verifier MUST be a plain function returning undefined. ssh2 does
'const ret = verifier(key, verify); if (ret !== undefined) verify(ret)', so an
async function returns a Promise — neither undefined nor falsy — and ssh2 accepts
the key immediately while ignoring whatever the callback later decides. Making
this async would silently restore exactly the accept-everything behaviour the
module exists to remove, so a test asserts the return value is undefined.
orderServerHostKeyAlgorithms is what makes type-scoped matching safe rather than
a downgrade. RFC 4253 gives the client's algorithm order priority, so leading
with the types we already hold for a host denies a server the choice of
presenting some other type to convert a hard failure into first contact. Without
it, an attacker who cannot forge the key on file just offers a different
algorithm. Revoked entries never contribute to that order.
Also fails closed on two paths that would otherwise hang or over-trust: a key
whose own length-prefixed header cannot be read is refused rather than reasoned
about, and a throw from any dependency denies, because ssh2 may not catch an
exception raised inside the verifier and the handshake would hang instead of
failing.
18 tests. Includes the two negative cases that matter — first-contact keys are
recorded, but keys we already know, rejected keys, ephemeral runtime targets and
a lax StrictHostKeyChecking are not.
* fix(ssh): promote every RSA signature algorithm for a known ssh-rsa key
A known_hosts entry names the KEY type, which is not the negotiated ALGORITHM
name. One ssh-rsa key is offered as rsa-sha2-512, rsa-sha2-256 or ssh-rsa
depending on the signature algorithm, so matching the literal name only would
leave a host we know by RSA ordered behind ed25519 — precisely the ordering this
function exists to prevent, and precisely the population (RSA-era known_hosts
entries) it was written for.
Verified from ssh2's own negotiation while wiring this: kex.js iterates the
CLIENT list and takes the first entry the server also offers, so client order
does decide, as RFC 4253 says. ssh2's default order leads with ed25519 and places
the RSA algorithms fifth through seventh.
* fix(ssh): verify host keys instead of accepting every one (STA-4319)
The actual fix. ssh-connection's verifier recorded a fingerprint and returned
true, so every ssh2 connection accepted every host key — no known_hosts consult,
no change detection. It now consults the user's known_hosts plus our own store
and refuses a changed, revoked or unverifiable key.
Phase 1 by design: no dialog. Unknown hosts are accepted and recorded
(accept-new semantics), because startup restore opens every previously-active
target at once, ephemeral VM targets present a new key each launch, and
paired-web connects run on the host desktop where a prompt would appear on
someone else's screen. The MITM defence lands now; the prompt is Phase 2.
Also sets algorithms.serverHostKey to lead with the types already known for the
host. Without it the type-scoped matching is a downgrade — an attacker who cannot
forge the key on file just presents another type and turns a hard failure into
first contact. Verified from ssh2's kex.js that the client list decides.
Denial replaces ssh2's generic handshake error with the specific reason, because
the reconnect ladder cannot distinguish a generic failure from a transient fault
and would retry forever against a decision that will never change.
An unreadable trust store degrades to known_hosts only rather than failing the
connect: a changed key is still refused, and a host trusted only by us falls back
to first contact and is re-recorded, reaching the same decision.
The ssh2 mock now uses the callback form and aborts the handshake on denial. As
written it called hostVerifier(key) with one argument and ignored the result, so
it would have passed against a verifier that never decides — flagged in the
design as a mock that had to change, not a test to quietly rewrite. Two new tests
pin the wiring rather than the module: an unidentifiable blob is refused, and a
well-formed key is accepted.
Note for review: commit
|
||
|
|
a7f1653415 |
fix(worktrees): keep retirement tombstones across project and SSH target re-add (#14917)
* fix(worktrees): keep retirement tombstones across project and SSH target re-add Generated workspace names are retired so a name is never reissued onto a cwd that still holds another workspace's Claude/Codex history. Two re-add paths lost that record. STA-4449 (local): retirement was stored only under `repo.id`. Removing a project deletes that row and re-adding the same path mints a new id, so the new repo starts with an empty registry. The on-disk backfill normally re-seeds local repos, but it cannot recover a name whose only surviving evidence is a Codex rollout JSONL — those are deliberately not scanned — so a name spent under Codex with its workspace directory gone came back. STA-4491 (SSH): the second, path-derived copy embedded the SSH target row id. Row ids are minted fresh on every re-add, so `ssh:ssh-old:...` became `ssh:ssh-new:...` and `reassignSshTargetId` migrated other carrier state but not the retirement namespaces. Keying the store on the namespace instead of `repo.id` was rejected in `nestWorkspaces`, `worktreeBasePath` and `repo.path`, so a settings toggle would orphan every retirement at once. `repo.id` therefore stays primary and the path-derived namespace stays a mirror — a settings toggle loses the mirror but keeps the repo row, a re-add loses the repo row but keeps the mirror, and reads union both. - Mirror local repos into the namespace too, not just remote ones. - Key the namespace's host half on the SSH endpoint (host+port+username), the thing that actually decides which filesystem a path lands on, instead of the target row id. Reads also accept the pre-identity key so an upgrade keeps tombstones it already wrote, and `reassignSshTargetId` re-keys the rest. - Cap the namespace map, which by design outlives the repos that wrote it and so has nothing to prune it per repo. Endpoint identity is extracted from ssh-target-readoption.ts, which already compared these fields for exactly the same reason, so re-adoption and retirement cannot drift apart. * fix(worktrees): copy shared SSH endpoint retirements instead of moving them An endpoint identity is not owned by the target row that rotates: nothing dedupes SSH targets by host|port|username, so a second live target can still resolve to the same host. Moving the bucket stripped that target's tombstones and reissued a path whose agent history is still on disk. Row-id identities stay a move — reassignment leaves nothing pointing at them. * fix(worktrees): carry retirement mirror across in-place SSH endpoint edits Config sync rewrites host/port/username on the existing target row and a runtime-owned target takes a fresh address from every provision, both keeping the row id. No re-adoption runs, so nothing carried the endpoint-keyed mirror across and it stranded — strictly worse than the pre-change key, which was the row id and was invariant under these edits. Also bound the map after a migration: a retained source bucket grows it, so the cap has to be applied there too, and compare registries by membership rather than size so an uncompacted destination cannot trade a folded name for a new one and read as unchanged. * fix(worktrees): skip retirement migration for on-demand runtime targets An on-demand VM is discarded between provisions, so its fresh address reaches an empty filesystem and a reissued name collides with nothing. Migrating there would spend names against history that no longer exists, and because each provision mints another address it would add a namespace bucket per run, evicting the real tombstones of local and ordinary SSH repos. * fix(worktrees): stop the namespace cap evicting what a migration just wrote Two defects with one root cause. Assigning to an existing key leaves it in its original insertion slot, so a merged destination kept the oldest position and the trim deleted the bucket it had just enriched. Retained source buckets are older than the destinations a copy appends, so at the cap the trim removed exactly the sources the copy existed to keep — silently turning it back into a move. The trim now exempts the keys the migration wrote or deliberately kept. Also stop on-demand runtime workspaces writing namespace mirrors at all: each provision reaches a discarded filesystem under a fresh address, so the entry can never be read back and only spends a capped slot that a local or SSH project needs. The repo-id row still records the name for the live session. * fix(worktrees): re-insert migrated namespaces so the cap cannot undo a migration Exempting keys from the trim protected them for that one call and no other. A merged destination keeps its original insertion slot, so it sat at the front of the eviction queue and the next unrelated retirement write dropped it — losing both the migrated name and the name the destination already held, on a host that had just been re-added. Re-insert what the migration writes instead, the same discipline the ordinary writer already follows, so insertion order reflects use. That also removes the exemption, which could otherwise leave the map stuck at twice the cap until one later write evicted the whole excess at once. Corrects the runtime-gate comment as well: the mirror is unreadable after the next provision, not immediately, so a remove/re-add inside one provision is a real if narrow loss. * fix(worktrees): refresh a retained namespace source even when its merge adds nothing Replacing the trim exemption with re-insertion narrowed the protection: the exemption covered every retained source, the re-insertion only covered sources whose merge actually wrote. A copy whose destination already held the same names was then neither re-inserted nor exempt, so the migration's own trim evicted the shared source bucket ahead of hundreds of untouched ones — losing the tombstones of a live sibling target still on that endpoint, which is what copying exists to prevent. A move's destination gets the same treatment: deleting the source makes it the only remaining copy, so it has been used. Both are order-only and deliberately do not set the changed flag, keeping an import that moved nothing from scheduling a save. |
||
|
|
60805f5c45 |
fix(agent-status): preserve restored child provenance (#15082)
* fix(agent-status): preserve restored child provenance * fix(agent-status): preserve restored completion context * fix(agent-status): retain child boundary across OSC |
||
|
|
be07b43a2b | fix(orchestration): enforce honest recipient routing (#14964) | ||
|
|
8ca4ed945e |
feat(terminal): report execution host and listing scope in terminal list (#14973)
* feat(terminal): report execution host and listing scope in terminal list `orca terminal list` returned rows with no host identity and no statement of what the listing covered, so a scoped listing that saw nothing read as "nothing exists anywhere" — an agent reported a live remote worker dead. Each row now carries an optional `executionHostId` derived from the PTY id (SSH and paired-runtime ids embed their owner), and the result carries an optional `hostScope` naming the hosts covered and the known hosts skipped. Both are surfaced in `--json` and in the human-readable CLI output, where an absent field renders as `unknown` rather than `local`. Both row builders route through one resolver, so the rule lives in one place. * fix(terminal): preserve unverifiable host scope * fix(terminal): fail closed on unverifiable hosts * test(terminal): name unverifiable scope explicitly * perf(terminal): keep graph hydration host scans narrow * fix(terminal): reject blank foreign host owners * fix(terminal): validate inferred inventory hosts * fix(terminal): preserve paired folder host scope * fix(terminal): keep inventory host inference typed * fix(terminal): disclose paired folder hosts |
||
|
|
9c4627d1c6 |
Refactor: split GitHubItemDialog into lifecycle-organized modules (#14931)
* refactor: split GitHubItemDialog.tsx under 400 lines No intentional behavior change. * refactor: group github-item-dialog into lifecycle folders Reorganize the 50 flat files under src/renderer/src/components/ github-item-dialog/ into six lifecycle folders: load-item-details/ shared types, both caches, fetch/settle, state badge open-dialog/ dialog shell, headers, body, tabs, link copy discuss-item/ conversation tab, comments, composer, timeline edit-item-fields/ GH edit section, labels, assignees, status inspect-pull-request/ combined diff viewer, checks tab land-pull-request/ PR actions, merge menu, reviewers No intentional behavior change. All 50 files moved verbatim; the only edits are relative-import specifiers (sibling paths plus a depth bump for ../../../../shared) and the hardcoded module paths in the two source-boundary tests. Import graph stays acyclic: zero mutual folder pairs, no file importing 4+ sibling folders, no dest file importing the public barrel, and no per-folder index barrels. * refactor: split item references and improve diff-viewer remount logic - Break down full `GitHubWorkItem` props into discrete `itemId`, `itemNumber`, and `itemRepoId` in mutation and action functions to prevent over-memoization of callbacks and improve dependency clarity. - Extract `getPRFilesCombinedDiffSignature()` and use it as a component key to safely remount the diff viewer when the PR revision changes, replacing generationRef tracking. - Add `getKeyedCheckAnnotations()` and `getKeyedCheckJobs()` to generate stable, collision-resistant keys for check arrays that may contain duplicates. - Consolidate interpreter timeouts into a single `SPAWNED_INTERPRETER_TIMEOUT_MS` constant and apply it via describe options rather than per-test values. * refactor: improve github-item-dialog repo context and i18n coverage - Add repoId prop to ConversationTab for explicit repo context override - Internationalize UI strings in diff viewer and PR action components - Improve error handling with cache rollback and guard cleanup on sync failure - Enhance cache key validation for cross-window invalidation by repoPath - Add repository access validation before rendering diff viewer - Fix cross-platform issues: skip symlink test on Windows, normalize CRLF in test assertions * Refactor check button i18n key and update text - Replace hash-based key with semantic name for maintainability - Change button label to "Open in browser" for broader context |
||
|
|
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. |
||
|
|
2100fb2553 |
fix(runtime): cap remote git.diff and file previews at the transport budget (#14160)
* fix(runtime): cap remote git.diff and file previews at the transport budget A remote or mobile user who opens the diff of a large image loses their whole WebSocket, not just that request: the E2EE channel closes with 1013 when a reply exceeds the 4 MiB outbound envelope. Two producers can exceed it unaided. git.diff/branchDiff/commitDiff cap text with MAX_RENDERED_DIFF_COMBINED_CHARACTERS (6M chars) -- a *renderer* budget that sits above the transport limit -- and return base64 for previewable binaries bounded only by MAX_GIT_SHOW_BYTES, so a 10 MiB PNG changed in place is ~26.7 MiB in one envelope. files.readPreview inlines base64 up to 10 MiB, and mobile calls it for every image tab. Both now measure against a budget derived from the outbound limit. The check sits in orca-runtime-git.ts, downstream of the dedupe and of both the SSH-provider and local branches, so a payload forwarded verbatim by an old relay is covered by the same code and src/relay needs no change. Local and in-process callers pass no budget and keep full fidelity. Measuring raw bytes would not work, which is the whole reason this needs a module. JSON escaping turns one control byte into six (\u00XX), and binary-buffer.ts sniffs only for NUL in the first 8 KiB -- so a NUL-free file of 0x01-0x1f bytes is classified as *text*, would pass a raw-byte cap, and would then blow the envelope. The budget is escape-aware, with a three-branch fast path that keeps normal diffs at two native byteLength calls and scans only the ambiguous band. The SSH branch of readFileExplorerPreview had the same raw-vs-escaped gap: its stat gate sizes base64 binaries, but text crossed unbounded. It now honours the same decoded-text limit the local branch already enforced. No wire change: GitDiffResult is untouched -- no third kind, no new field. Old clients see an error for one request instead of a dropped connection. diff_too_large joins the structured passthrough codes and lands on an existing error arm in both mobile consumers and the desktop remote path; file_too_large was already handled on both. Instruments the 1013 close, which nothing measured before, so the incidence this cap is meant to drive to zero is finally observable. `emitter` separates a producer size bug from a wedged link. Known regression: remote image previews between ~3.096 and ~3.146 MB now return file_too_large. They only intermittently worked before -- above ~3.0 MB they killed the socket -- so this trades intermittent connection loss for a consistent error. Test: 10281 passed in src/main/runtime + src/shared + src/main/git; mobile 3427 passed. Each of the six budget-enforcement sites is independently mutation-killed. Escaping fixtures cover newline-dense, control-char, CJK, lone-surrogate and base64 content against native JSON.stringify. tsc clean for node, web and cli; oxlint clean. Co-authored-by: Orca <help@stably.ai> * fix(runtime): harden remote reply transport budgets * test(runtime): cover desktop remote preview budgets * test(runtime): close telemetry review gaps * chore(shared): repoint budget imports after the shared/types barrel removal Upstream #14447 dropped the shared/types barrel; GitDiffResult now lives in git-diff-compare-types and GlobalSettings in global-settings-types. Co-authored-by: Orca <help@stably.ai> * fix(ssh): surface an over-cap preview read as file_too_large The stream reader aborts an over-cap read with StreamProtocolError, whose numeric code falls through mapRuntimeError to a generic runtime_error carrying the raw "Reported totalSize N exceeds client cap M" string. Neither preview client recognizes that: runtime-file-client.ts and mobile-file-preview-response.ts both key on file_too_large. It also made the two file_too_large guards directly below the read unreachable on the streaming path. Gives the cap its own error type so the caller can translate it, keeping the bandwidth saving the cap exists for. A genuine protocol fault still propagates unmasked. Found by the readiness review. Mutation-verified: removing the translation fails exactly the new test. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
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. |
||
|
|
583ab1601b |
refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.
Move each domain into its own folder and drop the now-redundant prefix:
src/shared/github-pr-types.ts -> src/shared/github/pull-request-types.ts
src/shared/worktree-id.ts -> src/shared/worktree/id.ts
src/shared/linear-links.ts -> src/shared/linear/links.ts
This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.
Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.
Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.
Two things `tsc` cannot catch, handled explicitly:
- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
entry is REPOINTED to the new path rather than pruned. Pruning would drop the
bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
(`mobile/node_modules` is empty). Instead every relative specifier in the repo
was resolved against the filesystem: 174 unresolved before this change and 174
after — identical, so nothing broke in mobile either.
The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.
Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
|
||
|
|
eb22e497bb | Revert "fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes" (#14395) | ||
|
|
6a0c8fa541 |
fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes (#14384)
* Reapply #13326 and #13928 (un-revert #14361) Restores the SSH reattach-identity and daemon-occupancy fixes. Reverting them reintroduced their P0s, filed as STA-4224, STA-4225, STA-4227, STA-4230, STA-4232, STA-4233 and STA-4234 against #14361. The tab loss that motivated the revert is fixed in the commits that follow, so this reapplication is not a straight redo. * fix(relay): stop the fallback attach fence refusing a pane that moved tabs The primary fence was moved to the shell's own incarnation precisely because paneKey/tabId froze the pane's LOCATION at spawn and refused panes that had merely moved. The fallback that older clients fall into kept the old rule, so the correction never reached it — the same 'the rule exists, but this path does not ask it' leak this work has hit repeatedly. A refusal here is not recoverable: an identity mismatch never grounds a respawn, so the pane keeps a live shell it can no longer reach and renders blank. Narrowed to paneKey, which is the identity; the tab is a location. Restoring the tabId comparison reddens the new test. |
||
|
|
11cd2b4310 |
revert(ssh): back out #13326 and #13928 — reconnect loses every tab (#14361)
* Revert "fix(daemon): stop killing live coding agents when the daemon can't report its sessions (#13928)" This reverts commit |
||
|
|
e7b85266f5 |
Add golden E2E tests for fresh terminal and shell commands (#14302)
* test(e2e): add golden tests for fresh terminal and shell commands Adds regression tests for terminal initialization in fresh profiles and shell command execution to the golden test suite, integrated across Linux, macOS, and Windows CI. * test(e2e): bracket shell command output between markers The echoed command can wrap or be clipped by the buffer tail. Bracket output between begin and end markers to reliably identify real output, and strip ANSI escape sequences that interfere with parsing. |
||
|
|
3ab8b6a117 |
fix(ssh): stop SSH reconnect from multiplying terminals and resuming agents twice (STA-3077) (#13326)
* fix(ssh): stop reconnect from grafting panes and stacking remote leases Reconnecting an SSH-backed workspace added terminal panes the user never opened, and the remote host accumulated shells nobody was using — one report went from 2 to 19 to 20 relay PTYs across three reconnects (STA-3077). Two root causes, both in the store. Reattach could create UI. `persistPtyBinding` has four creating branches — mint a tab, mint a root leaf, split the root and graft a leaf, mint a layout. All four are load-bearing for `pty:spawn`, which can beat the renderer's debounced layout writer, but none of them is appropriate on reattach, where the pane either already exists or is gone for good. Add `mayCreate`, defaulting true so the spawn path is untouched; every creating branch already sets `terminalMembershipChanged`, so refusing is a check rather than a new code path. Lease identity had no pane key. `upsertSshRemotePtyLease` matched on `(targetId, ptyId)` alone, so a pane that re-leased under a new relay id left its predecessor live with nothing to retire it, and the next reattach fanned out over both. One pane now keeps at most one live lease. Superseded leases are marked `expired` rather than terminated: losing a lease is not proof the shell died, so the remote process is deliberately left running. Tests assert observable behavior rather than mechanism, so they stay valid under any implementation that fixes this. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record the terminal session behavior contract Properties stated as observable behavior rather than mechanism, so an oracle written against them survives a change of implementation. Records the weaker, correct form of the timer rule — a timer may never be the sole cause of a destructive action — because recovery budgets and scratch-file age gates are correct code that an absolute ban would condemn. Also notes which mechanisms are deliberately not required, so each has to earn its place rather than arrive with an architecture. Co-authored-by: Orca <help@stably.ai> * fix(ssh): heal duplicate pane leases that predate pane-keyed supersession Pane-keyed supersession stops new duplicates, but it does nothing for installs that already carry the ones STA-3077 accumulated — the report behind this reached 20 live leases across a handful of panes, and every reconnect fanned out over all of them. Retire the stale duplicates once per reattach pass, keeping the newest lease for each pane under a total order so two hosts resolve a tie the same way. As with supersession, retired leases are marked `expired` rather than terminated: their remote shells are deliberately left running, because a lease we chose not to revive is not evidence the shell died. The relay-session store stubs gain the new method. Note the gap this leaves open: those shells keep running and are no longer reachable from the app, so the "accumulates unused shells" half of the report needs a visible recovery surface rather than a silent kill. Co-authored-by: Orca <help@stably.ai> * fix(terminal): stop respawning a shell that is still running A pane that failed to reattach spawned a fresh shell. Because the restored session id came along, the replacement resumed the same agent session, and two processes appended to one transcript — reported repeatedly, up to five concurrent resumes of a single session. Two defects fed it. The relay reported a source that merely needed re-establishing as `SSH_SESSION_EXPIRED`. The shell was still running; only its output source was gone. Give that outcome its own error so it stops reading as "the session no longer exists". The reattach failure handler then treated every error as proof of death. It checked for expiry and, in the else branch, took the identical action — so the check bought nothing and a transport fault, a timed-out call, or a wedged relay all respawned. Respawn now requires proof: an explicit host expiry or a not-found PTY. Anything else, including an error we have never seen before, is unresolved, leaves the shell running, and keeps the binding for a later reattach. Two existing tests asserted the old behavior. One threw a bare error as scaffolding to reach the spawn-adoption door; it now throws proof, which is what it meant. The other pinned the expiry mapping itself, and now asserts the outcome fails closed *without* being reported as expiry. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record what makes a retention bound safe Shortening a grace period is the wrong lever. Measuring process time and gating reclamation on an independent observation are what make one safe, and they are what deployed systems actually do. Also records that lifecycle belongs in the attach reply rather than a delivered event — that is what removes the need for a durable per-consumer cursor to guarantee an exit is never lost. Co-authored-by: Orca <help@stably.ai> * test(terminal): assert the empty-failure case without an empty Error A thrown empty value exercises the same property — a failure carrying no usable message is not proof the session is gone — and does not trip the empty-error-message lint. Co-authored-by: Orca <help@stably.ai> * fix(ssh): let the durable pane binding outrank recency when retiring leases Choosing the newest lease for a pane is wrong whenever a newer lease exists that no pane is bound to: it retires the lease the pane is actually attached to, detaching a live terminal instead of healing it. Two changes. Arbitration now prefers the lease matching the pane's durable binding, across both the SSH-target and local partitions, falling back to recency only when no binding names either candidate. And supersession at upsert time now defers rather than expiring a bound predecessor. When a lease arrives for a pane that is still bound to a different PTY, the binding has not caught up yet, so both stay live and reattach arbitrates once the binding is available. Co-authored-by: Orca <help@stably.ai> * fix(ssh): roll back a lease retirement whose durable write fails `flush()` logs and swallows write errors, so a failed write left these leases retired in memory while disk still called them attached — and the pane bindings scrubbed alongside them stayed scrubbed. Use `flushOrThrow` and restore both the lease states and the affected session partitions when it throws, reporting nothing retired. Co-authored-by: Orca <help@stably.ai> * test(ssh): prove pane and remote PTY cardinality across reconnects Counts the shells the relay actually hosts, on the container, rather than inferring them from app state — that is the census the report was based on. Asserts the PIDs are unchanged, not merely the count, so a kill-and-respawn cannot pass. Every pane streams before the transport is severed: an idle pane sends no recovery checkpoint, so only a live source comes back needing re-establishment, which is the outcome that used to read as expiry. Co-authored-by: Orca <help@stably.ai> * fix(ssh): actually pass mayCreate:false from the reattach binding write The `mayCreate` guard was correct and had no production caller, so the reattach path still went through the creating branches and grafted panes back. `restoreReattachedPtyRuntime` is that call site — RC3 in the original diagnosis — and it now refuses to create. Binding moves ahead of runtime registration, because registering first would surface a pane the user never opened before the refusal landed. A refusal leaves the remote shell running and reattachable; a *thrown* write stays unknown and still registers, so a failed disk write cannot detach a live pane. Adds an oracle over the call site itself. The store-level tests all passed while the fix was inert, because they called the store directly — only pinning the wiring catches that. Co-authored-by: Orca <help@stably.ai> * fix(terminal): apply the respawn-requires-proof rule to both reattach paths connectPanePty has two near-verbatim reattach blocks — one keyed on the deferred SSH session, one on the restored session — and only the second was fixed. The first still checked for expiry and then respawned unconditionally anyway, so a transport fault there resumed the same agent session a second time. Also keep the wire token out of the pane. The main-process bridge only special-cases expiry, so a source-restore failure crossed IPC as raw `SSH_SOURCE_RESTORE_REQUIRED: <id>` text and surfaced to the user. It correctly does not respawn; it just should not read like that. Co-authored-by: Orca <help@stably.ai> * test(ssh): state plainly that the reconnect spec is a forward guard It was run against an unfixed tree and passed, so it does not prove the STA-3077 fixes and should not be read as if it does. A clean severed transport does not reproduce the field conditions — accumulated duplicate leases, or a source returning needing re-establishment. It keeps its place as a forward guard: it counts the shells the relay actually hosts and pins their PIDs, so a later change that grafts a pane or respawns a shell fails here. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record that a guard must be pinned at its call site A refusal that exists and is never passed is indistinguishable from no refusal, and store-level tests cannot tell the difference — they call the store directly. Learned from `mayCreate`, which was correct and had no production caller for several commits. Co-authored-by: Orca <help@stably.ai> * fix(ssh): park one PTY's exhausted delivery recovery instead of dropping the channel A per-PTY recovery budget running out disposed the whole relay channel, so one PTY that could not re-prove its delivery aborted every in-flight filesystem and git request on that host and stalled every sibling pane. A retry count is not proof of anything, and it certainly is not proof about the other sessions sharing the channel. Exhaustion now parks that PTY's delivery. The remote shell keeps running, its lease stands, and the next relay open reattaches it with a fresh delivery generation — the parked state is cleared on teardown and the generation changes on reconnect, so a reconnect recovers it. The consecutive-attempt ceiling goes away entirely; the per-generation one is what bounds the retry cost, and the second ceiling only existed to reach the channel drop sooner. Tradeoff worth stating: the failing pane used to self-heal within seconds because the forced reconnect wiped all rejection state, and it now stays frozen until the next relay open. That is a worse outcome for that one pane and a much better one for every other session on the host, and reconnecting is user-reachable. Co-authored-by: Orca <help@stably.ai> * fix(pty): let liveness say unknown instead of forcing it to say dead `IPtyProvider.hasPty` returned a boolean, so a provider whose inventory was empty for reasons that have nothing to do with the session — socket down, cache never hydrated, provider generation just constructed — had no way to say so and answered "absent". Its own siblings already knew better: `probePtyLiveness` and the runtime's `PtyController.hasPty` were both already `boolean | null`, with consumers branching on null correctly. The lie was injected at exactly one interface. Now three-valued, and each provider answers unknown where it cannot prove absence: the daemon adapter off-socket, the SSH provider before a completed listing, the router when any adapter cannot answer, and the degraded provider rather than fabricating a verdict. `terminal_gone` requires unanimous proven absence. Also fixes a real cold-start bug this surfaced: `pty:hasPty` never awaited the daemon-swap startup promise, though the sibling `probePtyLiveness` bridge already did, so before the swap the local provider answered an authoritative false for every daemon-owned id. Net +27 production lines. The plan behind this predicted -92 on the strength of deleting the renderer's dead-session reconcile path; that code is live (`pty-connection.ts` imports it), so nothing was deleted. Expressing a third value where there were two costs lines, and a deletion that is not real is not worth manufacturing. Co-authored-by: Orca <help@stably.ai> * docs(terminal): track the terminal-session correctness handoff package The package was untracked under a gitignored `docs/**`, with the un-ignore rules living only in an uncommitted .gitignore edit — a single `git clean -xdf` would have destroyed the authoritative plan. The 814-path construction snapshot is now pushed as `nwparker/react185-authority-snapshot` too; it had no remote ref. Co-authored-by: Orca <help@stably.ai> * test(ssh): make the reconnect settle window actually wait The settle poll reused a matcher the assertion 15 lines above had already satisfied, and Playwright's poll engine probes immediately and returns as soon as the matcher passes — so it observed the same state twice and elapsed 0ms. A shell grafted a second or two after reattach reported ready slipped through into the next cycle. Reviewer was right on #13111. Test-only; no production change. Co-authored-by: Orca <help@stably.ai> * test(ssh): census both durable session partitions on reconnect Adds a second reconnect scenario and a helper that reads pane records from the local partition as well as the ssh host partition. That split matters: the reattach binding call passes no hostId, so a grafted pane lands in the LOCAL partition and an oracle reading only the host partition passes whether or not the guard is present. Both tests remain forward guards. The second one was reported as discriminating and did not reproduce: with `mayCreate: false` removed from the call site and the app rebuilt, both still passed. Its induction races `pty:kill` against a severed transport, so when the kill lands the lease is cleaned up and there is nothing left to graft. The handoff README is corrected to say so rather than claim a journey. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record the user decision relaxing G6 G6 becomes minimise-and-justify rather than strictly net-negative. The deletion budget the plan assumed does not exist: an entrypoint-rooted import graph found 51 of 53 candidate files reachable and instantiated on live paths, leaving 263 deletable LOC against roughly +1,021 to offset. Correctness may still not be traded for line count. Co-authored-by: Orca <help@stably.ai> * test(terminal): add discriminating oracles for restart, daemon, skew and namespaces Six parallel streams, each required to fail with its guard removed rather than merely pass. Local restart proves the OS process itself survives, by reading `ps -o lstart=` for the shell's own pid. That matters: with the quit path made destructive, the tab, leaf and pty ids all came back byte-identical while the shell underneath was a new process — every existing restart spec would have stayed green. Two separate guards were removed to redden it, and the second reddens only the stale-operation case. Daemon restart discriminates by reverting three-valued `hasPty`; version skew now covers publication semantics and confirms the new `SSH_SOURCE_RESTORE_REQUIRED` token mutates nothing on an old client; two-host isolation censuses both containers. Deletes `src/relay/pty-source-replay-index.ts` — 201 production lines with no importer outside its own test, verified against an entrypoint-rooted import graph rather than a name grep. Five namespace tests are skipped, not passing: they reproduce a defect still live on main where folder-workspace ids compare equal with the instance suffix stripped. PR #12474 fixes it; they are its oracle. Co-authored-by: Orca <help@stably.ai> * test(ssh): induce the reattach graft deterministically instead of racing a kill The previous induction closed a pane while the transport was severed and relied on `pty:kill` FAILING so the lease outlived the pane record. It does not fail: with the provider already torn down, `pty:kill` takes its tombstone branch and marks the lease terminated, and `reattachKnownPtys` filters terminated leases out of the fan-out — so the reconnect never visited the PTY the test was about. It passed on both trees. Seed the precondition instead. Spawn a real remote PTY on a leaf that never becomes a pane, then roll the host partition back to its pre-spawn snapshot, leaving a live lease and a live remote shell that no durable pane owns. No failure races a success. Adds a vacuity guard that is independent of the tree under test: the lease's own `lastAttachedAt` must advance, proving the fan-out actually visited this lease before the pane census is trusted. Verified on this machine under an isolated TMPDIR, since the e2e harness keys its seeded-repo pointer on a machine-global tmpdir path: guard present passes, guard removed fails with the phantom leaf grafted into the local partition, guard restored passes. Co-authored-by: Orca <help@stably.ai> * docs(terminal): propose one authoritative binding identity Every defect this program has touched is the same defect: identity compared with the wrong key, or not compared at all. Lease keyed without the pane, reattach using a creating write, folder-workspace ids compared with the instance suffix stripped, local mutating IPC carrying only an id, a live shell classified as expired, liveness unable to say unknown. Proposal: one branded binding type built from fields that already exist and are already persisted, constructible only from an authoritative source, carried by mutating operations, compared by one shared function. Makes a wrong-key comparison a type error rather than the next incident. Under adversarial review, including against the open issue corpus. Not accepted. Co-authored-by: Orca <help@stably.ai> * fix(pty): refuse mutating operations aimed at a superseded PTY `pty:write`, `pty:writeAccepted` and `pty:resize` accepted any id. The renderer queues input, so a keystroke buffered before a reattach landed on whatever PTY had since taken the pane — and a resize reshaped the successor's shell. Main already tracks `ptyPaneKey` and `paneKeyPtyId` in lock-step, so their disagreement is proof the caller's id was superseded. No wire change, no renderer change, nothing added to the input payload. An id with no recorded pane stays permitted: unowned and orphaned PTYs are unknown, not stale, and unknown never authorizes refusing an explicit operation. That is also what keeps orphan cleanup working — those ids have no pane by construction. The tests pin the CALL SITES, not the predicate. A capability that exists and is never called is indistinguishable from no capability, which is exactly how `mayCreate` sat inert here for several commits with every test green. Co-authored-by: Orca <help@stably.ai> * fix(pty): fence signals at a superseded PTY, and pin why kill is exempt A signal means "interrupt my pane", so delivering one to a PTY the pane has already replaced is a misdirected interrupt. Fence it with the same lock-step proof used for write and resize. `pty:kill` stays deliberately unfenced and a test now pins that: a superseded PTY is orphaned, and reclaiming it is exactly what the orphan-cleanup callers ask for. Refusing there would break the operation that reclaims leaked shells — the opposite of the intent. The fence sits at the IPC boundary, above `tryGetProviderForPty`, so it covers local, daemon and SSH rather than the local path alone. Co-authored-by: Orca <help@stably.ai> * test(terminal): poll the pane binding read so a slower host cannot flake it `readPaneBinding` took a single unpolled read of a DOM dataset attribute immediately after a renderer reload, while its sibling helper polls the same data for 15s. On a native Linux host both tests failed every run with 'No bound terminal pane is mounted' while the app was demonstrably healthy — the screenshot showed the terminal restored with a live prompt and the boot PID echoed. The assertion is unchanged; it is only awaited. Nothing is weakened. Found by running this spec on native Linux rather than assuming macOS behaviour generalises. Co-authored-by: Orca <help@stably.ai> * test(terminal): make the restart identity spec run on Windows too Both probes were POSIX-only and unconditional: `echo ...=\$\$` for the shell's own pid, and `ps -o lstart=` for its start time. Running the spec on a real Windows host proved it dies before reaching either guard, so Journey 1's Windows half was unprovable rather than merely unproven. PowerShell exposes the same two facts as `$PID` and `Get-Process` StartTime. The start time still matters on both platforms for the same reason: a PID alone cannot separate a survivor from a reused number. Still green on macOS. The Windows path is written from the host probe and has not itself been executed end to end — that is the next thing to run there, not a claim being made here. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record the fence's real gap and what peer designs taught Marks the client-constructed binding proposal as rejected with the three false claims that sank it, and records what shipped instead. States the shipped fence's actual limitation rather than leaving it implied: it compares a binding, not an incarnation, so a respawn under a reused ptyId passes. The obvious remedy is wrong here — the agent-create id is deterministic by design so a replayed create stays idempotent, and randomising it would trade this narrow gap for a duplicate-spawn bug. Also records the ranked lessons from four comparable agent IDEs, chiefly that a typed end-reason at end time is what stops a user quit from looking like a resume candidate. Co-authored-by: Orca <help@stably.ai> * docs(terminal): promote Journey 1 to proven on all three platforms The oracle now runs natively on macOS, Linux and Windows, and its discrimination was watched on each: a mutation reddens it, a restore greens it. On Linux and Windows both mutations were run, and the second reddens only the stale-operation test — so the journey's two clauses are proved independently rather than jointly. Windows is the new evidence. The PowerShell branches added blind at ebffb85a848 executed correctly on their first run: `$PID` expanded to real integers, which also proves the pane shell there is PowerShell-family rather than Git Bash, and `Get-Process StartTime` returned kernel start times 5.4s apart — so a recycled pid could not have passed as a survivor. First journey promoted in this program. The other twelve are unchanged, and the residual limit on "every stale exact operation" is recorded rather than glossed. Co-authored-by: Orca <help@stably.ai> * test(terminal): add discriminating oracles for the daemon, skew and multi-host journeys Daemon: replaces a spec that modelled only a client restart and never crossed the daemon boundary, whose successor generation owned nothing so "the live successor is neither killed nor replaced" was vacuous. The PTY leader is now a real login shell reporting `$$` back through the production write path, resolved to a kernel start time. Two mutations each redden exactly one of the three clauses, on macOS and Linux: reverting three-valued `hasPty` reddens only the unknown-not-dead clause; widening the sole-provider fallback reddens only the stale generation clause. Skew: reverting the restore-required publication to expiry reddens 4 of 5 new tests while the legacy control stays green — the regression this branch fixed is now caught if reintroduced. Multi-host: restoring `mux.dispose('connection_lost')` reddens sibling isolation on one host. It does NOT redden across hosts, and that is recorded rather than glossed: a mux belongs to one relay session per target, so its dispose cannot cross a host boundary. Journey 4's cross-host clause rests on isolation-by-construction, not on a mutation. No production code changes. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record journey evidence that falls short of promotion Four journeys now have discriminating oracles but none meets its full stated scope, and each shortfall is named rather than rounded up. Journey 2 is one WSL run from promotion. Journey 12's tests are in-process, so they do not close the live-skew gap the original ledger named. Journey 4's cross-host clause cannot be proven by mutation at all — a mux is per target, so its dispose cannot cross hosts, and the cross-host test stayed green under the mutation that reddens siblings. Journey 13 measured one dimension of ten, on lifted predicates rather than through real IPC. Co-authored-by: Orca <help@stably.ai> * docs(terminal): promote Journey 2 to proven on macOS, Linux and physical WSL The oracle runs on every environment the journey names, and is clause-selective on all three: reverting three-valued `hasPty` reddens only the unknown-not-dead clause, and widening the sole-provider fallback reddens only the stale-generation clause. Selectivity in WSL was established rather than assumed. The spec runs serially, so a red first test reports the others as "did not run" — they were re-run alone under the same mutation and stayed green. Also records that an Orca WSL-mode terminal now starts on that host at all, which it could not before: the distro had no provisioned default Unix user, so every interactive launch blocked on first-run setup. One diagnosis from the WSL run is corrected here rather than repeated: the unrelated `local-pty-shell-ready` failure was attributed to bash 5.3.9, but macOS runs the same bash version and passes 67/67. The trigger is environmental to that distro, and the underlying defect is that the spec pins an absolute count of OSC markers it does not own. Co-authored-by: Orca <help@stably.ai> * docs(terminal): correct the WSL provider-suite diagnosis The WSL run blamed bash 5.3.9 for the unrelated `local-pty-shell-ready` failure. macOS runs the same bash version and passes 67/67, so the version is not the cause — the trigger is environmental to that distro, and the underlying defect is that the spec asserts an absolute count of OSC markers it does not own. Co-authored-by: Orca <help@stably.ai> * test(runtime): unskip the workspace-namespace oracles now their fix has merged These five reproduced a defect that was live on main: folder-workspace ids were compared with the instance suffix stripped, so two workspaces sharing a directory read as the same namespace. They were committed skipped, pointing at the PR that fixes it. That PR is merged, and they pass. Verified they still bite: restoring the suffix-stripping comparison reddens exactly these five and leaves the other four green. An oracle written before its fix, held skipped, and confirmed against the fix after the merge — rather than deleted and rewritten from the answer. Co-authored-by: Orca <help@stably.ai> * test(ssh): add MaxSessions, lazy-discovery and paired-skew oracles Three journeys attempted; none promoted, and the reasons are recorded in the ledger rather than rounded up. MaxSessions=1 against real OpenSSH, with the cap read back from `sshd -T` rather than assumed, and remote pids read on the container two independent ways that must agree, each carrying its kernel start time. Two disjoint mutations discriminate — one reddens only the reconnect clause, the other only the two restart clauses. But the disconnect clause is a forward guard: four separate guard removals left it green, so nothing shipped is load-bearing for it. Lazy discovery samples sshd's own accept log and live session census across a 22s window with the in-use host as a positive control. No mutation reddens its third clause alone — the real cross-host lease scoping is load-bearing, but removing it breaks the sibling host during setup, so the failure carries no clause information. The paired-runtime skew spec pairs two real processes at different versions and refuses to run rather than degrade into a same-version pairing that would look green and prove nothing. No production code changes. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record why the duplicate-resume fix was not built I recommended adding a typed end-reason so a user quit stops looking like a resume candidate, then went to implement it and stopped. `SleepingAgentSessionRecord` already carries three fields that each exist to stop something resuming that should not have — `origin`, `restoreOnTabOpenOnly`, and `automaticResumeBlockedBy` — each traceable to its own incident, consulted at 22 non-test sites. A fourth predicate, however well typed, is the fifth containment cycle. The designs without this bug do not have a better flag; they resume only on an explicit action, into a new terminal id, and make two agents in one terminal unrepresentable in the schema. The first of those is a product decision about whether automatic resume stays a feature, so it is the user's call rather than mine. Co-authored-by: Orca <help@stably.ai> * docs(terminal): reconcile G6 with the recorded decision and assess its clauses G6's body still demanded strictly-negative production LOC after the user relaxed it to minimise-and-justify, so the gate had two conflicting pass conditions and no single truth value. Its body now points at that decision. Assessed the remaining clauses against the branch rather than assuming. Two fail structurally: more than one identity comparison and mutation admission path still exist, and `terminal-input-quarantine.ts` is still reachable from two production files. Records why the quarantine is not subsumed by the superseded-PTY fence, which I had assumed and checked. The fence refuses writes aimed at a stale ptyId; the quarantine guards the user's next keystrokes landing on the successor under its current, correct id — a case the fence never sees. Removing it needs the recovery path to surface a different shell as unresolved, not a deletion. Co-authored-by: Orca <help@stably.ai> * docs(terminal): the input quarantine is load-bearing, not superseded G6 lists "no superseded quarantine remains reachable" and this module was assumed to be one. Disabling its single call site reproduces the hazard it exists for — `cho hi; rm -rf x` reaching the shell — so deleting it without a replacement re-opens command execution. The replacement was costed by building it rather than estimated: +26 production LOC to thread the incarnation, ~+33 complete, and the cross-remount state it needs outlives the destroyed pane so it becomes a module about the size of the one deleted. Floor is roughly +140 to delete 88, and it would add a second identity comparison to a gate already failing for having more than one. The decisive part is that the route is not uniformly available: remote runtime results carry no incarnation, old hosts cannot be made to publish one, and mixed versions are the normal state. A paired client reads unknown, which this program's own rule says is not proof — so either every remote reattach surfaces unresolved, or a fallback is needed and the only correct fallback is this module. Whether to amend the clause or accept something weaker on remote hosts is a user decision, so the clause verdict is left as failing rather than quietly reclassified. Co-authored-by: Orca <help@stably.ai> * refactor(runtime): collapse duplicate identity comparisons G6 requires one identity comparison; five implementations existed across two concepts. Worktree-namespace identity had two: `runtimeWorktreeIdsEqual` and `runtimeWorktreeIdentityKey` independently re-derived repoId plus normalized path. Equality now derives from the key, so the comparison and the sleep / mutation-queue keying cannot drift into two different rules — which is exactly how the suffix-stripping bug reached production once. Pane identity had three byte-identical leaf-UUID comparisons, in orchestration `db.ts`, `lifecycle-reconciliation.ts`, and `orchestration-legacy-process-identity.ts`. One copy moved to `stable-pane-id.ts`, which already owns `PaneKey`, `parsePaneKey` and `makePaneKey` and which all three already imported. No new module, no branded type, no parallel comparison. Net -14 production lines. The namespace oracle still bites: restoring the filesystem parser inside the identity key reddens exactly its five cases. The raw counts are not the actionable set, and the classification is worth recording: of 409 non-test `worktreeId` comparisons, 71 are typeof guards and 81 are sentinel tag checks. Most of the remainder are renderer predicates over store rows where both operands are the same main-minted id, so normalizing there would widen equality rather than correct it. Co-authored-by: Orca <help@stably.ai> * refactor(terminal): finish a half-done fixture move and audit the rest `xterm-bypass-event-fixture.ts` and `__fixtures__/xterm-bypass-event.ts` were byte-identical apart from an import path. The `__fixtures__` copy had zero importers and the live copy compiled as production — someone started the move and left both. Dead copy deleted, live one moved, its three test importers updated. Audited the wider G6 clause by importer rather than filename: 32 test-only files, roughly 3,300 LOC, currently compile as production; 4 of the 36 candidates have real production importers and are correctly placed. The list is recorded in the goalposts. Those 32 are almost all older than this program and outside the terminal surface, so sweeping them belongs in its own change rather than inside a terminal PR. The clause stays failing, with the remaining files named. Co-authored-by: Orca <help@stably.ai> * docs(terminal): the fixture clause already holds where it matters Checked what the build emits rather than reasoning from file paths. None of the 32 test-only fixtures appears in `out/` — Rollup drops them because no production entrypoint reaches them. On "compiles into the shipped product", this clause holds today. On the other reading it cannot be closed by moving files at all: both production tsconfigs use bare `include` globs with no `exclude`, so a `__tests__/` directory matches exactly like any other path, as does every `*.test.ts` in the repo. Relocating 32 fixtures would remove nothing from typecheck scope. A sweep was started and stopped once this was verified, rather than landing 32 moves across areas this program does not own for no gain. If the intent is that typecheck scope should exclude test code, that is a repo-wide tsconfig change with a different owner. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add plain-language design and test overviews Two reviewable documents with diagrams, written so someone with no prior context can follow what breaks, why, and what changed. The design overview explains the five things stacked behind one terminal rectangle, the 2 -> 19 -> 20 report, the three root causes, and the rule underneath all of them: unknown is not dead. The test overview explains why a green test proves nothing on its own, the four-step mutation proof we adopted, and — the part worth reviewing hardest — an honest account of what could not be proven and why, including the properties that are true by construction and therefore have no guard to remove. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add a self-contained visual report of the design and its evidence Pre-renders every diagram to inline SVG in both themes so the report opens offline and stays sharp when zoomed. States the gate/journey score and the retractions alongside the fixes, so the unproven half is as visible as the proven half. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record the finalized two-plane architecture decision Adopts the data-plane proposal and adds the control-plane track it does not cover: re-key ownership by pane, split orphan inventory out, then delete the compensating code. Records that the host-authority alternative was refuted and that the shipped keystroke fence is inert on the reattach path. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add the design brief the review counsel works from Separates verified code facts from unverified leads so reviewers attack the design rather than a reconstruction of it, and records which simpler alternatives were already refuted and why. Co-authored-by: Orca <help@stably.ai> * docs(terminal): report the design counsel's outcome and the live respawn bug it found Three review rounds across two models replaced the two-record split with one leaf-keyed record, deleted attach-time pane identity, and made orphans a connect-time projection. Records that a shipped gesture still turns a healthy remote shell into a duplicate agent resume, and that the renderer classifier in that chain treats an error-message shape as proof of death. Co-authored-by: Orca <help@stably.ai> * docs(terminal): correct the report — the respawn proof gate guards a minority path A final review traced every auto-respawn route. The primary one converts the reattach failure into a boolean before any classifier sees it, so the shipped proof gate never runs there. Records that two of the six shipped changes are narrower than claimed, and why their tests could not have caught it. Co-authored-by: Orca <help@stably.ai> * docs(terminal): explain the landed design on its own terms One leaf-keyed ownership record, orphans computed at connect, and replacement shells only on positive proof — with the shipping order and the one product trade the design asks the owner to accept. Co-authored-by: Orca <help@stably.ai> * docs(terminal): rewrite the design explainer in plain English The first version assumed the reader knew the codebase. Reframed around two bugs, two fixes and one decision, with the jargon replaced by pane / program / note / helper and a five-word glossary for what could not be avoided. Co-authored-by: Orca <help@stably.ai> * fix(ssh): stop reading an identity mismatch as a dead shell The relay reports a pane-identity mismatch by saying the pty was not found, but it found it — comparing identity is how it noticed. Publishing that as expiry made the renderer clear the binding and cold-restore with agent resume, so a live shell gained a second agent on one transcript. Reachable today by detaching a pane into a new tab, which changes the tab the relay froze at spawn. Mismatch now carries its own token and the classifier refuses it as proof. Genuine absence still expires, so a shell that really went away is not stranded. The three failure tokens move to src/shared: main published them and the renderer decided respawn on them, from two copies that had drifted apart. Co-authored-by: Orca <help@stably.ai> * fix(ssh): stop sending pane identity on reattach The relay froze pane identity at spawn, so moving a pane to another tab made it refuse a live shell — and refuse by saying 'not found'. The comparison is presence-guarded, so not sending the fields disarms it on every relay version including ones already installed on hosts: no wire change, no redeploy. Nothing is lost. It existed to catch a relay restart recycling pty-N for a new shell, and in exactly that case pane and tab both still match, so it accepted the wrong shell anyway. The incarnation the attach returns is what distinguishes those, and it already crosses the wire. Removes the whole client-side apparatus: the expected-identity type, its per-lease derivation, its map, and the parameter threaded through four layers. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add tracked goalposts for the new design Each goalpost is a behaviour with an oracle and the mutation that must redden it, so 'proven' cannot be claimed from a green test. Records the anti-inert rule as a first-class goalpost, since three guards in this program passed their tests while sitting off the route production takes. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record that the recovery grant is dead code, deleting a design step The lease stores a relay-native pty id and the caller passes the app form, with a raw equality comparison between them, so the 30s grant cannot fire for a real SSH pane. The death rule that existed to referee it is deleted rather than built, and the dead path itself becomes a removal. Co-authored-by: Orca <help@stably.ai> * docs(terminal): keep the full design detail in the repo It only existed in an ephemeral job directory, so the plain-English explainer had no durable source for its specifics — record shape, death rule, reattach algorithm, migration order and the 25 oracles. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add a resume prompt for a clean session Points at the goalposts as the contract, names the three goalposts whose oracles are already written and red, and carries the process rules that were learned the expensive way — prove guards reachable, verify mutations land, commit per step, and never let a subagent write production files in a shared worktree. Co-authored-by: Orca <help@stably.ai> * test(ssh): add the failing oracles for goalposts S3, S4 and S5 Intentionally RED: 14 clauses that fail against current behaviour and go green under the changes named in new-design-goalposts.md. The branch is held unmerged, so red here means unimplemented, not broken. Each was verified to fail for the right reason and to flip green under the identified fix, which was then reverted. Each pins the producer as well as the consumer, so no clause can pass vacuously if its route is ever severed — the failure mode that let three earlier guards ship inert. Co-authored-by: Orca <help@stably.ai> * fix(ssh): stop fabricating an exit when a reattach fails A failed attach never proves the shell exited. The relay answers not-found for a pane-identity mismatch and for any id it merely cannot hand back, so treating it as death sent the pane a synthetic `pty:exit { code: -1 }`, cleared provider state, deleted ownership and expired the lease — four claims about a process we know nothing about, on a shell that is usually still running. Collapse every failure into the non-destructive branch that already existed a few lines above (`restoreRequired = 'reattachAttemptsExhausted'` + wakeRecovery). A branch collapse, not a new mechanism: goalpost S3. Two tests pinned the deleted premise and are INVERTED rather than patched, so the new intent stays covered: - ssh-relay-orphan-abandon-paths: "retires the lease without a kill when the relay proves the PTY is gone" -> "leaves the shell running when the relay only reports the PTY as not found". Its comment claimed attach verifies liveness before answering not-found; it does not. - ssh-relay-session: "invalidates and broadcasts remote PTYs that cannot reattach" -> "leaves an unreattachable remote PTY alone while its sibling reattaches". Also repairs two clauses left red by |
||
|
|
971d9548b5 |
docs(agent-status): make design references self-contained (#13902)
docs/design/agent-status-over-ssh.md was cited from ~10 source files but does not exist in the repo. Replace each pointer with the invariant the code actually relies on so the knowledge survives without the doc. Renderer-side citations (useIpcEvents.ts, agent-status-types.ts) are left for the concurrent batching change that owns those files. Co-authored-by: Orca <help@stably.ai> |
||
|
|
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.
|
||
|
|
aa50d8cee3 |
fix(ssh): release the relay-loss watcher before teardown mux writes (#13737)
* fix(ssh): release the relay-loss watcher before teardown mux writes
Cancelling the in-flight port scan emits rpc.cancel on the control lane.
If that lane is saturated, enqueue admission fails and the writer calls
fail() -> mux.dispose('connection_lost'), which fires the relay-loss
watcher during our own teardown and schedules a redundant relay redeploy.
teardownProviders already released the watcher first, but stopPortScanning
runs ahead of it and is what emits the frame. Hoist the release into a
named method and call it before stopPortScanning on all four teardown
paths.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): hoist the watcher release above abort on every teardown path
Review follow-up. Two nits from the readiness pass:
- The "stop scanning before teardownProviders" comment had drifted onto
releaseRelayLossWatcher(); move it back above the stopPortScanning()
call it describes.
- Release the watcher ahead of abortController.abort() too. That signal
reaches no mux request today, so this is not a live hole, but plumbing
it into one would have silently reopened the bug on three paths. Making
the release first keeps the invariant structural rather than incidental.
detachSshPtyConsumerRecovery stays first on the two detach paths, per the
existing synchronous-half-first rule.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
|
||
|
|
0acf04a985 | perf(ssh): cancel abandoned remote port scans (#13548) | ||
|
|
d50adec2d2 |
feat(ai-vault): isolate scanning from terminal workloads (#13411)
* feat(ai-vault): isolate scanning in service processes * fix(ai-vault): retire idle service processes * fix(ai-vault): discard unverified cache processes * fix(ai-vault): clear relay sidecar cancel watchdog on acknowledgement A cancelled relay call is settled before its 2s cancel watchdog is armed, so the acknowledgement path bailed out of settle() before clearing the timer. The watchdog then faulted a healthy sidecar two seconds after every aborted scan, killing whatever request had since become active. * fix(ai-vault): clear the pending restart before scheduling another recordFault overwrote this.timer, stranding a restart that dispose() could no longer cancel. * refactor(ai-vault): drop the orphaned first-prompt IPC wrapper session-first-user-prompt-handler.ts now owns this entry point and routes through the service; the copy left in the read module had no callers. * fix(ai-vault): retry a faulted cold start before surfacing it A slow first start surfaced a raw 'did not become ready' error to the caller even though the supervisor was already respawning. Requeue an unsent call once onto the scheduled respawn instead. Also stop arming the cancellation watchdog for a call the child never received: no acknowledgement is coming, so it killed a healthy service and stalled the lane. Invalidation bookkeeping and ready-waiter construction move to the state module to stay under the max-lines cap. * fix(ai-vault): give relay title reads their own lane Before this branch the relay read title files directly, concurrently with scans. Routing both through one sidecar lane put title resolution behind a list scan that may run up to 130s, so SSH tab titles could lag minutes behind. Split cache and interactive lanes in both the relay client and the sidecar entry, mirroring the desktop service. Also: clear the ready deadline on fault, so a sidecar that dies before ready cannot fault its healthy replacement five seconds later; retry an unsent call once across a respawn; and skip the cancellation watchdog for a call the sidecar never received. Restart/circuit bookkeeping moves to its own module, mirroring the desktop policy, to stay under the max-lines cap. * fix(ai-vault): degrade relay title resolution on sidecar failure listSessions already returns a host issue when the sidecar is unavailable; titles propagated the raw RPC error instead. Return no titles so callers fall back to preview text, and keep cancellation propagating. * fix(ai-vault): scrub the service child environment The children are forked with a 384 MiB heap cap and no loader, but both spawn sites handed them the full parent environment, so an exported NODE_OPTIONS silently raised the cap or --require'd code into them. Allowlist both, following the plugin worker. The desktop child keeps the eleven agent-root overrides it resolves its own roots from; the relay sidecar takes remoteHome and hostPlatform from its init message and so needs none of them. Both children share one priority module while they share this one. * fix(ai-vault): soft-disable relay vault when the service is missing A missing service threw out of the constructor, so a Vault wiring bug would abort relay startup and take every PTY on the host with it. The unsupported-platform branch three lines above already treats a Vault failure as a soft disable; do the same here. Threading the service through the two handlers instead of a field also retires the definite-assignment assertion the throw was propping up. * fix(ai-vault): drain consumed cache invalidations invalidatedPaths was re-applied in every request's finally and never drained, so once N paths had been invalidated every later request paid N evictions for the life of the process; the 4096 cap only bounded how bad that got. The re-apply exists to cover a read that overlapped the invalidation, so drain once nothing is executing. Clearing unconditionally would drop the re-apply for a request still running on the other lane. * fix(ai-vault): keep a busy child through slow invalidation acks invalidate() reused the 5s ready budget as its acknowledgement deadline and killed the child on expiry, so a delete issued during a large scan could kill a healthy process mid-scan and burn a slot toward the restart circuit. Fault only when nothing is executing. Fork IPC ordering already puts the invalidation ahead of any later request, so a busy child owes no ack here, and the 130s/15s request deadlines still catch a wedged one. The start-retry predicate moves to the state module to stay under the line cap, matching the shape the relay client already uses. * fix(ai-vault): report a failed local scan as a host issue A local-scope scan let its error escape to the renderer, which paints it over the session list. Service supervision now produces those errors, so "AI Vault service restart circuit is open." replaced the list. Route local scope through the degradation the all-hosts leg and every SSH leg already use, so it lands as a retryable host issue row instead. Same result shape either way, so no IPC or wire contract changes. * test(ai-vault): cover the relay restart circuit transitions The relay policy shipped without tests. Pin both circuit edges, the aging-out case, the forced-refresh reopen the relay has and the desktop does not, and the backoff schedule. * fix(ai-vault): keep the OpenCode roots in the service child env The scrubbed allowlist dropped XDG_DATA_HOME and OPENCODE_DB, which the child reads to locate the OpenCode store and database. The pre-PR worker thread inherited them, so a user who sets either lost every OpenCode session. * test(ai-vault): anchor the service spawn env assertion |
||
|
|
131010277c |
[Perf-LH] Serialize relay JSON payloads once per publication (#13516)
* perf(relay): reuse serialized JSON payloads * Defer bulk relay payload preparation until admission |
||
|
|
ced2719b26 | refactor: remove unreachable code (#13400) | ||
|
|
5df2ddbc9c |
perf(ai-vault): isolate tab title resolution (#13377)
* perf(ai-vault): isolate tab title resolution * fix(ai-vault): preserve background scan caches * fix(ai-vault): resolve nested worker from chunks |
||
|
|
a1f61ef8c0 | feat(agents): add Prime Agent status hooks (#13384) | ||
|
|
6397668271 | Add manual artifact sharing from HTML and Markdown views (#13369) | ||
|
|
c991bb27d3 | Add account-backed artifact sharing (#13012) | ||
|
|
9fb4dbe8eb |
fix(ssh): handle rejected PTY deliveries with targeted recovery (#12746)
Add targeted recovery for rejected PTY source frames instead of terminating the relay channel. Classify rejection reasons (malformed, generation mismatch, range invalid) and attempt recovery based on the rejection type. Implement admission control at publication time to ensure frames aren't delivered after ownership changes. Bound recovery attempts and retry with backoff to prevent exhaustion. Diagnose and log rejection reasons to aid debugging. |
||
|
|
eea0bb64db |
fix(ssh): make PTY owner admission explicit and non-destructive (#12673)
An owner-capable `pty.openClient` had two failure modes that presented as something else. If the relay still held an owner record but the request carried no matching resume proof, admission fell through to a SUBSCRIBER grant — a success-shaped response the client cannot use, which it then rejected as "did not grant an authenticated PTY session owner". And if the relay had forgotten the record the client named, admission threw a stale-recovery error, which the client answered by deleting its own recovery row — `clientInstanceId` included — and reopening. Two round trips, and the identity that lets it resume that target at all went with the deletion. Now every owner grant carries a required `resumed` flag, a forgotten record mints a fresh claim in one round trip, a held claim returns one of three coded refusals, duplicate opens on one connection are rejected even when identical, and an attached-holder refusal becomes a typed error routed through the terminal-relay-error callback instead of feeding redeploy backoff a link that is working fine. Independent review caught two regressions in the first attempt, both now fixed and both with tests that fail without them: **A backpressure teardown could take a live owner's session.** The safety argument was that a record only becomes `disconnected` from an observed peer close — but two of the six paths there are capacity paths, where the relay destroys the client's socket itself because its lane queue filled. That is the signature of a client that is ALIVE but not draining fast enough. Demonstrated: the real owner is torn down for backpressure, a rival is granted ownership 270ms into a nominal 30s grace, and the owner's later reconnect with a valid resume proof is refused permanently, backoff cleared, no retry. Closes now carry a cause (`peer-closed` | `local`, defaulting to `local`, which only ever widens a grace), and the floor applies only to closes the transport actually observed on the peer's side. Capacity teardowns, decode faults and sink failures keep the default. **A client's own zombie connection blocked it permanently.** Only `SshRelaySession` ever requests owner, and every endpoint-credential client shares one principal — so in a normal single-app deployment an `active` incumbent refusing you is almost always your own half-open connection the relay never saw close. That was refused as terminal, where main recovered on bounded backoff once keepalive noticed. The refusal already held both client identities; a match is now a distinct transient refusal that falls through to relay-lost backoff, restoring that recovery. A genuinely different client is still blocked. Also: each retry deadline now starts when its own phase begins, instead of both being computed at entry where a slow first phase could leave the second with zero attempts. Fixes STA-3365. |
||
|
|
cc1859d61c |
fix(ssh): snapshot detached SSH leases on quit and bound the teardown (#12687)
Per-target SSH teardown awaited `removeAllForwards` BEFORE anything marked the lease detached, so a slow forward close let the final store flush snapshot while leases still said `attached` — and the later durable write was rejected because persistence had already finalized. On the next launch those leases described a state that never existed.
`beginSshShutdown()` now performs every in-memory transition synchronously before returning, and the quit path calls it immediately before `store.flushAsync()` with no await between. The whole drain shares one deadline that REPORTS unfinished `{targetId, phase}` rather than concluding anything about it, and `waitForSystemSshForwardStop` gained a post-SIGKILL bound.
Nothing here destroys a session. `detached` means this app let go of the lease, not that the shell died — the pre-pass exists precisely so still-running PTYs are recorded as detached-but-alive instead of being lost to an `attached` snapshot. Review confirmed every reader honors that: reattach enumeration and persistence restore filter only `terminated`/`expired`, lease normalization has no age-based expiry, and attempt exhaustion leaves a lease alone. The drain deadline's only consumers are a warning log and a join that discards the value — nothing reads it as "gone".
Review also caught a defect the refactor introduced, now fixed: making the pre-pass synchronous meant a throw from `beginShutdownDetach` — via `webContents.send` on a renderer that quit had already destroyed — escaped the non-async `will-quit` listener and skipped `killAllPty()`, the watchers, `store.flushAsync()`, the teardown barrier and `app.quit()`. That would have lost the exact snapshot this PR exists to make correct. Each call is now wrapped per session, collecting errors and continuing. Proven: the test throws from the first of two sessions and fails without the fix with "Object has been destroyed".
A second test could only fail via timeout rather than assertion; the ordering is corrected so removing the post-SIGKILL bound now fails in 5ms with a clean assertion instead of a 5s timeout.
Rebased onto main and verified independent of #12673 (zero references to its owner-admission changes), which is being reworked separately. Fixes STA-3366.
|
||
|
|
0f9caf52b1 |
fix(ssh): time out stalled remote file streams (#11364)
* fix(ssh): time out stalled remote file streams * test(ssh): cover stream dispose-listener cleanup * fix(ssh): pause file stream deadlines during sleep * fix(ssh): replay suspended state to late streams * fix(ssh): allow slower file stream progress --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com> |
||
|
|
e233d6a641 |
Recover automatically instead of getting stuck on "Multiplexer disposed" when an SSH relay drops (#12216)
* fix(ssh): recover instead of wedging when the relay channel dies mid-connect Three coupled defects made a dropped SSH relay look like a permanent bug: 1. SshRelaySession.establish()/reconnect() ran their last liveness gate before configureRelayGraceTime(), whose mux.notify() can dispose the mux synchronously (writer control-lane admission cap, or a throwing transport). The session then latched _state='ready' + _onReady (status bar "connected") while watchMuxForRelayLoss() silently no-op'd on the dead mux, so the bounded relay backoff in ipc/ssh.ts never ran and the fs/pty/git providers stayed registered against a dead multiplexer. Both sites now re-check mux.isDisposed() after the notify and take the existing failure path. 2. SshChannelMultiplexer.request()/notifyWithSettlement() always reported the permanent-shutdown string 'Multiplexer disposed' with no code, even when the recorded dispose reason was connection_lost. The reason is now recorded and a shared disposedError() factory serves dispose(), request(), notifyWithSettlement(), so a transient drop reports 'SSH connection lost, reconnecting...' / CONNECTION_LOST. onDispose() on an already-disposed mux now fires the handler synchronously with that reason instead of returning a silent no-op (without retaining it). 3. TerminalErrorToast no longer renders a transient relay drop in the red "please file an issue" style. The marker is matched with includes() because the message reaches the toast IPC-wrapped. ssh-git-response-stream-reader registers its onDispose subscriber after the abort wiring, since an already-dead mux now fails synchronously there and the cleanup must be able to drop the caller's abort listener. Closes #11953 * fix(ssh): treat a mux killed during PTY reattach as relay loss reconnect()'s post-reattach gate bare-returned when ownsAttempt() went false, and reattachKnownPtys swallows every per-PTY error, so a control-lane failure during a large reattach burst disposed the mux without ever reaching the catch: providers stayed bound to the dead mux, no relay-loss watcher was installed, and the session wedged in 'reconnecting' until restart. Take the failure path when our own mux is the one that died so ssh.ts's bounded backoff retries. Co-authored-by: Orca <help@stably.ai> * fix(ssh): recover when relay dies during setup instead of wedging Introduce verifyRelayAttempt() to detect mux disposal at each setup phase (consumer session, home resolution, provider registration, PTY reattach). Routes mid-setup connection loss into relay-loss recovery instead of hanging in reconnecting state. * Extract SSH disposal error factory Multiple sites were duplicating the disposal error creation logic with specific message and code values. The renderer uses these to distinguish temporary disconnects (show reconnection overlay) from permanent shutdown (show error toast), so all producers must use the same factory to avoid silent UI degradation. --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> |
||
|
|
1f86f980c5 |
fix(ssh): let terminate reach remote PTYs the app gave up reattaching (#12642)
Every SSH reattach failure abandons the remote terminal without shutting it down, and abandoned terminals then became structurally unreachable — excluded from reattach enumeration and, critically, filtered out of the user-facing "Terminate sessions" action, so a user could not kill them even manually. The core problem is a naming trap: `expired` never meant the remote shell died. It means the app gave up reattaching. It is written on reattach failure, on spawn-time expiry, and in bulk by a relay reset inside a `finally` that runs even when the force-stop threw. So the leases most likely to name a still-live orphan were exactly the ones the terminate path excluded. This change is reachability only. Expired leases are now reachable by an explicit user-initiated terminate, with the relay's response used as evidence: a shutdown that reports the PTY gone tombstones the lease, and leases already proven terminated are left alone. **No automatic kills were added.** Every abandon path was enumerated and none of them proves abandonment: attempts-exhausted knows nothing (the relay never answered), identity mismatch means a *live* PTY belongs to a different pane so killing it would destroy someone else's terminal, and not-found is the one branch with real proof of death — where the process is already gone and needs no shutdown. Per the rule that unprovable liveness never authorizes destroying a session, the abandon paths deliberately leave the process running. Relay-side automatic collection of unattached PTYs is deliberately NOT implemented: the relay cannot distinguish an abandoned terminal from a deliberately detached one, and the unlimited default grace exists precisely so long-running work survives disconnects and host sleep. Any bounded reaper would be killing on absence of evidence. Verified: 3 tests red on main. Negative tests assert each abandon path leaves the shell running and the lease terminable, proven real by mutation — adding a shutdown to the exhausted branch or expiring on identity mismatch each turns them red. Fixes STA-3376. |
||
|
|
39c3c58d55 |
perf(runtime): gate terminal.list visual layouts (#12450)
* perf(runtime): gate terminal.list visual layouts and stop the false writable claim visualLayouts is ~31% of a large terminal.list payload (44,208 B of 137,412 B on a live 134-terminal remote runtime) and has exactly one consumer: the human-readable CLI formatter. Gate it behind an includeVisualLayouts request param that defaults to included, so pre-flag clients are unaffected, and have every --json/internal caller opt out. Also drop the record-backed builder's writable, which was a verbatim copy of connected. terminal.show now states writability explicitly as exactly what terminal.send's PTY gate enforces. * test(runtime): type the payload-size fixture arrays for tsc * fix(runtime): preserve terminal list compatibility * test(runtime): guard terminal list optimization * fix(cli): preserve agent access to terminal layouts |
||
|
|
847c8c852d |
fix(agent-status): correlate manual Claude compact hooks (#12332)
Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
00867f06e2 |
fix(ssh): handle owner displacement and graceful shutdown (#12367)
* fix(ssh): handle owner displacement and graceful shutdown SSH connections can reconnect with valid session proof after network loss or device sleep. When the incumbent owner is still half-open, allow the reconnecting client to displace it outright rather than wait for socket closure — a window that may never close. Retain displaced deliveries for the new owner to rotate. During app shutdown, drain SSH sessions without terminating recovery operations, and retry pending owner grants in case a replacement commits mid-drain. * fix(ssh): handle owner displacement and graceful shutdown Make QuitTeardownStartGate a shared singleton so SSH connects use the same shutdown fence as the main quit path. Track test-connection probes to ensure they complete before final teardown. Guard owner displacement to prevent stale owners from clearing recovery state claimed by newer owners. * fix(ssh): fix flaky test sync and add error code safety check Test was using tick-based Promise.resolve() loops which don't guarantee the async operation has started. Replace with signal-based synchronization that waits for the actual lease flush. Also add nullish-coalescing to error code check to prevent crashes if error is null or undefined. * fix(ssh): fence reset transport opens during shutdown * fix(ssh): keep recovery leases stable across reconnects * fix(ssh): close transports owned by cancelled connect attempts When a connect is cancelled after its transport has opened, that cancelled attempt still owns the transport and must close it — otherwise it leaks. Add disconnectConnection() to close by identity (not by target ID) so a cancelled attempt closes only the transport it minted, without tearing down its replacement's live transport. Track priorConnection to detect whether this attempt opened a new transport or reused an existing one, and close only on abandonment if this attempt owns the session. * fix(ssh): fence old owner proofs and close superseded transports When an owner reconnects with a new proof while an old one is still live, the old proof is now fenced with SUPERSEDED_ERROR instead of retrying indefinitely. The relay also closes stale transports to signal that their recovery generation has been overtaken by a newer one. This ensures overlapping reconnect scenarios complete with the newest proof rather than getting blocked by stale recovery attempts. * fix(relay): re-pin stdin/stdout fds after closing to prevent recycling When the relay closes stdin/stdout to signal EOF to the SSH peer, the OS can recycle those fds (0 and 1) for new sockets or files. If Node still treats process.stdin/stdout as those numbers, subsequent operations corrupt socket clients and trigger shutdown errors. Re-pin the fds by opening /dev/null to keep them occupied and prevent recycling. |
||
|
|
637c7e94c9 |
Add SSH config host picker to add-host dialog (#12334)
* feat(ssh): add SSH config host picker for add-host form Users can now click 'Fill from ~/.ssh/config…' to browse available SSH config hosts in a picker, select one, and have the form automatically prefill with resolved connection details (hostname, port, username, auth). Previously, an 'import' button provided bulk sync on this form—confusing and unhelpful when everything was already synced. That action is now available as a secondary 'Add all' option in the picker. * fix(ssh): import filter preservation and label fallback - Reuse search loader on import completion to preserve active filter inside generation guard - Fall back to hostname when manual host has no label, not empty string - Make alias duplicate detection case-insensitive to match config picker behavior - Validate host availability when restoring project group selection - Add aria-selected attribute to picker options for accessibility * fix(ssh): harden config picker import, alias folding, and host targeting Review findings on the ~/.ssh/config picker + bulk add: - Guard config-host resolution with a generation counter so a late resolve cannot overwrite a later pick or a form the user backed out of; freeze the other rows while a pick resolves. - Stop "Add all N" from re-adopting deleted hosts — it now imports without reAdopt, matching the new-host count it advertises. Settings → Import keeps the explicit re-adopt path. - Fold SSH aliases through a shared normalizeSshConfigAlias for import ownership, delete tombstones, reclaim, picker search, and the save-time duplicate check, which now occupies configHost *and* label like the picker. - Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not when `ssh -G` merely echoes the /etc/ssh system default. - Fail closed with unavailable/setup-not-found when an explicit projectHostSetupId names a non-actionable host instead of silently creating the workspace on a sibling host. - Cache the parsed config for the picker session (refresh on open/retry) so filter keystrokes no longer reparse and Include-expand the file, keep the filter usable during loads, add a Retry on load errors, explain an empty Identity file after a config fill, and drop the always-false aria-selected. * refactor(ssh): centralize host result limit and extract folder group val Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message cannot drift from the host's query limit. Extract findActionableFolderProjectGroup to avoid repeating the folder-host-availability check across the composer hook. * fix(ssh): pass -F to ssh -G when HOME differs from passwd home In E2E tests and sandboxes, isolated HOME can differ from the system passwd home. OpenSSH resolves the default config via getpwuid (passwd), while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F to explicitly specify the config path when they diverge, so ssh -G and the picker resolve the same file. * fix(ssh): verify config host exists before resolving with ssh -G When a user edits ~/.ssh/config and removes a host, the import picker should not fall back to ssh -G's echoed response (which treats any alias as valid). Check the reloaded config file before resolving. - Force reload config on each resolve to catch user edits post-open - Reject aliases not in the current config before calling ssh -G - Add test for deleted alias edge case - Fix workspace-target fallback to honor explicit host selection * fix(ssh): let tombstoned aliases be re-picked in the config picker Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path. |
||
|
|
d7fe9d6bcc |
fix(ai-vault): support session scanning in SSH worktrees (#11004)
* fix(ai-vault): support session scanning in SSH worktrees Add relay-native aiVault.listSessions scanning that discovers agent sessions on SSH hosts. Includes fallback to filesystem crawl for legacy relays, full cancellation support, result validation, and scan coalescing to reduce redundant work. * fix(ai-vault): scan sessions in SSH worktrees with coordinated cancellat - Extract batching logic to `mapRemoteScanBatches` for reuse and proper cancellation checkpoints - Move `AiVaultScanCoordinator` from relay to main to handle concurrent same-key requests with individual cancellation signals - Report scope path truncation consistently across relay and SSH fallback paths - Gracefully degrade relay handler on unsupported platforms instead of aborting startup - Refactor issue display to separate blocking errors, scope notices, and skipped transcript counts * fix(ai-vault): stabilize SSH session scan CI Swallow async WSL relay stdin EPIPE so the live hook-relay shard no longer fails after all tests pass. Merge main, resolve scan/relay conflicts, and align cancellation/host-issue reporting with IPC expectations. * fix(ai-vault): harden session scan cancellation, relay timeouts, and preemption Thread the abort signal through every scan and parse path so superseded or cancelled scans stop promptly instead of parsing every remaining transcript for a caller that already left. Replace the fragile message-text relay timeout check with a typed error code so unrelated errors carrying the phrase "timed out after" no longer suppress the filesystem fallback. Fix scan coordinator preemption so a forced Refresh in one window no longer re-enters as a spurious cancellation in another. Add a host-leg cache for the all-hosts view and cap filesystem concurrency so a single slow remote home cannot stall the whole merge. Co-authored-by: Orca <help@stably.ai> * fix(ai-vault): use stable React keys for scan issue banners Drop array-index keys so react-doctor/no-array-index-as-key passes. Uniqueness comes from host, kind, agent, path, and message. * fix(ai-vault): SSH session scanning with configurable depth limits Implement depth-aware caching and proper scan boundaries to make SSH session scanning reliable in worktrees. Users can now select between faster (250 sessions) and comprehensive (unlimited) history scans. The scanner: - Deduplicates scans across relay, host leg, runtime, and renderer layers - Reuses larger scans to serve smaller depth requests - Properly bounds in-scope discovery per-limit - Fixes timeout enforcement when SSH providers ignore abort signals * Move sessionLimit ref update to useLayoutEffect Keep render pure for React Doctor by deferring ref updates to a layout effect, which still executes before render-dependent effects that consume the ref. * fix(adhoc): stamp version prefix from main, not the feature branch Adhoc builds check out arbitrary refs whose package.json often lags version bumps (e.g. 1.4.165-rc.0 while main is 1.4.168-rc.1). Hourly always builds main so it already tracks the product line; adhoc now resolves the base version from origin/main (or ORCA_ADHOC_BASE_VERSION) so branch builds share that prefix. * Revert "fix(adhoc): stamp version prefix from main, not the feature branch" This reverts commit a26a18eb3fd83f7e7d2db9a6a7c3e02e0f79089a. * fix(ai-vault): fix scoped backfill and coordinator race conditions Resolve race where the last waiter leaving could abort an already-settled scan (add `settled` flag). Redesign scoped session backfill to keep searching through newer files until the scope reaches its requested session quota instead of stopping at the candidate limit; out-of-scope files no longer consume the scope budget. Centralize scan limit normalization and fix error classification for cancelled scans using the proper helper instead of checking Error.name. Disambiguate cache keys using JSON and add cancellation check after scope discovery phase. --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
031115b0a5 |
test(ssh): freeze FrameDecoder clock in framing unit tests (#12356)
Default 4ms maxTurnMs can defer later frames via setImmediate under CI load, so multi-frame assertions after a single feed were flaky. |
||
|
|
1dbf55e4df |
Stop reporting supported Linux hosts as an unsupported remote platform (#12209)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
a000839465 |
Add first prompt to agent session history rows (#12085)
* Add first user prompt to AI Vault session history rows Re-parse transcripts on demand to extract and display the untruncated first user prompt for copy/reuse. List scans omit the body (payload/perf); UI loads it when session details expand. Grok sessions extract the typed ask from <user_query> envelope, skipping injected <user_info> bootstrap rows. Supports Claude, Codex, Grok, and OpenCode agents. * fix(ai-vault): split SessionTime out to pass max-lines lint AiVaultSessionDetails exceeded the 400-line oxlint limit after adding first-prompt UI; move SessionTime into its own module. * fix(ai-vault): handle corrupt transcripts and fix OpenCode prompt captur Corrupt transcripts now resolve null instead of rejecting the IPC call, matching behavior for other unavailable cases. OpenCode SQLite parsing now correctly captures all text parts from the earliest user message only, fixing truncation of large prompts and padding of small ones. Add stale-response guard in the UI to prevent late results from overwriting the current session when tabs switch. Consolidate text slicing via `sliceAtCodeUnitLimit` to avoid surrogate-pair splits across all callers. * test(ai-vault): add first-user-prompt UTF-16 safety tests Ensure truncation at safety limits doesn't split UTF-16 surrogate pairs, preventing corruption of astral characters in captured prompts. * fix(ai-vault): key first-prompt-card by session.id Remounting the card on session switches prevents late responses from a previous load from writing stale data into the component's refs. Also improves conversation-turn key stability. * fix(ai-vault): preserve first prompt after preview truncation * refactor(ai-vault): improve first user prompt capture robustness and per - Add 15s timeout to full-prompt load to prevent indefinite loading states - Extract seedFullFirstUserPrompt helper for reuse across parsers - Prevent AI-generated summaries from becoming the copyable first prompt - Fix truncation detection in OpenCode SQLite by probing for N+1 rows - Optimize text bounding to apply safety limit before toLowerCase - Gate synthetic OpenCode path detection on agent type, not just # presence - Add test coverage for remote execution host handling * Fix FirstPromptCard loading state stranded by stale promise reuse Clears loadPromiseRef during cleanup to prevent the dedupe handle from causing StrictMode remounts to await stale in-flight requests. Stops loading when session becomes non-loadable mid-request. Adds tests for StrictMode double-invoke resolution and main-process timeout scenarios. * refactor(ai-vault): split session parsers into modular files Split secondary-parsers into individual files per agent type (copilot, cursor, hermes, opencode) for improved modularity. Add test coverage for first-user-prompt envelope handling: unwrap user_query tags and reject bare user_info dumps. * fix(ci): clear max-lines and flaky portal readiness check Collapse an accidental multi-line regex wrap in ssh-connection-utils that pushed counted lines to 301. Harden the latched-readiness test's ready transition so CI load can re-observe attach after MutationObserver gaps. * fix(ssh): extract proxy command helpers to pass max-lines Move resolveEffectiveProxy/spawnProxyCommand out of ssh-connection-utils so oxfmt line wrapping cannot push that file over the 300-line lint cap. * capture first user prompt by ordering OpenCode messages by creation time - Add `readOpenCodeMessagesInOrder` to rebuild transcript by timestamp, handling corrupt/partial files gracefully instead of discarding sessions - Extract SSH proxy command tests to dedicated file; add backpressure handling and stderr draining to prevent proxy process stalls - On Windows, reject unsafe characters in ProxyCommand values instead of pretending to escape them; properly format cmd.exe invocation with verbatim arguments - Expand ProxyJump chains into -J plus final hop, mirroring OpenSSH behavior - Decouple portal readiness reapply budget from flip-count budget via explicit constant |
||
|
|
673d7ca926 |
refactor(relay): collapse the duplicated FrameDecoder into one shared module (#12078)
src/relay/relay-frame-decoder.ts and src/main/ssh/relay-frame-decoder.ts were 264 identical lines apart from one default: the relay logs decode faults to stderr when no handler is supplied, the SSH side stays silent. Two copies of framing logic is exactly where a wire-format fix lands in one and not the other. The decoder's contract and buffer already live in src/shared, so the class joins them there. The relay keeps a thin subclass that supplies its stderr default, preserving behaviour for the call sites that omit onError. The SSH copy is deleted and relay-protocol.ts points at shared directly. Verified: pnpm typecheck, 102 tests across the 9 framing/backpressure/ handshake suites, and `pnpm build:relay` for all six platform targets plus the WSL hook relay — the standalone bundle has no new dependencies. |
||
|
|
73c5009b82 |
chore(dead-code): drop ~2k lines of unreachable exports and orphan modules (#12077)
* chore(dead-code): drop 2k lines of unreachable exports and orphan modules Ran knip across every build entry (main, preload, renderer, popout, web, cli, relay, workers, forked sidecars, config scripts) and removed what no entry graph can reach. - 11 orphan modules nothing imported, plus one test that only covered them - 159 unused exports/types, with their now-dead helpers, imports and tests Each candidate was verified against dynamic references before deletion. 42 knip hits were false positives and are kept: shared modules consumed by the mobile/ workspace, the src/shared/plugins/** public API, vendored shadcn primitives, and relay wire-protocol constants held for compatibility. Adds knip.json + `pnpm audit:dead-code` so this stays measurable. Verified: pnpm typecheck, pnpm lint, and 2081 tests across the 73 affected test files all pass. * chore(dead-code): move knip config under config/ Root-level additions are blocked by the root directory guard. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
25fefa4072 |
fix(P1-A): async SSH consumer-recovery persistence and detach on failed connect (#12026)
* fix(P1-A): persist SSH consumer recovery without a sync store flush rememberPtyConsumerRecovery ran on the live establish/reconnect path and called flushOrThrow -> writeToDiskSync, parking the Electron main thread on the profile-directory write. On a stalled or slow profile mount that freezes the whole app during SSH recovery and reconnect. Add Store.flushAsync(): same debounce-cancel and write serialization as flushOrThrow, but awaits writeToDiskAsync instead of blocking. The consumer recovery upsert/remove pair is now async and awaits it, and the SSH callers await through to establish()/reconnect() so ownership is still durable before relay setup continues. In-memory state still mutates synchronously (before the first await), so no caller can observe a torn record and dispose() stays synchronous. * fix(P1-A): detach the SSH session when a connect attempt fails Both failure exits in doConnect dropped the session from activeSessions without calling detach(). claimSshPtyConsumerRecovery only reuses an existing in-memory entry when detached === true, so the next connect attempt fell through to minting a fresh clientInstanceId, discarding the remembered owner lease and its resume identity. Route both exits through abandonFailedSshSession(), which detaches (keeping PTY ownership, unlike dispose()) before removing the session, and tolerates a teardown throw so it can't mask the connect error being rethrown. * fix(P1-A): await async lease persistence in SSH relay teardown Failed connect attempts now wait for 'detached' leases to persist before throwing, preventing reconnects from claiming them before cleanup completes. Detach and dispose operations are now async and await store durability. * fix(ssh): make session detach lease writes retryable on failure Separate in-memory detach (identity recovery, provider cleanup) from lease write persistence so rejected writes can be re-issued without re-running provider teardown or re-minting the session identity. Introduce flushDurableStateOrThrowAsync to flush only SSH-recovery state on the live establish/reconnect path, avoiding snapshot writes of sidecars that belong to quit/startup. Use Promise.allSettled in test reset to prevent one rejected disposal from leaking state into the next test. * fix(ssh): dispose mux on failed establish and propagate sync errors - Dispose mux when session is disposed during establish to prevent resource leak - Propagate synchronous errors in teardown via the completion promise instead of leaving completion undefined - Add test coverage for terminated PTYs that exit mid-reattach and must stay dead |
||
|
|
ce5b639e03 |
fix(P1-B): recover SSH targets and remote file watchers after a network drop (#12032)
* fix(P1-B): recover system-SSH targets after a network drop
Two defects stopped a remote workspace auto-recovering after a blip.
runReconnectAttempt classified failures with isTransientError, which only
matches ETIMEDOUT/ECONNREFUSED/ECONNRESET by errno code or literal
substring. The system-SSH transport — the only transport FIDO2 and
ProxyUseFdpass targets can use — reports network failures as OpenSSH
prose ("System SSH connection timed out"), so the ladder published a
permanent 'error' on the first timeout and the target never came back
without a manual reconnect. isTransientReconnectError adds a
network-shaped prose table on top of isTransientError and is used only on
the reconnect path: connect() keeps the narrow classifier so an
unreachable host still fails fast instead of burning five 30s attempts
and five security-key touch prompts. Auth and passphrase failures stay
permanent on both paths.
runReconnectAttempt also had no generation fence, so a superseded attempt
published its cancellation as a permanent error over the winner's live
connection — reachable when a system-transport proc.onExit schedules a
reconnect while an attempt is still in flight. Cancellation now carries a
stable error name, and both connect() and runReconnectAttempt claim their
connectGeneration and stay silent when a newer attempt owns the state.
* fix(P1-B): retry a dropped watcher overflow marker on real capacity
emitWatcherOverflowToClient published the {kind:'overflow'} resync marker
with controlOverflow:'reject'. A full control queue rejects at admission
with no settlement callback, so the marker was silently discarded and the
remote File Explorer stayed stale until some later watcher event happened
to produce another one — for a quiet tree, possibly never.
The emitter now retains a rejected marker per (client, root) and
republishes it when the sink actually frees up. The existing
onLegacyPtyCapacity signal cannot drive that: it is gated on producer
retention, so it stays silent exactly under the dual-queue pressure that
caused the rejection. RelayDispatcher.onClientCapacity is an ungated
per-client capacity signal that fires on every writer settlement and
drain. It lives on the dispatcher rather than the writer so a retained
marker survives setWrite() replacing the primary sink, and setWrite
notifies capacity once afterwards so the marker does not wait on traffic
that may never arrive.
Retention is bounded to one marker per (client, root), released on
settlement and purged on client detach.
* fix(P1-B): address all review findings on SSH network recovery
Fix four issues from code review:
1. **Bug — admitted overflow markers lost on setWrite**: Retain markers when
settlement fails `ok: false`, not just on admission rejection. Prevents
desynced filesystem trees after SSH sink replacement.
2. **SSH error classification expanded**: Add missing OpenSSH patterns
(`ssh_exchange_identification`, `connection closed by remote`) and new
`isDefiniteSystemSshHostFailure()` classifier.
3. **ControlMaster retry optimization**: Skip second probe when first failure is
already definite host-level (network timeout, refused, unreachable). Saves
~30s per reconnect ladder step.
4. **Overflow flush under dual-queue pressure**: Gate pending marker retries on
control-lane headroom instead of re-attempting on every capacity notification.
Reduces thrash proportional to producer traffic.
Add regression tests for marker republish on sink replacement and validate auth
error detection against live OpenSSH credential rejection messages.
* rm random doc
* fix(P1-B): skip credential-failure retries and recover watcher markers o
- Auth and passphrase errors fail immediately without retry attempts
- Bare "System SSH probe failed (exit 255)" is transient only for reconnect
- Watcher markers survive client invalidation when switching SSH connections
- Add network error patterns: "lost connection", "remote end closed"
|