mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fed1eca486f9c88bceab5ce471658e9b092fe294
309
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fed1eca486 |
test: stop restating internal tuning constants, keep the ones that are contracts (#23950)
Removes ~74 assertions of the form `expect(SOME_CONSTANT).toBe(<literal>)` where the literal is an internal tuning value — a timeout, retry count, debounce interval, cache TTL, circuit-breaker window, Tailwind class string. Those cannot fail for any reason a user would notice: they fail only when someone deliberately changes the number, and then the test is simply updated. They are copies of the declaration. The same pattern is NOT junk when the exact value is observable outside this process, so those were deliberately kept: - terminal byte contracts: `\r`, `\x03` ETX, Kitty escapes, `\x1b[?1;2c`; - wire and capability values: `agent.launch.v2`, protocol 3 / min-compatible 2, daemon per-feature boundary versions (a daemon survives app updates, so those pin what an old field daemon may be trusted with), relay header tokens; - security invariants: the `127.0.0.1` bind default, an empty iframe `sandbox`; - values external processes read: exit code 78 (EX_CONFIG) and exit code 3 (systemd `RestartPreventExitStatus`), `ORCA_AGENT_SESSION_SPAWN_TOKEN`, `npx skills …` commands users paste, on-disk journal schema versions, the `orca_<hash>` filename prefix the fish sweeper matches; - third-party names: expo-router's `unstable_settings` / `ErrorBoundary`, iOS Safari's 16px zoom threshold. Where a case asserted a relation rather than a literal — `A < B`, a sum of parts, a cap compared against a sibling budget — the relation stays and only the literal went. Test-only changes: no production file is touched and no test file is deleted. |
||
|
|
70475e0228 |
test: stop testing private internals through exports no caller needs (#23829)
Third audit wave. The detector looked for production modules exporting three
or more symbols that no production file imports — only tests do. That shape is
the authoring gate's fourth question failing: a test needing a production seam
no caller needs belongs at the real boundary instead.
Most hits were detector false positives and were left alone; the scanner misses
re-export barrels and dynamic imports, so every module was re-verified with rg
before any edit. Where a private predicate's behavior was already covered
through the module's real entry point, the duplicate cases are gone and the
symbol is module-private again. Where it was NOT covered anywhere else, the test
stays — this audit removes tests, it does not author replacements.
Production code deleted where tests were its only callers: the superseded
`filesystem-directory-listing-limit` module, the unused
`format{Hourly,Daily,Adhoc}Version` helpers and their orphaned prerelease
identifiers, the dead `filterByAutomationListSearch*` family superseded by
`matchAutomationListSearchRowKeys`, and the dead
`getAiVaultResumeWorktreeTargetStatus` copy of the live workspace branch.
Also drops two call-shape source greps in `relay-sweep-schedule.test.ts` that
asserted `index.ts` spells `jitteredSweepIntervalMs(30_000)`; the jitter math
has a behavioral owner at the top of the same file. The structural census that
counts role-gated vs total `setInterval(` calls stays — an ungated sweep runs in
every cell, and nothing else can catch that.
|
||
|
|
6e1b7e7fa3 |
test: remove junk tests that assert source text instead of behavior (#23815)
Deletes 101 test files and trims 112 more, all matching documented junk patterns: exact source/import/string greps, copied inventories and export lists, duplicate invocations of a contract another test already owns, typeof-shape checks TypeScript already enforces, and self-comparisons. The largest group read a production `.ts` file and asserted on its text — for example a TaskPage test that required the source to contain `selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null`. Any behavior-preserving rename broke it; no behavior change ever did. Production-side follow-through: exports that only these tests imported are de-exported or deleted, stale comments pointing at removed censuses are dropped, and the reliability-gate registry, `cloud/package.json` test lists, and orphaned source-reading helpers are updated so nothing references a deleted file. Two files kept their real coverage and lost only the census scaffolding: `agent-status-producer-census.test.ts` now drives all five producers end to end instead of grepping the source tree, and `config-toml-trust-stale-writes` replaces an export-list parity check. |
||
|
|
2ca4ecbc61 |
feat(orchestration): let a structured chat run orchestration as itself (#22568)
* feat(orchestration): inject the Orca session id into structured children and let the CLI act as it Every structured session's child (native Claude, native Codex, and the terminal view) carries ORCA_AGENT_SESSION_ID and reaches the Orca CLI. The CLI sends the id in the orchestration envelope; when present it is the caller, and a caller flag naming anyone else is refused before any request. The id is stripped from inherited PTY env and from the SSH host-CLI passthrough, and crosses into WSL so the host can refuse the cross-host claim. * test(orchestration): pin session id injection for native Claude, native Codex, the terminal view, WSL, PTY inheritance and SSH * test(orchestration): pin one caller precedence rule across every CLI verb that names its caller Adds the per-verb table (flagless acts as the session; a conflicting --from or --terminal is refused before any request; the session's own spellings are accepted), the enumerated guess population with its positive control, the structured worker's own handle, the identity-less refusal for an older child, the unchanged terminal agent, and the envelope. dispatch-show's --from only fills preview text, so it passes through unfenced and a session's flagless preview names the address the real dispatch writes. * refactor(orchestration): keep the identity-less marker reader to the marker; the id is checked first * test(orchestration): pin that a host refusal of the session surfaces verbatim from the CLI * fix(orchestration): keep the identity-less marker beside the id for CLIs that predate it A CLI older than the id, reached through a global install when a shell rc resets PATH, would otherwise guess a sibling's terminal in a chat that no longer carries the marker. It refuses on the marker instead; a current CLI checks the id first, so the marker never makes a session with an id identity-less. * fix(orchestration): refuse a conflicting --from on gate-list and task-list scoped by --run A --run listing needs no caller, so both handlers skipped the resolver and a --from naming another actor was dropped silently under a session. The conflict check now runs on that branch too; terminal callers are unchanged. * fix(orchestration): name this app's CLI by absolute path for a structured session's login shells A provider can run each command in a login shell: Codex runs zsh -lc, and the profile rebuilds PATH, putting a global install (possibly an older Orca) ahead of the directory Orca prepended. ORCA_CLI_COMMAND, which an agent resolves the CLI from first, is now the absolute launcher in that directory (the native launcher on Windows), so no shell's startup files can swap it. The PATH prepend stays for shells that read no profile. Found by the live coordinator run of the next PR. * test(orchestration): pin a structured worker's CLI command as this app's absolute launcher * test(orchestration): run the zsh login-shell arm in the real-shell lane that installs zsh The ordinary Linux unit lane has no /bin/zsh, so the zsh arm failed there with ENOENT. It moves to a live-shell file registered in the shell-contracts lane; the bash arm keeps running in every lane. The lane guard's detector now also sees a zsh spawned through the ProcessSpec program field, which is how this test escaped it. * fix(orchestration): omit a structured child's CLI command when no launcher resolves, and pin its instance A bare `orca` fallback named GNOME's screen reader on packaged Linux, and an inherited value named another app's CLI. The builder now deletes any inherited value, sets the absolute launcher only when one resolved, and pins ORCA_USER_DATA_PATH so a current CLI dials the instance that minted the id. Renames the marker reader to hasStructuredSessionMarker and records why the terminal view carries the id without the marker. * fix(terminal): name this app's CLI launcher by absolute path in every local terminal ORCA_CLI_COMMAND meant three things by lane: an absolute launcher for a structured session, a bare name for WSL, and nothing for any other terminal, so a structured session's terminal view lost it. Local terminals now get the same absolute launcher the structured lane gets; WSL keeps its guest command name, and a terminal whose launcher does not resolve still gets none. * feat(cli): hand a command to the session's own CLI when another Orca CLI was invoked A login shell can reorder PATH behind a global install, and an agent or its helper script can run bare `orca`, so the binary that answered depended on the agent following instructions. Orca's packaged launchers and bare-orca shims now export ORCA_CLI_SELF (outermost wins). At the CLI entry, when it names a different launcher than ORCA_CLI_COMMAND, the command re-runs once through the named launcher with ORCA_CLI_REEXEC=1 and exits with its status; both variables are consumed so no child inherits them. Dev launchers export no self on purpose, WSL and SSH names never qualify, and a launcher that cannot start leaves the command to run here. The Windows launcher no longer rewrites ORCA_CLI_COMMAND; the legacy ask protocol normalizes its resume command itself. * refactor(orchestration): declare which flag names the caller on each spec and refuse at the CLI entry Each handler hand-classified its --from/--terminal as the caller or a target, and the refusal of a conflicting caller flag ran inside the caller resolver plus two standalone calls for --run listings, so a new verb that read its flag raw would pass a sibling's handle to a pre-session host. Specs now declare identityFlagRoles, the CLI entry refuses a conflicting caller flag once from the spec, the resolver only applies the id-wins rule, and a test fails any orchestration verb that accepts --from or --terminal without classifying it. * perf(cli): keep the session caller check off the actor codec's module graph The check runs at the CLI entry for every command, and the actor codec pulls zod through the session record. Compare the session's own spellings as plain strings instead. * refactor(cli): spell a session's address from the one prefix constant, off the codec's module graph The Orca session address prefix moves to a leaf module with no imports, re-exported by the address codec, so the CLI entry check derives `session:<id>` from that constant instead of re-typing it and still stays off the codec's zod graph. Prose and test names say caller or Orca session id, not actor. * refactor(orchestration): drop the session id's terminal-view spawn now that the handoff is gone The terminal handoff was removed, so no terminal is ever a structured session: - delete the terminal-view identity env and its WSL passthrough, and their tests; - strip the session caller keys from every terminal's env unconditionally; - the CLI's own-address spelling moves beside the injected id in src/shared, with a test pinning it to the address the host's party resolver gives that session. * fix(terminal): run the Codex launch preflight through the CLI the terminal names Packaged Linux names the userData shim in ORCA_CLI_COMMAND, while the preflight ran the bundled launcher behind it. The CLI saw a different launcher and handed the preflight off to the shim, booting Electron twice before every codex launch. * revert(terminal): keep terminals on main's ORCA_CLI_COMMAND and Codex preflight Only a structured session needs an absolute ORCA_CLI_COMMAND; local terminals go back to naming none (WSL keeps its guest command), and the Codex launch preflight goes back to the bundled launcher. The CLI handoff is scoped to sessions, so a terminal's preflight can no longer be handed off and start Electron twice. This reverts commit |
||
|
|
6c4625d7ff |
fix(terminal): main records every tab close, and seeding reads the records (#22955)
* fix(terminal): every explicit terminal close commits through one main transaction
A renderer save cannot shrink terminal membership once main owns a repo's
topology, so desktop tab and pane closes, CLI split-pane closes and mobile
split-pane closes only became durable when the killed process's exit retired
the surface. A close whose kill failed or threw, or whose exit was never
certified, came back after a reload.
Every close now reaches closeTerminalSurface: the renderer sends an explicit
intent for user and cleanup closes, the CLI and mobile split-pane closes commit
the pane after their stop, and the headless and relayed mobile closes reuse the
same commit. A failed flush keeps the in-memory removal and no longer cancels
the kill. Exit retirement is unchanged.
* fix(terminal): tell the desktop renderer to drop a split pane main closed
A CLI or mobile close of one pane in a split commits the pane in main, but the
desktop kept showing it until reload when no exit arrived to remove it. The
close now sends a leaf-addressed notice: a mounted pane closes by leaf id, and
a parked tab collapses its stored layout. Addressing by leaf makes the notice
and the renderer's exit handling no-ops after each other, which replaces the
numeric pane-id notice that could close the whole tab when the exit won.
* fix(terminal): a pane close never widens into a whole-tab close
A leaf-addressed close fell through to the whole-tab close whenever main's layout no longer
held that leaf as one of several. Main's exit handling retires an exited split pane from the
saved layout, so closing that pane afterwards (the exited-pane overlay's Close, or a CLI close
whose stop delivers the exit first) removed the whole tab, live sibling included, and the
next renderer save could not restore it. A pane close is now a no-op unless its leaf is in a
multi-pane layout.
Also updates two mobile split-close assertions to expect the leaf-addressed notice, and adds a
test that a relayed mobile close of a renderer-listed tab still reaches the renderer's pin guard.
* test(terminal): cover the PTY-handle branch of a CLI split-pane close
The existing CLI split test resolves its handle through the renderer graph, so the branch
that closes a runtime-owned pane by its PTY handle had no test failing without its commit.
* fix(terminal): main records every terminal tab close, and seeding reads the records
* test(terminal): pin the renderer close mirror against an early SSH pull, and main's record writes against later saves
* fix(terminal): a CLI pane close with an unconfirmed stop closes only that pane
`orca terminal close <handle>` on one pane of a split used to close the
whole tab, live sibling included, whenever that pane's stop could not be
confirmed (for example an unreachable SSH host). An unconfirmed stop is
unverifiable, not a reason to drop siblings: the close now commits only
that leaf, tells the renderer to drop that leaf, and leaves the owed kill
to the controller's existing SSH pending-kill path.
On a host where no renderer lists the tab, main now also removes the
closed pane from the paired-client snapshot (with its retirement proof),
since no exit may arrive to do it.
* docs(terminal): correct the pull merge's record-safety comment
The mirror is written by closeTab before main commits, so the claim that only a
main-committed close writes a record was inaccurate; also reflow a split comment.
* refactor(terminal): one resolver decides whether a pane close becomes a tab close
Every explicit close now states its target as `{kind:'tab'}` or `{kind:'pane', leafId}`; no
optional leaf id silently means the whole tab. Main resolves a close it started in exactly one
place, reading the copy of the tab's panes its layout owner holds (the renderer-published layout
for tabs the desktop renderer lists, main's session layout otherwise). Only `last-pane` escalates,
through the existing tab path so the renderer's pin guard still runs; an unknown pane never widens.
- The CLI and phone paths drop their per-site sibling counts for the resolver.
- The notifier splits into a tab-only close and a leaf-addressed pane close.
- The headless tab closer takes a parent tab id, so a pane row cannot reach it.
- A phone close of one pane on a host with no desktop window now stops and closes only that pane.
- A phone close of one pane with no live process record closes that pane, not its tab.
* fix(cli): an unverifiable stop says the close happened
`orca terminal close` still exits 1 when the process stop cannot be verified, but its message now
says the terminal was closed and names the host's reason, instead of "close failed". It promises
that the kill retries on reconnect only when the SSH relay itself never answered the stop, the one
case a recorded kill order backs.
* fix(terminal): a phone pane close commits even when its kill fails
A paired client's close of one pane threw `terminal_close_failed` before committing anything when
the controller reported the kill failed, so the pane stayed. The kill is now best-effort, as it is
for a whole-tab close: the pane's removal always commits and the failure stays on the PTY's
liveness verdict.
* fix(terminal): a pane close widens only when a copy shows it is the last pane
The close resolver read an owner copy that records no panes as "the tab has
one pane", so a CLI close of one pane of a split, addressed while the
renderer listed the tab before publishing its panes, closed the whole tab.
Every copy now counts only if it records at least one pane, read in the
owner's order with the published rows as the last fallback, and a pane
close widens only when a copy lists that pane as the tab's only one. An
unsplit tab whose saved layout predates its pane still closes: its
published row names the pane.
* fix(cli): promise a kill retry only when the host recorded the kill
The close receipt inferred "the kill retries when the host reconnects" from
the stop reason's text, which a new transport message or a reworded error
would silently break.
An explicit close now records the replayable kill order when its stop goes
unconfirmed, before sending the follow-up kill (whose own failure is
recorded only once its RPC settles), and reports that on the receipt as an
optional `pendingKillRecorded`. The CLI promises the retry only from that
field, so an older host, which never sends it, gets no promise.
* test(pty): justify the controller cast the recorded-stop tests extend
* fix(terminal): parse the close target with typed narrowing
The low-evidence lint gate rejects Reflect.get and broad object parameters,
which failed static analysis. Narrow with 'in' checks instead and cover the
boundary parser's accept and reject cases.
* test(terminal): drive the close-record tests through the durable store
* fix(terminal): a desktop tab close is not refused by a split that bound while it waited
The renderer has already removed and killed a tab it closes, so its close intent now skips the
owner fence phone and CLI closes use. Before, a split pane whose binding was admitted between the
close request and its durable write made main refuse the close, and the tab came back on the next
launch whenever its processes did not exit.
* chore(terminal): note that closedByLayoutOwner goes away once main owns the terminal layout
* chore: take the base branch's lockfile, which a merge had reverted
* test(terminal): reload the close-intent fixture through the SQLite profile store
Main now requires a SQLite profile-state authority for a writable Store, so the save-and-reload
close tests build and reopen their store through the shared SQLite test harness.
* docs(terminal): state the close-record rule in the merge's active-workspace comment
* fix(terminal): the first close of a tab keeps its record, and every lookup honours the TTL
* test(terminal): give the relay reattach close record a recent close time
* fix(terminal): a close that removes a listed tab records itself; only an echo is skipped
The echo of a close main already made never finds the tab listed, and the close transaction already skips it when a live record exists. The record helper kept the first record as well, which only ever applied to a close that did remove a listed tab, and there it kept a stale reason and TTL.
|
||
|
|
93d8b1f042 |
fix(ssh): complete keyboard-interactive MFA prompt handling (#15588)
Honor SSH keyboard-interactive prompt echo and empty responses, reuse login passwords without replaying rejected values, and stop cancelled or stale credential requests from continuing authentication or restoring the cache. Original implementation: Junho Kim (#8750). Port and follow-up work: Allen (#15588). Verified with 2,773 SSH/credential tests, full typecheck, changed-code quality, a production Electron build, real-socket MFA fixtures, and rendered UI checks. Fixes #8622 Co-authored-by: Junho Kim <arkimjh@illinois.edu> Co-authored-by: microdaery <microdaery@gapp.nthu.edu.tw> |
||
|
|
bdb897b735 |
Respect disabled OpenCode variants and refresh WSL settings safely (#23328)
Respect disabled OpenCode variants, preserve explicit config ownership, and refresh WSL guest settings safely across reconnects. Based on Harshul Rathod proposal #22805. Co-authored-by: Harshul Rathod <harshulrathod1640@gmail.com> |
||
|
|
080c4ad62a |
fix(ssh): name the missing unzip when Bun archive extraction cannot start (#23298)
* fix(ssh): name the missing unzip when Bun archive extraction cannot start Extracting the downloaded Bun runtime shells out to `unzip` on POSIX hosts, which a minimal Debian/Ubuntu install does not ship. runProcess rejects a missing program with a bare `spawn unzip ENOENT`, which the caller's non-zero-exit branch never sees, so the operator got an errno instead of a remedy. Translate that one errno into a message naming the tool and the ORCA_UNZIP_BIN override. * fix(ssh): reuse the canonical absence predicate for extractor launch failures isDefinitiveAbsence is the repo's single errno allowlist for "definitively not there", and it also covers ENOTDIR — which spawn throws synchronously when a configured ORCA_UNZIP_BIN has a regular file for a parent. That case previously escaped as a bare `spawn ENOTDIR` naming no path at all. Also correct the comment: a deleted working directory is not a second source of these errnos, because runProcess leaves cwd unset and the child inherits the parent's without resolving it. Verified on macOS, Linux and Windows. |
||
|
|
3eb1adec20 |
ci: reuse fixture setup and scope localization extraction (#23291)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
46907de602 |
fix(ssh): recover abandoned caches without deleting live dependencies (#23281)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
6433c3c7a3 |
fix(ssh): preserve interrupted installation outcomes through retries (#23273)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
da6d483ab9 |
fix(vault): read OpenCode SQLite inside WSL and SSH hosts (#23128)
* fix(vault): read OpenCode SQLite on WSL and SSH execution hosts * fix(vault): bound host setup and preserve cancellation across readers * fix(vault): keep WSL discovery visible and isolate probe tests * fix: retry local Vault runtime downloads without reserving remote stages Preserve verified remote cache reuse and latch only unresolved host work. Update WSL source-guard and remote dedup test integration. * fix(queue): discard aborted requests before respawn * fix(vault): preserve host paths and recover setup after reconnect * fix(ssh): fence every runtime platform probe across reconnects * fix(vault): reject setup results from superseded SSH connections --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
d17a17684b |
Reduce redundant CI runs, pnpm uploads, and fixture startups (#23145)
* Reduce redundant CI runs, store uploads, and fixture processes * Avoid repeating draft-independent mobile checks on readiness --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
6fc3cdcad6 |
Bundle Bun for headless Orca and profile persistence (#22635)
Bundle a pinned, verified Bun runtime for headless Orca so existing Node launch commands can hand off before opening a profile. Keep desktop execution on Electron. Add the Bun SQLite adapter and terminal backend, bounded shutdown, process inspection and cross-platform artifact qualification. Keep future managed SSH deployment separate from current production launch paths. |
||
|
|
82412dab8b |
Persist profile state in SQLite with background writes (#22612)
Migrate profile state to SQLite and move writes and backups into a background worker. Acknowledge terminal, SSH and automation changes only after durable saves. Preserve JSON import, recovery, rollback and compatibility exports. Validate migration, worker failures, maintenance, cross-profile moves and terminal lifetime races with unit, integration and end-to-end coverage. |
||
|
|
18bbf6f209 |
refactor(orchestration): resolve every caller and target to one orchestration party, keyed by the Orca session id (#22555)
* refactor(orchestration): resolve a session caller at the dispatch entry and bind it by actor WIP: entry resolver on both dispatchers, caller identity through run scope, actor-keyed Run binding and unbind, actor writes on bind/create/assign, and actor-aware mail ownership exclusions. * test(orchestration): pin actor-keyed Run binding, stale-actor precedence and actor mail ownership Keeps the non-session dispatch path synchronous so terminal and session-tab streams reach their handler without an extra async hop. * feat(orchestration): resolve session callers before params parse and pin every verb on both routes A session caller need not name itself in a param that requires a caller: the entry binds the declared caller to the session before the schema runs. Session refusal codes pass through the RPC error map, and DB row reads added here carry their SAFETY rationale. * test(orchestration): pin the SSH check's pane through the caller-identity lookup * test(orchestration): pin a Run-less session's direct check and receipt binding without a caller param Drops the actor clause from self-dispatch detection: a creator and assignee can only share an actor when they already share a handle or pane. * test(orchestration): pin a session's own Run for plain and group sends and the assignee-only mail sweep * test(orchestration): name the party-naming field population for its role * fix(orchestration): clear a worker actor an older binary's unbind leaves, and refuse a worker without its identity An older binary unbinds a structured worker's Run by clearing handle and pane, which leaves the actor looking like a handle-less chat binding. The every-open repair clears that shape for actors recorded as structured workers only, and the resolver refuses a worker session whose worker identity is gone, so this binary never writes the shape itself. * refactor(orchestration): read a Run's coordinator actor through its generation, and give a party's addresses one owner The coordinator actor now counts only at the consumer generation it was written at, so a Run binding matches a session by that rule alone. It replaces two mechanisms for the same fact: the rule that an actor beside a handle it did not bind with never matches, and the open-time repair that cleared a structured worker's actor an older binary's unbind left. Every write of an older binary that rebinds or unbinds bumps the generation, so both shapes stop counting by themselves, including a chat's actor after a rebind then an unbind, which neither old mechanism caught. createRun and the same-coordinator actor correction write the generation in the statement that writes the actor. The resolver still refuses a structured worker whose worker identity is gone; its predicate moves next to the worker identity lookup. addressSpellingsOf is the one owner of the addresses a party is reachable at (a structured worker's handle and session actor). createRun, bindRun, the coordinator unbind and the declared-caller check use it instead of hand-built sets, and each has a test at both of a worker's addresses. * fix(orchestration): say a released session is not running, and scope the pane-key credential claim to requests without a session A released lease is evicted, not ended: a user turn resumes the session, so the refusal now says it is not running right now instead of that it has ended. The worker pane-key comment claimed the random leaf is what stops anyone who learns a session id from acting as the worker. On the same-host socket route the session id now names the worker with no token by design; the pane key still matters where a request names no session (a PTY agent's, or the paired-client route, which refuses session ids). * test(orchestration): pin the same-coordinator actor correction's generation write on a row an older binary wrote * refactor(orchestration): resolve session callers by the bare Orca session id, typed apart from its address Carries the Orca session id rename into caller resolution, Run binding and Dispatch creation. The caller identity holds the bare `orcaSessionId`; the `session:<id>` spelling is derived by formatOrcaSessionAddress wherever mail needs it. `OrcaSessionId` and `OrcaSessionAddress` are distinct branded strings. Only isOrcaSessionId and parseOrcaSessionAddress produce an id, and only formatOrcaSessionAddress produces an address, so comparing the two is a type error. The Orca session id columns on the row types carry the id type. Every reader that compares a stored id with a mail address now compares like with like: the active-Dispatch ownership check formats the stored id (orcaSessionAddressSql), and the stray-mail sweep, the creator nesting lookup and the recorded-worker check bind a parsed or typed bare id. The Run-mailbox ownership check keeps its existing handle comparison beside the session one. * fix(orchestration): accept a worker's ask to every address its coordinator is reachable at A worker's preamble names its coordinator as `session:<id>` when the coordinator is a structured session, but ask only accepted `run:<id>` or the coordinator's terminal handle. A chat coordinator has no handle, and a coordinating structured worker has two addresses, so ask --to the session address was refused as dispatch_run_mismatch. The check now takes the Run's current coordinator addresses from addressSpellingsOf(runCoordinatorKey(run)). * refactor(orchestration): require every caller-identity entry point to be handed the resolved session The resolved session parameter was optional on resolveRunScope, resolveOrchestrationCaller, orchestrationCallerIdentity, resolveDispatchCreator and resolveDispatchCallerWorktreeId, so a method that forgot to pass it would compile and silently treat a chat's session address as a terminal handle. It is now required and typed `OrchestrationSessionCaller | undefined`, so leaving it out is a type error. Every call site already passed it; no behavior changes. * fix(orchestration): deliver mail sent to a session address to the mailbox that session reads A send to session:<id> was resolved like a terminal handle: no live pane, so a chat's current Run was missed (two Runs read as ambiguous), a Run-less chat was refused though it reads its direct mailbox, and a structured worker's session address never reached its Dispatch. Resolve a worker's session address as its handle, a chat's by its bound Run, and fall back to the chat's durable direct mailbox while it runs on this host. * refactor(orchestration): resolve every party through one resolver with one mailbox address A structured worker is reachable at its handle and at its session address, and a chat only at its session address. Callers and targets were each compared or resolved at their own site, some against one spelling and some against every spelling, and the sites that did neither refused or misrouted. Add orchestration-party: resolveOrchestrationParty (and resolveOrcaSessionParty for a bare id) is now the only place an address becomes a party. The session caller, the declared-caller check, ask's target and inbox's filter all resolve through it, so caller and recipient resolution cannot disagree, including on a recorded worker whose identity this host lost. Every session-to-party step passes through canonicalOrcaSessionId, the seam later lineage canonicalization plugs into. mailboxAddressOf replaces addressSpellingsOf: a party has one mailbox address (a worker's handle today), and Run binding remembers and reroutes only that. A request with no session id that declares a session address as its caller now gets the party it names: a worker's handle, as if it had named it, or a session_caller_chat_not_declarable refusal for a chat, which is identified only by the session id its own environment sends. * fix(orchestration): route dispatch and mail targets through the party they name dispatch --to a worker's session address stored that address as the assignee handle, so the worker, which reads by its handle, never saw the Dispatch. The assignee now resolves through the party resolver: a worker's either spelling assigns its handle and records its Orca session id. A chat cannot receive a dispatch yet, so dispatch --to a chat is refused with session_chat_not_dispatchable before any row is written. Recipient routing resolves the party once and, for any session-backed party, finds its Run by its durable binding rather than by a live pane. A structured worker that coordinates a child Run while assigned in its parent got mail at its parent Dispatch mailbox whenever its session was evicted, which it never reads while bound to the child Run. Terminal handles keep the same live-pane lookup. * refactor(orchestration): delete the SQL that matched a worker's second spelling With every caller and target resolved to one mailbox address, no writer stores mail under a structured worker's session address: sends resolve it to the handle, a declared session caller is rewritten to the handle or refused, replies answer a stored canonical sender, and questions, answers, escalations, federation and legacy mail write run:, dispatch: or handle addresses (legacy rows are legacy_direct, which these queries never read). The branches that matched that spelling were unreachable and are removed: - the session-address OR in activeDispatchOwnsAddressSql, back to the assignee handle alone; - the session branches in routeForeignDirectMessagesToOwnedMailboxes and findActiveDispatchForDirectMessageOwner, which return to the base-branch form. Tests that inserted such rows directly now pin the behaviour through the verbs: both spellings of a worker land in the one mailbox it reads. * test(orchestration): pin one canonical address per party across every caller and target - every target param in ORCHESTRATION_TARGET_PARAM, for a worker's handle and session address and a chat's session address: send, ask, dispatch (a chat refused with no row) and inbox; - a declared session-address caller on a request with no session id: a worker acts as its handle, a chat is refused; - a worker that coordinates a child Run while assigned in its parent gets mail in the child Run with no live pane; - no mail writer stores to_handle or from_handle as a worker's session address; - the caller a session id resolves to and the recipient its address resolves to agree in every party state, and every session-to-party step goes through canonicalOrcaSessionId. * refactor(orchestration): drop the session caller's chat-to-terminal-view handoff remnants The structured-chat terminal handoff is gone from the base branch: a session is owned natively only. - the lease rule no longer speaks of either owner or a handoff keeping identity; - an in-progress owner change (new-owner-proving, recovering, manual-recovery, which the base branch still produces) is refused as changing owners, not as switching between chat and terminal view; - the native to terminal-view to native identity test is deleted, and the terminal-evidence test no longer describes that evidence as a terminal view's. * fix(orchestration): read a declared caller param by typed access, not Reflect.get |
||
|
|
c220d92c03 |
fix(codex): Codex 0.157+ starts in Orca-managed homes instead of failing with SUN_LEN (#22878)
* fix(codex): turn off Codex daemon auto-start in homes whose socket path exceeds sun_path
Codex >= 0.157 auto-starts a background app-server daemon and connects to
<CODEX_HOME>/app-server-control/app-server-control.sock. Orca's managed homes
under userData make that path longer than sun_path (104 bytes on macOS, 108 on
Linux/Windows), so every interactive codex in an Orca terminal failed with
'path must be shorter than SUN_LEN'. The config mirror now writes a marked
[features] daemon_auto_start = false into only those homes, removes it when the
home fits, and never promotes it into ~/.codex.
* fix(codex): address review of the daemon socket guard
- A runtime config.toml holding only Orca's daemon override no longer reads as a
config-sync stall, so users without ~/.codex/config.toml get no false
"missing" warning in the accounts pane.
- The legacy shared-home refresh re-applies the guard, so retained pre-rollout
panes keep daemon auto-start off after a system-default launch.
- Warn once when an inline `features = {...}` or `[[features]]` blocks the
override instead of failing silently.
- Rename the upsert's TUI-specific internals now that it serves any table.
* fix(codex): apply the daemon socket guard even when the settings mirror stalls
When the settings write-back or mirror refused (unreadable baseline, failed
write to ~/.codex, unreadable source), the whole pass returned before the
daemon guard was applied. A home whose config.toml predates the guard then
kept failing with SUN_LEN on every launch for as long as the stall lasted.
The guard now lands on those paths too; the mirror itself is unchanged.
* fix(codex): guard managed account homes when ~/.codex/config.toml is missing
* test(codex): keep reset-credit ownership checks scoped to the retry, not service construction
* test(codex): build the account mirror test without a type cast
* fix(codex): keep blocking WSL ownership checks off the no-config guard pass
Guarding account homes with no ~/.codex/config.toml ran the WSL ownership
check, a synchronous wsl.exe call per account, at startup before the window
opens and on every account switch. WSL homes are guarded by WSL launch prep,
so that pass now covers host homes only.
---------
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
|
||
|
|
7ea01279cd |
feat(search): bundle ripgrep for local, WSL, and SSH search (#22396)
* feat(search): bundle ripgrep for local, WSL, and SSH search Ship @vscode/ripgrep-universal's prebuilt rg for all six relay platforms in every desktop artifact. Local and WSL searches spawn the bundled binary and drop the git ls-files / git grep fallbacks; SSH deploys upload the remote's binary once per ripgrep version and the relay prefers it over PATH rg. * fix(search): address bundled ripgrep review findings - Key the SSH ripgrep cache on the binary's content hash; a package bump is the only update step - glibc verifier: read arch tokens below the slice root and accept static ELFs (arm64 release blocker) - Ship ripgrep/PCRE2/musl license notices; bundle rg with orcad - Packaged builds never spawn a bare rg; report fd pressure as transient - SSH: install rg before sweep/GC, size-validate installs, back off instead of disabling on launch failure - Scope Dependabot to @vscode/ripgrep-universal; revert unrelated lockfile churn * chore(search): drop bundled-ripgrep reference doc; assert full packaging layout parity * refactor(search): one entry point for spawning the bundled ripgrep Local Quick Open, Quick Open path search, the Explorer name filter, and runtime text search each repeated the same three steps: resolve the bundled command, spread in the WSL distro, spread in the WSL shell expression. Fold that into spawnBundledRipgrep so one place owns the rule that a bare 'rg' must never reach spawn, and simplify the resolver's command/packaged checks. Restore the AGENTS.md ripgrep rule dropped alongside its reference doc in |
||
|
|
8fb0ba671b | fix(ssh): keep the generation floor when a target is removed and recreated (#22146) | ||
|
|
88f2f01061 |
fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY (#19430)
* fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY Root cause: daemon-launched-child.ts forks the detached terminal daemon with detached: true, which escapes the POSIX process group (setsid) but never the systemd cgroup. Every PTY the daemon owns is itself an undetached direct child of the daemon (native-pty-spawn.ts). Under a combined systemd unit (Type=simple, KillMode=mixed, per docs/reference/headless-linux-server.md), a systemctl restart/stop SIGKILLs every process still in the cgroup at the stop timeout -- the daemon and every live terminal -- even though the codebase already has a fully-built adoption/reattachment path for a surviving daemon (orcad-entry.ts's refreshRestoredOrchestrationAuthority + reconcileLegacyWorkerTerminals, gated on daemonOwnsFreshPersistentPtys()). That path never fires today because the daemon never survives long enough. Fix: when systemd is actually supervising the process and the OS user has a reachable systemd --user manager (isDurableDaemonScopeSupported(), Linux only), launch the daemon via systemd-run --user --scope so it lands in a cgroup that is a sibling of the service unit's cgroup, not a descendant of it. A systemctl restart of the combined unit then never reaches it. Any failure of the scoped launch (no reachable bus, D-Bus policy rejection, etc.) falls back transparently to the existing plain fork() launch, so every platform/environment without this capability is unaffected. The daemon self-detects its own resulting cgroup scope via /proc/self/cgroup (detectOwnCgroupScopeUnit()) rather than trusting the launcher's intent, and publishes it as cgroupUnit in its pid record and orcad's health/readiness payload (health.terminalDaemon.cgroupUnit), so a running deployment can be observed to confirm the fix actually engaged. No new session registry is added: the existing daemon pid-record + adoption protocol (publishDaemonPidFile, daemon-pid-record-quarantine.ts's dead-record reclaim, refreshRestoredOrchestrationAuthority) already implements durable, crash-safe reattachment for a surviving daemon -- it was simply never exercised against a full unit restart before now. Proven via a systemd-in-Docker recovery test: a live PTY session's shell process, its daemon, and the daemon's cgroup scope were all confirmed unchanged across a real systemctl restart of a Type=simple/KillMode=mixed unit, while the main process pid changed (confirming the unit actually restarted) and the new process's health payload recognized the surviving daemon as adopted and live. A fresh write into the same PTY post-restart reached the same running shell. Ordinary terminal create/work/release and the #18789/#18790 worker-release reap-fix regression tests are unaffected. Fixes stablyai/orca#19408 * fix(daemon): probe the real per-UID XDG_RUNTIME_DIR before trusting the process's own env isDurableDaemonScopeSupported()/buildDurableDaemonScopeCommand() trusted the current process's own XDG_RUNTIME_DIR env var first, falling back to /run/user/<uid> only when that var was unset entirely. On mtl-02, orca-serve@factory.service's RuntimeDirectory= hardening directive makes systemd export XDG_RUNTIME_DIR=/run/orca_serve/factory into the unit's process -- a private scratch dir that shares the env var's name but has nothing to do with the user session bus. /proc/<pid>/environ on that host confirmed exactly that path plus DBUS_SESSION_BUS_ADDRESS=disabled:, while the real bus was reachable the whole time at /run/user/985 (confirmed via systemctl --user is-system-running with that dir exported by hand). The probe treated the hardened override as authoritative, found no bus socket there, and reported unsupported on every launch -- so the cgroup-escape fix from #19408/#19430 never actually engaged on real hardware, even though tonight's factory deployment picked it up. Fix: resolveUserRuntimeDir() now always tries the conventional /run/user/<uid> path first (computed independently via getuid(), never trusted from env), checking for a genuinely connectable bus socket via statSync(...).isSocket() rather than a bare existsSync. It falls back to the process's own XDG_RUNTIME_DIR only when that canonical path has no reachable bus -- covering hosts that legitimately have no /run/user/<uid> at all but do have a working bus wherever their own environment points. buildDurableDaemonScopeCommand() now explicitly sets XDG_RUNTIME_DIR to whichever path this resolution picked, rather than inheriting the spread env's (possibly hardened-wrong) value. Both isDurableDaemonScopeSupported() and buildDurableDaemonScopeCommand() gained an injectable canonicalRuntimeDir parameter (defaulting to the real computed path) so tests can exercise the hardened-override scenario deterministically with a real, connectable AF_UNIX socket fixture instead of the live host's actual runtime directory. Docker's stock jrei/systemd-ubuntu test container never had this hardening directive, so this gap was structurally invisible to the container-based verification in #19430 -- only caught against real mtl-02 hardware. * fix(daemon): report the daemon's own pid over the ready handshake, not systemd-run's The launcher used to infer the daemon's identity pid from the immediate spawned child (`child.pid`). On the durable-scope path that child is `systemd-run --user --scope`, not the daemon, so the launcher was asserting an identity it had no authority over. `DaemonReadyIdentity` now carries a required `pid` populated from `process.pid` inside the daemon itself, and `daemon-launched-child.ts` takes `launchedIdentity.pid` from that self-report. Both sides of the `holdDaemonAdoptionLease` pid comparison therefore originate inside the daemon process, which is the idiom this branch already uses for cgroup membership (`detectOwnCgroupScopeUnit` reads `/proc/self/cgroup` rather than trusting what the launcher intended). Note on the reported consequence: `systemd-run --scope` registers its *own* pid on the transient scope unit and then `execvpe()`s the target command -- same pid, no intermediate process -- so adoption did not in fact fail on systemd >= 206 (verified against systemd 255.4-1ubuntu8.17 and current main, `src/run/run.c` `start_transient_scope()`). The fix stands on its own merits: it removes a silent dependency on that exec-vs-fork implementation detail, which a `systemd-run` shim earlier in PATH or any future systemd change would have broken with no diagnostic. `terminateLaunchedDaemonChild` was audited and deliberately left on `child.pid`: for the same execve-preserves-pid reason that pid is either still systemd-run mid-scope-setup (killing it correctly aborts the launch) or already the daemon, so it targets the right process either way. Regression coverage: `daemon-launched-child-identity.test.ts` pins the identity source, and `daemon-ready-identity.test.ts` gains pid-validation cases. Ready-message fixtures across the `daemon-init-*` suites were updated for the now-mandatory field. Addresses: https://github.com/stablyai/orca/pull/19430#discussion_r3953722704 https://github.com/stablyai/orca/pull/19430#discussion_r3954346518 * test(daemon): assert cgroupUnit in the pid-file parse contract `parseDaemonPidFile` returns `cgroupUnit` on every branch as of the durable-scope commit on this branch, but five exhaustive `toEqual` assertions in daemon-health.test.ts still described the pre-scope shape, so they failed on the branch independently of any later change. Adds the field to those expectations. Deliberately not relaxed to `toMatchObject`: asserting the full parsed shape is what makes these tests catch a field silently dropped from the pid-file contract. * refactor(daemon): resolve the canonical user runtime dir at one point The per-UID path cannot change for a live process, so compute it once into a module const instead of threading the same default call through three signatures, and drop the try/catch around a getuid() that cannot throw once it exists. Trims the module prose to the non-obvious facts and corrects the pid-file record comment: an unscoped daemon writes null; only records no daemon wrote are absent. * test(daemon): clean up the cgroup-scope fixtures and assert a verdict The cgroup fixture tracked only the file it wrote, leaking one temp dir per case. Drains both fixture lists with splice so the pop-may-be-undefined guards go away, and replaces a not-throw/typeof-boolean pair with the verdict it was circling: no resolvable runtime dir means unsupported. * refactor(daemon): share the detached child options across both launch paths cwd, detached and stdio were repeated in the fork and systemd-run branches, which left the two comments explaining them hovering over the env block instead. Names them once so each branch carries only its own delta. * refactor(daemon): validate the ready pid like every other field typeof-first narrows the value, so the two 'as number' casts the isSafeInteger check needed disappear and the pid guard reads like the startedAtMs guard below it. * fix(daemon): don't retry the launch unscoped after losing the endpoint race A scoped attempt that lost the endpoint to another daemon was retried unscoped: a second doomed fork, a misleading 'cgroup-scope launch failed' warning, and the same DaemonEndpointUnavailableError the caller was already going to adopt on. Rethrows it instead, since no launch mode can win a race that is already lost. Also drops a private alias for DaemonChildSpawnOptions and the two 'as number' casts on child.pid in the startup-failure cleanup. * fix(daemon): unlink the pid record by the pid the daemon published The record holds the daemon's self-reported pid, so match on that rather than on the immediate child's, which is the systemd-run wrapper's until it execs. * fix(daemon): route the scope launch through the child-process chokepoint The two files this PR added imported `node:child_process` directly, which `child-process-import-boundary.test.ts` fails on deterministically: the offender count went 155 -> 157 against a pin of exactly 155. Raising the pin or listing the files is what that test explicitly forbids, and the allowlist's own note says a split "moved the import, it did not add one" -- so the fix is to get both new files off the module and put the count back at 155. - `daemon-cgroup-scope.ts`: the `systemd-run --version` probe now uses `runProcessSync` instead of `execFileSync`, so it gets the shared spawn decisions. Kept synchronous deliberately: `launchDaemonChild` attaches the readiness listener in the same tick it is called, and an await before the spawn moves the child past that tick. A non-zero exit is data rather than a throw here, so the verdict now checks `code === 0 && !timedOut`. - `daemon-launched-child-spawn.ts`: the scoped launch uses `spawnProcess`, and the long-standing unscoped launch keeps `fork` semantics through a new `forkProcess`. - `src/shared/child-process/fork-process.ts`: the fork arm of the chokepoint. `spawnProcess` cannot express a Node child with an IPC channel started from a module path under an overridden `execPath`, and the existing launch tests are written against `fork`'s contract, so a spawn rewrite would have changed module resolution, `execPath` and `execArgv` at once. It passes `windowsHide: true` -- the flag every other call site in that directory sets, reachable via an assertion because `ForkOptions` omits it -- which keeps `windows-console-visibility.test.ts` at its pin of 65 too. Both ratchets pass with both pins and both allowlists untouched. Docs: `orcad-operations.md` and `headless-linux-server.md` still described the limitation this PR removes as permanent. Both now describe the durable-scope survival path and its preconditions (systemd as PID 1, a reachable user bus / `loginctl enable-linger`, `systemd-run` on PATH), and scope the old text to the unscoped-fallback case, pointing at `health.terminalDaemon.cgroupUnit` as the way to tell the two apart on a running host. * fix(daemon): seal the cgroup capability probe from the host and correct KillMode=mixed docs The capability probe consulted the host's own /run/systemd/system marker and spawned the real systemd-run binary, so the hermetic unit tests could only pass on a systemd host (and fail closed otherwise, even with faked bus sockets). - Thread systemdBootPath and runVersionProbe as test seams through isDurableDaemonScopeSupported, defaulting to the real boot marker and systemd-run --version probe in production. - Narrow the injected probe to the ProcessResult slice it consumes. - Cover: no-systemd-boot, non-zero probe exit, and probe-timeout cases. - Correct KillMode=mixed semantics in the docs: the cgroup-wide SIGKILL fires the instant the main process exits, not after TimeoutStopSec; document the Docker-container caveat and add KillMode=mixed to the multi-service template. * fix(daemon): satisfy assertion checks in scoped launch * fix(daemon): satisfy anti-slop and console guards * test(serve): update shutdown docs assertions for daemon scope * fix(daemon): migrate adopted legacy scopes * docs: qualify restart safety by daemon scope * docs(daemon): qualify Upgrade restart prose with durable scope caveat Align the Upgrade section in docs/reference/headless-linux-server.md with the earlier preservation section and docs/reference/orcad-operations.md: a service restart terminates live processes only when running under the unscoped fallback, and stops should be treated as destructive unless health.terminalDaemon.cgroupUnit names an orca-daemon-*.scope. Update the shutdown workflow test assertion in config/scripts/headless-serve-shutdown-workflow.test.mjs to match. * fix(daemon): harden legacy scope migration --------- Co-authored-by: Lesley Murfin <260182349+LesleyMurfin@users.noreply.github.com> Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
4085e1cf60 |
fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries * fix(memory): bound transient filesystem registries * fix(memory): cap path and locale caches * fix(memory): bound runtime recovery registries * fix(memory): bound host mirror gap verdicts * fix(memory): bound shell startup env cache * fix(memory): bound gitlab host context cache * fix(memory): release removed ssh generations * fix(memory): expire cloud refresh replay guards * fix(memory): release retired plugin generations * fix(memory): bound plugin log key retention * fix(memory): bound automation authority generations * fix(memory): bound native chat enrichment cache * fix(memory): bound web session tracking generations * fix(memory): bound codex credential absence paths * fix(memory): bound WSL canonical path cache * fix(memory): bound sparse checkout cache * fix(memory): bound shared directory cache * fix(memory): bound advertised URL scan snapshots * fix(memory): bound automation manager cache * fix(memory): bound web session reorder intents * fix(memory): bound web session focus intents * fix(memory): bound web session handoffs * fix(memory): bound automation dispatch tokens * fix(memory): bound host mirror waiters * fix(memory): bound retained session activity * fix(memory): bound retained session activity * fix(memory): bound web session close intents * fix(memory): bound cloud session cache * fix(memory): bound WSL home cache * fix(memory): bound SSH capability cache * fix(memory): bound trust grant cooldowns * fix(memory): bound WSL auth drain state * fix(memory): bound Linear workspace credential cache * fix(memory): bound local Git capability cache * fix(memory): bound WSL Git environment cache * fix(memory): bound WSL Git environment cache * fix(memory): bound WSL preflight cache * fix(memory): keep hot cache entries warm * fix(memory): preserve generation fences across eviction * fix(memory): close remaining eviction fences * fix(memory): align evicted upstream generations * fix(memory): trim successful capability probes * fix(auth): retain expired refresh replay evidence --------- Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
ee354a35d7 |
feat(agents): add OpenCode 2 beta support (#21418)
* feat(agents): add OpenCode 2 beta support Co-authored-by: Xiro The Dev <lethanhtrung.trungle@gmail.com> * fix(opencode2): support current plugin lifecycle and session storage * fix(opencode2): preserve lifecycle ordering and full session capture * test(opencode2): cover setup event bridge * test(opencode2): cover setup event bridge * test(browser): satisfy anti-slop naming check * test(opencode2): cover live form lifecycle * fix(relay): preserve OMP config directory selection * test(opencode2): avoid assertions in bridge fixture * fix(rebase): retain OMP resume and fresh launch behavior * test: align upstream OMP resume expectations * test(opencode2): verify rejected form closes waiting state --------- Co-authored-by: Xiro The Dev <lethanhtrung.trungle@gmail.com> |
||
|
|
e8a7be4ce2 |
fix(omp): recover retired pane status with validated restart authority
Merged after fresh run 35448889017 passed all required checks, including static analysis, typecheck, package jobs, all test shards, changed E2E, Docker SSH E2E, and verify. |
||
|
|
a85e580e51 |
fix(orchestration): stop the sender-terminal refusal recommending another pane's handle (#21097)
* fix(orchestration): stop the sender-terminal refusal recommending another pane's handle The structured-session guard told callers to pass `--from <terminal-handle>`, but the explicit-flag branch returns before that guard runs — so following the advice succeeds, against a handle that necessarily belongs to a different pane, and the next `check` consumes that pane's unread mail. Both refusals now say what is actually true: no handle names a structured chat session, and a caller that does have one should pass its own. Also pins ORCA_STRUCTURED_SESSION in the gate CLI test, which until now decided which refusal it exercised from ambient environment. * fix(orchestration): route the lifecycle-send refusal to the structured message `orchestration send --type worker_done|heartbeat` refuses in the send handler before `resolveOrchestrationTerminalHandle` runs, so the structured guard never saw the case a structured session hits most: the canonical worker lifecycle report. That caller was still told to pass `--from` with "your own terminal's handle" — which it does not have, so any handle it picked would belong to another pane. `throwNoActiveSenderTerminal` now derives which refusal fits instead of each call site deciding: marker set AND no handle means no identity exists, so the structured refusal applies. A stale `ORCA_TERMINAL_HANDLE` is deliberately excluded — that caller does have an identity, it just went stale, and keeps the advice to re-run under a live one. Also corrects the guidance itself (`--agent` is a `worktree create` flag; `terminal create` has no such flag), aligns the SSH fallback wording with its local twin, and pins ORCA_STRUCTURED_SESSION in the send tests, which until now decided which refusal they exercised from ambient environment. |
||
|
|
2bdf281433 |
fix: avoid retaining foreign SSH file frames before metadata (#21167)
* fix: avoid retaining foreign SSH file frames before metadata * test(ssh): exercise empty metadata through the streaming mux fixture * fix(ssh): fail the file read when beforeResolve never runs Moving the metadata install from .then() to beforeResolve moved it from a mandatory callback to an optional one, and handleResponse clears the request timer before beforeResolve runs. That left "response fulfilled, metadata never installed" with no deadline: the read never settled, holding its notification and dispose closures until mux disposal. Before this PR the same state failed after the 60s inactivity deadline. Unreachable with the concrete mux, which calls resolve on the line after beforeResolve, but the hook is optional in the type and nothing enforces the pairing. The guard is a no-op on every real path: empty, missing streamId, cap-exceeded and alloc-failure all settle first, and the success path sets metadataReady. Found during review of #21167; raised at https://github.com/stablyai/orca/pull/21167#issuecomment-5726058832 --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
edbcf68e53 |
fix(ssh): record the superseded-relay pass the Windows arm abandons (#20045)
* fix(ssh): record the superseded-relay pass the Windows arm abandons `sweepSupersededRelayEndpoints` returned `[]` for every Windows remote host and for every failed listing without writing a line. Both returns are indistinguishable from "this host had no orphans", which is the one thing this sweep exists not to be: its own header says it makes the orphan population "visible and deliberate rather than silent". The Windows population is real. `relayEndpointForHost` hashes the version directory into the pipe name, so an app update strands the incumbent exactly as it does on POSIX, and with `--grace-time 0` that relay keeps its PTYs and agents forever. Measured on a Windows 11 host (awin): the NPFS root lists 262 named pipes from an unprivileged shell, and the count of `orca-relay-*` names goes 0 -> 1 the moment a relay binds, so the endpoints are enumerable; the repo already enumerates them for GC via `relayLivenessProbeCommand`'s `.windows-active-pipe-*` marker scan. Reclaiming them is not this change. `probeRelayEndpointIncumbent` answers `unverifiable` for every Windows path, so nothing here could be classified, let alone reaped, and nothing about the kill path moves. What changes is that an abandoned pass now leaves a trace. * fix(ssh): keep the endpoints a half-run superseded sweep already classified The Windows arm and the failed-listing arm now both leave a line. The loop between them did not: socket 1 could be fully probed and classified, and an exec on socket 2 that threw took `logSupersededRelayFindings` with it — so a half-run pass and a host with nothing to sweep produced the same silence, and socket 1's verdict was lost. Only one failure class can leave that loop, and it is the one that matters: an exec whose SSH channel never confirmed close, which may still be running remotely and which `probeRelayEndpointIncumbent` rethrows by design. Every ordinary probe failure already degrades to `unverifiable` and the pass continues — a test now pins that too, so nobody "fixes" the loop into stopping on an absence of evidence. Findings are logged before the rethrow, which propagates unchanged. The added line says how far the pass got and claims nothing about the endpoints it never reached. * fix(ssh): word the Windows sweep skip so a first install does not read as orphaned The line fired on every Windows relay launch and asserted a population: "orphans from earlier builds are neither listed nor reclaimed" reads as a finding on a machine that has never had an earlier build. The skip is what is being recorded, not a census. |
||
|
|
78a17bb24d |
fix(relay): one malformed pre-auth handshake frame closes its connection, not the daemon (#19879)
* fix(relay): one malformed pre-auth handshake frame closes its connection, not the daemon
parseHandshakeMessage returned whatever JSON.parse produced, and the daemon
interpolates the peer's version into a log line before any credential check.
A version that is an object with a non-callable toString throws TypeError
there, inside the frame-decoder callback. FrameDecoder.drainTurn wrapped its
synchronous dispatch in try/finally with no catch, so the throw escaped
feed(), escaped the socket data handler, and reached uncaughtException: the
relay daemon exited and every PTY and agent session it held died with it.
Two layers, because only the second closes the class:
- parseHandshakeMessage now requires the string fields each arm carries
(version; expected/got) and rejects a non-object payload. Both readers
share the parser, so neither side can interpolate a non-string again.
- FrameDecoder contains a frame owner that throws on the synchronous turn
the same way it already contained one on a continuation turn: reset the
residue and report one FrameDecoderContinuationError to onError. Every
owner's onError already closes its own connection, so any future throw
of this shape costs one connection instead of the process.
The relay CLI channel gains an explicit onError so a malformed reply still
ends that one-shot command instead of parking it.
* fix(relay): keep the diagnostic the refusal path exists to produce
Two error paths that destroy their own evidence.
`parseHandshakeMessage`'s unknown-type refusal interpolated `String(t)` on a
peer-supplied value: `{"type":{"toString":1}}` makes String() throw "Cannot
convert object to primitive value", so the refusal arrives without naming what
was refused. `describeRelayProtocolVersion` guards this exact hazard two files
away; the sibling was missed.
`runRelayOrcaCliChannel`'s new `onDecodeError` wrote to stderr and then exited
synchronously. stderr is async on a pipe transport, so the one line recording
why the command died could be dropped — the reason relay-handshake.ts already
exits inside its write callback.
* fix(relay): prove the optional handshake field too, not just the required ones
The parser refuses a non-string `version`, `expected` and `got`, then returns the
object with `endpointCredential` unproved — the most pre-auth field on the frame.
It is safe today only by accident: its one reader compares it, and a non-string
loses that comparison. Nothing holds that shape in place, and the next reader to
put it in a log line reinstates the template-literal throw this function exists
to stop.
Present-but-not-a-string is now refused at the parser. Absent stays absent: a
bridge presenting no credential is the common case, and refusing it would close
every unauthenticated-endpoint connection.
Wire-visible delta, deliberate: a peer sending a non-string credential used to get
`orca-relay-handshake-credential-mismatch` and exit 43; it now gets a bare close.
No first-party client can reach it — `runConnectHandshake` types the parameter
`string` and omits it when falsy — and a bare close is the right answer to a frame
that was malformed before any credential was checked.
* fix(relay): carry the SAFETY: rationale main's casting gate now requires
Main gained a `typescript/consistent-type-assertions` scan while this branch sat 432
commits behind, so every `as` the branch touches lands as a new finding. The parser is
the one place the handshake shape is proved, so each cast names the check that earns it,
and the hostile-frame cast in the round-trip test names the fact that it is a deliberate
lie the type system cannot describe.
* test(relay): annotate the hostile handshake frame instead of suppressing a cast
JSON.parse answers `any`, so a typed const expresses the same deliberate lie the
assertion did and the casting gate has nothing to flag. One fewer suppression.
|
||
|
|
14654d03cb |
fix: release completed SSH writer queue entries (#21150)
Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
a46b5b15ec |
fix(worktree): ask the execution host whose home a remote delete would take (#19865)
* fix(worktree): ask the execution host whose home a remote delete would take
`isDangerousWorktreeRemovalPath` read `os.homedir()` — the machine running
Orca — and then applied POSIX-only shape rules. SSH orphan cleanup feeds it
remote paths, so a Windows host profile (`C:\Users\bob`) was unrecognised from
a macOS/Linux desktop and the recursive delete lost its last guard, while a
coincidental client-home prefix could refuse a legitimate remote delete.
The removal route already resolves one execution host for the whole removal;
it now resolves one home authority the same way. `WorktreeRemovalHomeAuthority`
is `{ kind: 'client' }` or `{ kind: 'executionHost'; homePath }`, required at
every guard entry point, so the ambient read is unreachable from a remote
removal. The host's answer is the `$HOME` the SSH session already read on the
host during relay deploy — no new probe. Unresolved stays `null`, meaning
unknown, never "same as this client's".
Path-shape rules now cover Windows profiles (`C:\Users`, `C:\Users\<name>`,
any drive or UNC root, case-insensitively) and WSL UNC aliases, which front a
Linux filesystem and so take the POSIX shapes.
Fixes #18275
* fix(worktree): merge the duplicated removal-route import
The focused code-quality plugins deny `import/no-duplicates`.
* test(worktree): pin the IPC removal call site and the unknown-host-home refusal
Mutation testing found four survivors in the home guard:
- Swapping the IPC unregistered-removal call site to the client's home passed
every suite while the remote delete could reach the host's own home. Only
the runtime call site was pinned. Add the mirror test for the IPC path.
- Falling back to os.homedir() when the execution host reported nothing was
indistinguishable from refusing; the client home never coincided with the
probed path. Assert the fallback stays off with homedir pinned to the path.
- Comparing an execution-host home across path syntaxes survived because no
row exercised the win32 home under POSIX ops: path.resolve manufactures
<cwd>/C:/Users/bob, which every ancestor of the cwd contains.
- Dropping the bare /Users rule survived; add the row.
Also cover the forward-slash C:/Users/bob form normalizeRemoteHome reports
for a Windows host, which no existing row used.
* ci: re-run after an unrelated Electron probe startup timeout
* fix(lint): clear the casting and max-lines gates on the home guard
Rebasing onto main brings two gates this branch predates:
typescript/consistent-type-assertions at assertionStyle: never, and the
300-line ceiling that the added home lookup pushed
orca-runtime-remove-managed-worktree.ts past. The fixture casts carry
per-site SAFETY rationales; the route's git-options-and-listing step
moves into its own module, which also stops the local/SSH branch being
spelled twice in one expression.
* fix(lint): name the home predicate for what it matches
main enabled anti-slop/no-shape-in-symbol-names (#20785) after this
branch was written. The predicate answers whether a path IS a home root,
not whether it resembles one.
* fix(worktree): refuse a removal the execution host cannot vouch for
Review of the home guard found three ways it still let a delete proceed on
evidence about the wrong machine, or on no evidence at all.
`getPathOps` switches to win32 as soon as EITHER the worktree path or the repo
path looks Windows-absolute, and `//nas/share/repo` does. A POSIX worktree path
was then judged by Windows-only shape rules, which recognise `<root>\Users\<name>`
and nothing else, so `/home/alice` — and any client home outside `\Users` —
stopped matching and the last guard in front of a recursive delete went quiet.
The home question involves the worktree path and a home, never the repo path, so
the predicate now reads the path in its own syntax as well and refuses if either
reading names a home. A union of refusals can only ever refuse more.
An execution host that never reported its `$HOME` is `unverifiable`, and
`unverifiable` does not authorise a delete. `isRemovalHomeAuthorityResolved`
gates the two paths that recursively delete a directory —
`canSafelyRemoveOrphanedWorktreeDirectory` and
`canCleanupUnregisteredOrcaLeftoverDirectory` — because the orphan proof they
accept, a `.git` file at the top of a directory, is also what a bare-repo
dotfiles `$HOME` looks like, and there the guard is the only evidence there is.
`git worktree remove` is deliberately not gated: the host's own Git registry
already established that the path is a linked worktree of that repo, and a
missing second opinion does not retract a first one. An empty `$HOME` is
normalised to unanswered rather than read as a resolved home.
The IPC entry point spelled its host two ways. The metadata prune, the
archive-hook route and now the home authority came from
`getRepoExecutionHostId(repo)`, while the `git worktree list` and every delete
came from raw `repo.connectionId`. A row carrying only
`executionHostId: 'ssh:<target>'` therefore listed a remote checkout on this
client and deleted a same-named local path while the guards vouched for the
remote one; the mirror row did the reverse (#11163, previously fixed on the
runtime path only). Neither spelling is evidence about the other, so a row that
carries two host names is refused before anything is listed or deleted. Both
sides are spelled by `getRepoExecutionHostId`, so they can differ on content but
never on normalisation.
A `runtime:<env>` row refuses here for the same reason. It is not reachable
through this handler today — the renderer sends environment targets to
`worktree.rm`, and the host-qualified catalog refuses to list a runtime host —
so that arm closes a door rather than changing a flow.
Fixtures that register an SSH provider now report a host home, because a
connected relay session always has one: `remoteCliBridgeEnv` is assigned before
`registerSshGitProvider`, is never cleared, and providers are unregistered
before the session leaves `activeSessions`. The wiring lives in its own module
called from the harness rather than in `worktrees-test-module-mocks`, which
`vi.mock` factories import: reaching the production route module from there
pulls in `providers/ssh-git-dispatch` while it is being mocked, and the module
runner deadlocks.
* fix(worktree): compare removal host names after decoding, not as stored text
`getRepoExecutionHostId` returns a row's `executionHostId` as stored, while the
same row's `connectionId` is re-spelled through `toSshExecutionHostId`, which
percent-encodes. A byte compare of the two would refuse a perfectly consistent
row over a `%20`, so the two host ids are now compared after `parseExecutionHostId`
has decoded the target id out of each.
`runtime:<env>` and an unparseable id decode to no machine at all and match
nothing, including each other — a runtime-owned row has a null `connectionId`
and would otherwise read as local, which is a delete on this client.
* fix(lint): clear the static-analysis gates on the removal home authority
The type-aware audit rejects a `default` arm on a discriminated switch, so the
host-kind switch names `runtime` and `undefined` outright — which also makes a
host kind added later a compile error here rather than a silent fallthrough.
The two test casts the changed-code gate flagged are gone: the leftover-cleanup
meta is typed instead of asserted, and the unparseable-host-id case narrows to
`ExecutionHostId` with the SAFETY rationale the gate asks for.
* docs(worktree): say why an unroutable removal host is refused by a plain compare
The comparison refuses `runtime:<env>` because only the left operand can name
no machine — `repoRowHostId` comes from `connectionId` and is always `local` or
an `ssh:` id. That invariant was doing the work silently; an explicit null test
in its place was a branch no input can reach, so the reason is written down
instead.
* fix(worktree): gate the registered removal on the host home answer too
I argued `git worktree remove --force` did not need the host's home answer,
because the host's own Git registry had already established that the path is a
linked worktree of that repo. That is true and it is not enough: `git worktree
add` accepts a pre-existing empty directory, and that directory can afterwards
be somebody's `$HOME` — a build account's home, a container's `HOME=/workspace`.
Being a linked worktree proves provenance, not that the path is not a home, and
the remove deletes the checkout either way.
With the host's answer that case is already caught by containment. Without it
only the path shapes remain, and a home at a non-standard location
(`/var/home/<u>`, `/export/home/<u>`, `D:\\Profiles\\<u>`) has no shape to match.
So `findRegisteredDeletableWorktree` now requires the answer as well, and every
gate that authorises a delete is on the same rule.
The fixture that models a connected relay session moves out of `ipc/` and is
shared: four runtime specs register an SSH provider without one, and a live
provider implies a reported home in production.
|
||
|
|
13ba649c22 |
fix(terminal): let a runtime-created Windows terminal BE the requested shell (#20825)
* fix(terminal): let a runtime-created Windows terminal BE the requested shell
`orca terminal create --environment <windows-host> --command 'cmd.exe'` never
created a cmd terminal. `--command` is text the provider TYPES into whatever
shell it spawned, so the PTY stayed the host's default shell with cmd running
inside it. Captured on `awin`, whose default is Git Bash:
$ orca terminal create --environment awin --command 'cmd.exe' --json
$ orca terminal send --environment awin --terminal term_10656cf7... \
--text exit --enter
$ orca terminal read --environment awin --terminal term_10656cf7... --screen
neil@awin MINGW64 ~/orca/orca ((30f820708f...))
$ cmd.exe
Microsoft Windows [Version 10.0.26200.9445]
C:\Users\neil\orca\orca>exit
neil@awin MINGW64 ~/orca/orca ((30f820708f...))
$
The handle is alive the whole time and `terminal list` shows one healthy
terminal, because the PTY never changed — so the only symptom is that the
caller's terminal is now a shell it never asked for, and every later `send` is
quoted for the wrong one. On `win-lowspec` (default pwsh) the same create lands
cmd inside PowerShell.
Root cause
----------
There are two spawn preflights and they are twins:
- `src/main/ipc/pty/ipc/spawn-preflight.ts` — renderer/IPC spawns, i.e. a
terminal tab opened in the app.
- `src/main/ipc/pty/runtime/spawn-preflight.ts` — runtime spawns: the CLI's
`terminal.create`, headless `orca serve`, and every paired remote
environment.
Only the IPC twin read the caller's requested shell. The runtime twin passed a
literal `requestedShellOverride: undefined`, so a runtime-created terminal on
Windows could only ever be the host default. Everything downstream of that
point — `spawn-options`, the daemon, `resolvePtyShellOverride` in the relay,
`local-pty-launch-plan` — already honoured `shellOverride`; nothing upstream
could supply one.
Change
------
- Thread `shellOverride` through the runtime lane: `RuntimePtySpawnArgs` ->
runtime `spawn-preflight` -> `RuntimePtyController.spawn` ->
`TerminalCreateOptions` -> the `terminal.create` RPC's new `shell` param ->
`orca terminal create --shell`.
- Thread it through the renderer-backed lane too (`createDesktopTerminal` ->
`terminal:requestTabCreate` -> `store.createTab`), so `--shell --focus` is not
silently dropped on a local Windows app.
- An agent launch quotes its startup command for the shell it will actually run
in, so a requested shell now owns the startup-shell family instead of the
global `terminalWindowsShell` setting.
- Lift the relay's `ALLOWED_WINDOWS_SHELL_OVERRIDES` into
`isSupportedWindowsShellOverride` in `src/shared/windows-terminal-shell.ts`
(membership unchanged) so the CLI, the zod param schema, and the relay refuse
the same names. `--shell` therefore cannot carry a path or a command line into
`pty.spawn`; only allowlisted bare shell names pass.
- Gate on `TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY`. An older host
strips the unknown `shell` param and answers with a healthy terminal running
its default shell — a reply indistinguishable from success — so the CLI
refuses before creating anything rather than creating the wrong shell quietly.
`--shell` stays Windows-only; macOS and Linux hosts spawn the login shell and
the relay drops the value off win32 rather than honouring it half-way. A WSL
project runtime still outranks it, unchanged.
Tests
-----
- `pty-spawn-shell-override-parity.test.ts` pins both preflights against the
exact drift that caused this (verified failing with the fix reverted).
- `createTerminal` passes `shellOverride` to `ptyController.spawn` with no
startup command.
- CLI: sends `shell`, refuses a shell the host cannot spawn, and refuses a host
without the capability — in both refusals without making the round trip.
- Allowlist and `terminal.create` schema accept/refuse cases, including paths
and appended arguments.
* fix(terminal): refuse a requested shell the execution host cannot apply
The first commit made `--shell` reach the spawn, but only a LOCAL win32
execution host applies it: `spawn-options` gates the override on
`process.platform === 'win32' && !args.connectionId`. So `--shell cmd.exe`
against an SSH-routed worktree, or against a macOS/Linux host, still returned a
healthy terminal running that host's default shell — the same
indistinguishable-from-success reply the capability gate exists to prevent, one
layer down.
Refuse instead, before anything spawns. The check sits at the top of
`resolveAgentTerminalCreateOptions`, which every create lane funnels through, so
neither lane has to remember it; the desktop lane additionally refuses a
worktree-less create, which has no execution host to resolve a shell on.
An SSH host's platform and installed shells are not visible to this runtime, and
a POSIX host has no Windows shell to pick. Neither can honour the request, and
saying so is the whole point of the flag.
Docs and the CLI spec now say "refused", not "ignored".
* fix(terminal): refuse a shell that contradicts the project execution runtime
`resolveLocalWindowsTerminalRuntimeOptions` does not merely rank the project's
execution runtime above a per-terminal pick -- it REWRITES the pick, in both
directions, and says nothing:
- a WSL project forces `wsl.exe`, discarding `--shell cmd.exe`;
- a Windows-host project discards a WSL name and falls back to `COMSPEC`
(`getHostShellForProjectRuntime`), so `--shell wsl.exe` spawns cmd. That is
the common case, not an edge: `resolveProjectExecutionRuntime` resolves
`windows-host` for every project that is not WSL, while a repo belonging to no
project honours `wsl.exe` -- so the same flag behaved differently depending on
whether the repo was in a project.
Either rewrite returns a healthy terminal running a shell the caller did not ask
for, which is the failure `--shell` exists to remove.
It also split an agent launch's quoting from the shell that receives it. The
previous commit made the startup-shell family follow the REQUESTED shell, so
`--shell wsl.exe --command codex` on a Windows-host project typed POSIX-quoted
launch args into cmd. Refusing the contradiction removes that case rather than
papering over it.
Refuse instead, alongside the SSH and non-Windows refusals, from the same
`resolveAgentTerminalCreateOptions` seam every create lane funnels through.
Also from review:
- the allowlist test looped the list against itself; spell the members out.
- the runtime spec case claimed to prove the pty's shell when it asserts the
controller received the field; name it for what it checks.
Reported by an adversarial review of the branch.
* fix(terminal): canonicalize --shell and refuse a WSL-path rewrite
Review of the --shell create path turned up two ways the terminal could
still end up being a shell the caller never asked for -- the exact failure
--shell exists to remove.
Bare and mixed-case spellings passed the allowlist but reached consumers
that exact-match the canonical name: resolveWindowsShellStartupFamily
classified `cmd` as the PowerShell family, resolveWindowsShellLaunchArgs
fell through to empty shellArgs (no `chcp 65001`, no OSC 133 bootstrap that
Windows foreground status depends on), and resolveWindowsGitBashShellPath
compares case-sensitively so `Git-Bash` spawned a literal `Git-Bash`.
The allowlist is now one canonical-name map and terminal.create canonicalizes
on parse, so the spawn path only ever sees `.exe` spellings. `pwsh` and
`powershell` stay distinct binaries.
A `\\wsl$\<distro>\...` cwd made the providers force wsl.exe regardless of
the request, and terminalShellOverrideRefusal only inspected the project
runtime -- undefined for a folder workspace with no project. Refuse on the
resolved cwd and the workspace path, judging what the PTY actually gets.
Also: the capability gate reported an unreachable host as too old rather
than unavailable; the SSH CLI shim dropped capabilities from status, so
--shell there blamed the host version instead of naming SSH; and --shell
had no help entry, rendering bare in `orca terminal create --help`. Adding
that entry crossed help.ts's max-lines cap, so the flag table moved to
flag-help-text.ts rather than suppressing the rule.
Adds a behavioural test for the runtime preflight (the one-line fix was
pinned only by a source-text scan), plus coverage for the startup-command
quoting family, the no-workspace refusal, and the WSL-path refusal.
* fix(build): keep tests out of the RPC params catalog bundle
The catalog walk under methods/ already skips *.test.ts, but the contract
directory glob took every .ts. terminal-create-shell-param.test.ts is the
first test to live there, so the bundle pulled vitest into a CJS build and
the generator threw on require(). Same exclusion, same reason.
|
||
|
|
9ab0a18e82 |
refactor(agent-status): isolate legacy status ingress behind one admission point (#20716)
* refactor(agent-status): isolate legacy status ingress * fix(agent-hooks): move advertised-capability source onto the ingest envelope ingestRemote() gained a third positional argument in this PR (advertisedAgentStatusCapabilities) to satisfy a new ratchet requiring every legacy-ingress call site to name its capability source. Both production callers pass the same constant every time, so the argument carries zero runtime information — but Vitest's toHaveBeenCalledWith matches argument count exactly, so the pre-existing SSH relay integration test (which asserts a 2-argument call) started failing even though nothing about the actual admission decision changed. Capabilities are a property of the producing peer/connection, not an orthogonal call parameter, so move the field onto the envelope object instead of adding a third positional argument: ingestRemote reads envelope.advertisedAgentStatusCapabilities (defaulting to the unadvertised-legacy-peer set), and both call sites stamp the constant onto their envelope literal. Call arity stays at two arguments, so the pre-existing evidence test needs no change. The envelope never crosses the wire in either caller: SSH rebuilds it field-by-field from the RPC params, and the WSL path copies (never mutates) the wire-deserialized notification before stamping the field on, so this is purely an internal main-process shape change. Also strengthens the ingress ratchet test that required this: it previously only checked that the capability constant's name appeared somewhere in each caller's source, which a stray unused import could satisfy. It now asserts the actual `advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES` key:value binding is present. |
||
|
|
231e805b1e |
fix(lint): enable anti-slop/no-shape-in-symbol-names (#20785)
Flip `anti-slop/no-shape-in-symbol-names` from "off" to "error" and clear
every violation under src, config, tests and mobile.
What the rule bans
------------------
The case-insensitive substring "shape" in any JS/TS identifier: variables,
functions, parameters, types, type parameters, class members, private names,
object-literal keys and JSX identifiers. The one exemption is a statically
accessed member read owned by another value (`zodObject.shape` is fine), so
third-party APIs stay readable without a suppression.
"Shape" names a value's structure rather than its domain role. `UserShape`,
`validateArgShape` and `errorShape` all tell you the symbol is "an object
with some fields" -- which is already what a type says -- while saying
nothing about what the value is for or who owns it. The rule forces the
name to carry the domain instead.
Violations fixed
----------------
689 violations across 109 files at baseline (verified by re-running the
audit against the pre-change tree with the rule set to "error").
Fix pattern
-----------
Rename for the domain role, not the structure:
-type FieldShape = 'list' | 'map' | 'whole'
-const FIELD_SHAPES = { ... } satisfies Record<keyof Observation, FieldShape>
+type FieldEncoding = 'list' | 'map' | 'whole'
+const FIELD_ENCODINGS = { ... } satisfies Record<keyof Observation, FieldEncoding>
-function assertGitPushTargetShape(target: unknown): void
+function assertValidGitPushTarget(target: unknown): void
-function describeReadDirPathShape(p: string): ReadDirPathKind
+function classifyReadDirPath(p: string): ReadDirPathKind
Predicates became statements about the value (`isDeltaShapedProviderFrameKind`
-> `isDeltaProviderFrameKind`, `isDeleteShapedDiscardEntry` ->
`discardDeletesEntryFile`, `isSkillsCliAgentKeyShaped` ->
`isUsableSkillsCliAgentKey`). Type aliases dropped the suffix where the
remaining name was already unambiguous (`GhGraphqlErrorShape` ->
`GhGraphqlError`).
No wire-visible name was renamed: no IPC or RPC channel, stream opcode,
request/response param, persisted field, or i18n key. The `--shape=symlink|copy`
CLI flag read by .github/workflows/skill-update-roundtrip.yml is unchanged --
only the local variable holding it was renamed.
Exemptions
----------
They are file-scoped entries in config/oxlint-anti-slop.json, not inline
`oxlint-disable` comments. An inline directive naming an anti-slop rule reads
back as an UNUSED directive under the root lint scan, which does not load this
plugin -- the changed-code quality gate counts that warning, so the comment form
cannot be used for a rule that lives only in this config.
* src/renderer/src/components/browser-pane/annotate/**:
in the screenshot annotator a "shape" is the drawn geometry -- pen, arrow,
rect, ellipse, highlight. That is a genuine domain noun, and it pervades
every symbol in the module.
* repo-icon.tsx, repo-header-project-actions.tsx, mobile MobileRepoIcon.tsx:
lucide exports the icon component as `Shapes`. The name is theirs, and the
matching REPO_LUCIDE_ICONS key is the persisted icon name shared with the
desktop picker -- renaming it would orphan saved repo icons.
* src/shared/onboarding-state-types.ts, src/shared/constants.ts:
`shapedSidebar` is a persisted onboarding-checklist field and a telemetry
enum member; renaming it would orphan saved state.
* src/shared/rpc-contract/rpc-send-params.ts: matching zod's own literal `shape`
property is what selects the ZodObject branch of the conditional type.
No exemption was added merely to avoid a rename. Eight symbols initially
suppressed as "a cross-module refactor outside this change" were proven to have
zero non-TypeScript references repo-wide and renamed instead.
Zod's `ZodRawShape` needed no exemption at all: `Readonly<Record<string,
z.ZodType>>` is its definition, so repo-update-params.ts and
ui-update-value-tolerance-params.ts spell it out instead. Likewise
telemetry-event-classification.ts now reads `.shape` through an `in` narrowing,
which also retires two pre-existing type assertions; three more assertions the
rename had dragged onto changed lines (two `JSON.parse` sites, one node:sqlite
row read) became annotations and an explicit row mapping.
Verified
--------
* Audit reports zero violations; confirmed the rule genuinely fires by
planting a probe violation.
* node config/scripts/run-typecheck-projects-in-parallel.mjs exits 0.
* Vitest over src/shared, src/main/github/project-view, the annotate module,
the repo-icon components and the Chromium SameSite electron spec: all green.
* All 66 removed "shape" identifiers grepped repo-wide across every file type;
none survive.
* node config/scripts/generate-rpc-params-catalog.mjs --check exits 0.
* node --check on every changed .mjs; oxfmt clean on all changed files.
* `pnpm run check:code-quality:changed` reports 0 findings.
Not machine-verified: the 3 mobile/ files (its Vitest run cannot resolve
`expo/tsconfig.base.json` in this worktree), and the WSL- and Playwright-gated
specs. All are rename- or comment-only hunks, read in full.
|
||
|
|
d2d32691ef |
perf(persistence): skip redundant whole-state flushes on terminal reattach (#20137)
* perf(persistence): add pty-binding fast lane to skip redundant flushes Terminal pane reattachment currently clones the session and serializes the entire 9.2 MB app state even when the binding is already in place and durable. Add an early-return fast path that skips this work when all nine predicates hold: no split, binding matches in-memory and on-disk, incarnation matches, no tombstone, and generation counter proves durability. Includes one-line fix in `writeToDiskSync` to record hash-matched sync flushes as durable, so the fast path doesn't stay parked behind a stale generation. Adds `persistence.pty-binding` observability spans (local NDJSON, unsampled for mutations, budgeted for fast-lane hits) to measure eligibility rates before and after. Includes ratchet test to ensure every binding writer bumps the generation. Diagnostic tools and full investigation notes from September 7, 2026 capture that identified the 59–100 ms no-op binds and measured a real terminal keystroke queued 117 ms behind one such call. * perf(persistence): add pty-binding fast lane to skip redundant flushes Rapid rebinds of already-durable PTY bindings (e.g., remounting panes) were unnecessarily expensive because they cloned and flushed the entire document state every time. Detect when a binding hasn't changed since the last durable write and skip to return immediately, eliminating main-thread cost on that path. * perf(persistence): record binding.origin on the pty-binding span Fresh spawns always flush, so a fast-lane rate over all calls is diluted by however many terminals the user opened. Each caller knows whether it is a spawn, a reattach, a split, or a relay reattach; pass that through as metadata and record it so the reattach hit rate can be read from the trace file. Never branched on. * fix(persistence): keep the tab row on its first pane when a sibling pane binds A tab row names one PTY, but a split tab holds several panes. The renderer keeps the row on the first pane and refuses to let later split-pane spawns steal it, since a remount reattaches the tab to whatever the row says. Main overwrote it with whichever pane was binding, and the renderer's next publish put it back, so every sibling reattach was a state change and could never take the fast lane. On the real profile that is 38% of panes. Rewrite the row only when it names nothing useful: null, the PTY this leaf is replacing, or a PTY no leaf holds. The fast-lane predicate compares against the same rule. * perf(persistence): record durable pty-binding flushes per pane The global write generation is held back by any unrelated dirty state, causing bindings unchanged for minutes to appear unpersisted despite being on disk. Track per-pane durability to skip redundant flushes. * docs(persistence): describe the per-pane durability record The durability section still described the global generation check as the whole story and claimed there was no binding durability cache. Record the measurement that motivated the per-pane record, and why retiring one needs no cooperation from other binding writers. * docs(perf): consolidate every measured Orca performance issue into one register Folds the findings from all related debug sessions into the live lag investigation: the persistence/main-thread work (P1-P11), host contention (H1-H5), git and subprocess load on main (G1-G8), renderer and terminal rendering (R1-R8), the terminal daemon session leak from the deleted debug-orca-perf-issue worktree (D1-D9), and the Cmd-J palette review (C1-C6). Keeps the measurement behind each claim, records what is fixed versus open, and restates what the 117 ms keystroke delay still does not explain. * fix: address performance review findings * fix: satisfy diagnostic probe lint * chore: keep investigation artifacts out of performance PR * fix: run lag probe regression tests with Vitest * perf(persistence): replace pane receipts with global durability check * refactor(persistence): remove redundant binding review machinery * test(persistence): satisfy current assertion-free quality gate --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> |
||
|
|
33149fcde5 | fix(claude): install SessionEnd for capable versions (#20530) | ||
|
|
2ce252f471 |
fix(grok): announce a completion once, when Grok is actually finished (#20523)
* fix(grok): announce a completion once, when Grok is actually finished Orca pinged on every Grok turn-end. Grok runs turns the user never asked for: when a background task finishes it wakes itself, does a little work, and ends another turn. One request produced several pings. Grok already reports, on every turn-end, whether it still has work outstanding. Read that instead of trying to classify which turns are "real": backgroundTasks absent -> silent, this is the session-end tail StopFailure / StopCancelled -> announce, a failure is never hidden stopHookActive -> silent, a Stop hook is keeping it working a shell task or subagent running -> silent, the work is not done otherwise -> announce Nothing here knows what an auto-wake turn is. A turn that ends with work outstanding stays quiet; the later turn where that work is finally done is the one that announces. That is also why this survives the case where Grok completes a user's goal inside one of those turns — prefix-based suppression would have silenced it. Monitors and scheduled entries are deliberately not counted as outstanding work. They can run indefinitely, so counting them would suppress a user's completion permanently, and a lost ping is worse than an extra one. Also registers StopCancelled, which Grok fires instead of Stop on a user interrupt, a declined permission, --max-turns, or a no-progress bail-out. Orca never subscribed to it, so those turns were reported as successes. Also removes a stale notification matcher that searched for prose the shipping binary never sends; the typed notification kind is matched instead, and neither idle_prompt nor task_complete is treated as a completion. Needs-input behaviour (permission prompts and ask_user_question waits) is unchanged and stays ungated by background work. * fix(grok): never hide a failed or cancelled turn behind the background-work gate The announce predicate checked field-absence before terminal outcome. Grok's StopFailure and StopCancelled payloads carry no background inventory at all, so the absent-field branch — added so the session-end tail stays silent — fired first and silenced every failure and every cancellation. That inverted the rule it was meant to serve. Before this series a cancelled turn at least surfaced as a (wrong) success; gated this way it surfaced as nothing. Terminal outcome is now checked first, so a failure or cancellation announces regardless of what other fields the payload happens to carry. The existing tests passed straight through the bug because they built failure payloads with a backgroundTasks field Grok never sends for those events. They now model the real payload shapes, verified against the provider's payload definitions and the captured envelopes. * fix(grok): settle completion from provider lifecycle state * fix(grok): fence stale turn ends without prompt ids |
||
|
|
96b450fae8 |
fix(ssh): bound relay incumbent lsof probe (#18304)
* fix ssh relay incumbent probe timeout
* test ssh relay unconfirmed probe termination
* fix(ssh): preserve connect evidence while bounding lsof
* fix: preserve uncertainty when relay holder enumeration fails
* fix(ssh): supervise lsof helpers and preserve partial holder evidence
* test(ssh): prevent GC racing unconfirmed probe cleanup
* refactor(process): keep POSIX lsof supervision in process owner
* fix(ssh): confirm census cleanup and handle probe startup signals
* fix(ssh): keep lsof holder evidence usable on hosts with unstat-able mounts
lsof warns to stderr about mounts it cannot stat, and any stderr byte forced
the holder enumeration to 'unavailable' — making the 'exited' verdict, and so
husk reaping, unreachable on those hosts. Pass -w to suppress the warnings.
* Revert "fix(ssh): keep lsof holder evidence usable on hosts with unstat-able mounts"
This reverts commit
|
||
|
|
1f7655f3e3 |
feat(ai-vault-search): public session search contract and transports (#20277)
* feat(ai-vault-search): define public contract and service seam * feat(ai-vault-search): add IPC runtime relay and web transports * fix(ai-vault-search): register search IPC at the core handler site ai-vault.ts was two lines over the 300-line max-lines limit; the search handlers belong with the other register*Handlers calls anyway. * fix(ai-vault-search): withhold degraded-root paths from relay status Status carried local filesystem paths over the relay while hits redact theirs. redactStatusForTransport applies the same policy at the same boundary: relay callers keep each root's reason and the array length as the count, so the type only makes root optional. * fix(ai-vault-search): close diagnostic path leak and remove test casts * feat(ai-vault-search): carry an execution host id and per-host outcomes on hits * feat(ai-vault-search): route desktop search by execution host scope, including runtimes * feat(preload): accept an execution host scope on session search * feat(web): answer only for the paired runtime on session search * docs(ai-vault-search): describe execution-host routing and the all-hosts merge * test(ai-vault-search): cover every host scope, the all-hosts merge and wire compat * fix(ai-vault-search): resume every host mid-page so a merged page never drops a hit * fix(ai-vault-search): decode the merged cursor with a schema instead of casts CI's type-aware audit refuses type assertions; a zod record validates the per-host entries and yields the typed map without one. * refactor(ai-vault-search): defer cross-host merged search |
||
|
|
da5d555259 |
refactor(agent-status): delete the runtime's retained row store (PR 1b) (#19785)
* docs(agent-status): plan PR 1b at file level Names the five RuntimeAgentRowStore call sites and what each becomes, why terminalHandle has to be stamped before the store can go, and the one intended behavior change. * feat(agent-status): stamp the pane terminal handle on hook-server rows The runtime's retained row store carried the pty binding two readers need. Put that fact on the row that already owns the pane instead, resolved through the same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot disagree about which terminal a pane is. Carried forward when a later write resolves no handle (only main's OSC parse can), and never persisted: a handle belongs to the runtime that issued it. * refactor(agent-status): route the session-tabs republish off the store `retain()` was not only a duplicate store: its boolean return was the signal that republished `session.tabs` for a status-only transition, which no title change covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that change set plus hook restore provenance, so route the signal off the store rather than keep a second comparator. Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out deliberately skips — now load-bearing, because a dismissed row leaves the listing at once. Installed on both hosts. orcad had neither the OSC producer nor this signal, so its runtime observed agent status and published it nowhere; deleting the retained copy without wiring it would list no PTY agents there at all. * refactor(agent-status): delete the runtime's duplicate retained row store `RuntimeAgentRowStore` held the same payload the hook server already holds, so the same pane could legitimately read differently in the sidebar, in `worktree ps`, and on the phone. Both of its readers move onto the store's snapshot in `runtime-hook-agent-row-selection.ts`, and `collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook reconciliation that only existed because two stores could disagree. `ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`, which is how a row still resolves the connected PTY behind it — the working-terminal rollup's match key, and the last rescue for a row whose pane binding a controller incarnation nulled under it. The one intended behavior change: a row the user dismisses on the desktop leaves `worktree ps` and mobile at once instead of lingering until the pty exits. One store means one dismissal. The suites written against the retained store are rewired to a real AgentHookServer rather than deleted, so each still asserts the listing behavior it named. * docs(agent-status): record what PR 1b landed Past tense, plus two corrections to the plan: `terminalHandle` is not the pty id (they are different identifiers, and the explicit-status reader was already comparing against a real handle), and the legacy numeric pane key is a consequence the plan did not name. * fix(agent-status): harden single-store lifecycle * fix(agent-status): preserve mobile terminal rejoin * fix(agent-status): preserve unverifiable remote rows * fix(agent-status): own PTY row lifecycle in hook server * fix(agent-status): preserve state and renew freshness * fix(agent-status): ignore freshness for dismissed identity rows * fix(agent-status): fence orcad observed identities * fix(orcad): always release daemon adapter on cleanup * fix(agent-status): cover remint and headless lifecycle edges * fix agent status identity recovery gaps * fix(agent-status): suppress duplicate child-only row mutation * test(runtime): preserve hook store wiring in transcript harness --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
bcb703fb4c |
test(ssh): isolate the MFA fixture from the developer's real ~/.ssh (#19300)
The multi-stage cases pass `resolved: null`, so `resolvePrivateKeys` falls through to `findDefaultKeyFile`, which reads `~/.ssh/id_*` via `homedir()`. On a machine with an encrypted default key ssh2 rejects with "Cannot parse privateKey" before authentication is exercised, so two cases failed locally while staying green on hosted CI, which has no key. Point home at the existing fixture directory so default-key discovery stays in the test's control. Co-authored-by: Merge Sim <sim@local> |
||
|
|
0c33f58e8a |
fix(ssh-relay): daemon owns the endpoint credential; a losing start never rotates it (#19052)
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->
| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 19 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$962 | $\color{#cf222e}{\Huge{\mathbf{−}}}$136 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$826 |
| Prod | 18 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$295 | $\color{#cf222e}{\Huge{\mathbf{−}}}$116 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$179 |
<!-- /orca-pr-loc -->
## Symptom
Live 2026-09-05 (Orca 1.4.198 client, Ubuntu host): both relay processes `kill -STOP`ped for 20 s, then `-CONT`. The client redeployed while the host was frozen. Its fresh daemon lost the socket bind (`Socket path already in use`) but had **already rewritten** `relay-<id>.sock.credential`. The surviving daemon kept its in-memory credential, so every later `--connect` got `Endpoint credential mismatch; closing socket`, then `Grace started … timeoutMs=0 … ptys=1, clients=0` every ~20 s, forever. Only a manual `kill -TERM` cleared it. Receipts: `review-archive/orchestration-v3-pr16904/smoke-receipts-t012b/E16,E17,E18,E24`.
Three independent defects kept the wedge alive; each is fixed at its own seam.
## Fix
**1. The relay daemon owns credential publication (race-free under two concurrent starters).**
`relay-daemon.ts` binds the socket first, then publishes via the new `src/relay/relay-endpoint-credential-publication.ts`: adopt a valid pre-existing file (older clients still pre-write), else mint 32 random bytes and write temp+rename at 0600. A start that loses the bind exits inside `listen()` and never reaches the file. Why this option and not restore-on-loss or a client-side write: the only process that can *prove* ownership is the one whose `listen()` succeeded, and that proof is atomic with the bind. The client-side pre-write (`ssh-relay-endpoint-credential.ts`) and the launch-command `chmod 600`/`icacls` are removed on POSIX and Windows. The racing test also exposed that macOS reports a mid-bind collision as `EEXIST` rather than `EADDRINUSE`; `relay-socket-ownership.ts` now treats both as "held or stale".
**2. The client distinguishes "no daemon" from "daemon present but not answering", and never rewrites.**
A credential refusal is now typed on the wire: the daemon replies `orca-relay-handshake-credential-mismatch` (same frame type, no new opcode) and the bridge exits **43**; `waitForSentinel` maps it to `RelayCredentialMismatchError`, which the takeover treats as handshake-refusal evidence exactly like exit 42. A relay that holds the endpoint but **never refused** (the stalled-host shape: kernel backlog accepts the probe, handshake gets no answer) is now `RelayEndpointUnresponsiveError`, routed to the relay-lost backoff instead of the terminal Reset Relay path. Silence is not a decision (`docs/reference/ssh-execution-boundary.md`).
**2b. Deploy honours the verdict.** The 40 s live run exposed that the `--connect` catch block in `deployAndLaunchRelay` predates the incumbent probe and swallowed both verdicts as "probe failed, launch fresh", so a fresh daemon was still launched over the live one (it lost the bind by luck, which is exactly the collision in the incident). Held and Unresponsive now propagate; the session backs off on Unresponsive and surfaces Reset Relay on Held. Red-first in `ssh-relay-deploy-incumbent-verdict.test.ts`.
**3. The daemon cannot be wedged by a rotated file, because nothing can rotate it.**
The credential lives in the content-hashed relay dir, and after (1) the only writer is the daemon that owns the socket, so the "file changed under a live daemon" state the incident depended on is no longer reachable in-product. The credential is therefore fixed for the daemon's lifetime, as a plain secret should be. A hand-edited file is refused with the typed reply until restored (tested). Startup adoption of a pre-written file applies an owner-only + same-uid rule (review finding): anything else is replaced by a fresh mint. An earlier revision of this PR also re-read the file on mismatch and adopted it; that was removed as unreachable machinery that turned the credential into a per-handshake file-ownership check.
**3b. Fail closed between bind and publication.** A client that arrives after `listen()` resolves but before the credential is set is refused, not admitted as `unproved`. Nothing can be delivered in that window today; the guard makes the boundary structural instead of an event-loop ordering fact. Red-first in `relay-reconnect-listener-credential-gate.test.ts`.
**Wire compat.** New optional handshake reply only; an old `--connect` hits `Unknown handshake type` and exits 1 pre-sentinel, which it already treated as a generic failure. New daemon adopts an old client's pre-written file; new client still passes `--credential-file` so an old daemon reads it as before. Absence of exit 43 is never used as evidence.
**Also.** `terminal create` on a reconnecting SSH host now says what to do instead of a bare `No PTY provider for connection "<id>"` (prefix preserved; the renderer matches it).
## Tests (red first)
- `src/relay/subprocess.test.ts`: two `--detached` starts race one socket + credential file → exactly one reaches the sentinel, loser exits 1 with `Socket path already in use`, file valid + 0600, a `--connect` reading it reaches `relay.status` and reports the winner's pid. Red before (both starters died: daemon required a pre-existing file), green 6/6 after.
- `src/relay/relay-endpoint-credential-publication.test.ts`: mints after bind; adopts a pre-written 0600 file; replaces a pre-written 0644 file with a fresh mint; refuses a stale credential with exit 43 while still serving the real one, and keeps refusing a rewritten file until it is restored.
- `src/relay/relay-reconnect-listener-credential-gate.test.ts`: a client in the bind-to-publish window is refused and never attached; after publication the right credential is accepted and a wrong one refused; a daemon launched without a credential file is not gated. Red without the guard.
- `ssh-relay-deploy-incumbent-verdict.test.ts`: live-but-silent incumbent → `RelayEndpointUnresponsiveError`, refused → `RelayEndpointHeldError`, and in neither case is `--detached` launched; a failed `test -S` probe still launches fresh. Red 2/3 without the deploy change.
- `ssh-relay-deploy-helpers.test.ts` (exit 43), `ssh-relay-endpoint-takeover.test.ts` (refused → Held even with no `lsof`; silent → Unresponsive, nothing unlinked or signalled), `ssh-relay-session-terminal-error.test.ts` (Unresponsive → `onRelayLost`, not terminal). Deploy/namespace/native-deps tests updated to assert the client writes **no** credential.
## Live proof
New `tests/e2e/ssh-docker-relay-stall-credential.spec.ts` (claimed in `run-ssh-docker-e2e.mjs` and PR source routing), two cases: `kill -STOP` every relay pid in the container, send input during the freeze, hold **20 s** (the incident's duration, which races the mux liveness timeout) or **40 s** (past it for sure), `kill -CONT`; assert status back to `connected`, same pty, same daemon pid, same credential inode and content, relay.log did not shrink (a relaunch truncates it) and has zero `Endpoint credential mismatch` / `Socket path already in use` lines, in-stall input delivered at most once.
Run output (local, fixture image `orca-e2e-ssh-relay:3a864c665ba2cefd`, `ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 … --project electron-headless --workers=1`, head `c2c20fd994`; re-run identically on the final head after the credential-lifetime change, 2 passed (1.7m), same annotations, and the bind-to-publish refusal never fired):
```
✓ keeps the same daemon and credential across a 20s relay freeze (38.3s)
relay-processes-stopped: 2 relay-processes-continued: 2
bridge-pids-before-after: 480 -> 480
socket-clients-accepted-before-after: 1 -> 1
in-stall-input-delivered: 1
✓ backs off and reattaches, never relaunching, across a 40s relay freeze (57.5s)
relay-processes-stopped: 2 relay-processes-continued: 4
bridge-pids-before-after: 480 -> 1202
socket-clients-accepted-before-after: 1 -> 3
in-stall-input-delivered: 1
2 passed (1.6m)
```
Client log in the 40 s case shows the new path end to end: `Relay channel lost … reconnect attempt 1/6` → `Socket probe result: "ALIVE"` → `Socket reconnect failed … Relay failed to start within 10s` → `Relay endpoint incumbent: … verdict=live evidence=accepted-connection holders=unenumerable` → `Failed to re-establish relay … A relay still owns … but did not answer the handshake … Orca will retry` → `reconnect attempt 2/6` → `Reconnected to existing relay via socket`. The 20 s case never left the frozen bridge (same bridge pid, one accept), so it exercises the "silence is not death" side of the same race. The 20 s case passed 6/6 across the session; the 40 s case was red on the prior head (`Socket path already in use` + `Startup failed: listen EADDRINUSE` in relay.log from the swallowed verdict) and is green after 2b. Before the fix the same injection produced a fresh daemon that rewrote the credential and a survivor refusing every client.
The `relay-processes-continued` count exceeds `stopped` in the 40 s case because the timed-out client's `--connect` bridge and the loser-side processes are parked behind the frozen listener when `CONT` runs; they exit on their own once it resumes.
## Gates
`pnpm test src/relay src/main/ssh` 332 files / 3884 tests pass · `pnpm typecheck:tsc:node` clean · `check:code-quality:changed` 0 findings · `check:react-doctor:changed` 0 findings · `pr-e2e-gate-contract.test.mjs` 42 pass · no lint disables or max-lines bumps added.
## Noted, not fixed here
- `terminal list` `orphaned:false` / `terminal close` `ptyKilled:true` for a pane whose relay is gone (`orca-runtime-stop-explicitly-closed-tab-ptys.ts`): different seam, `@ts-nocheck` characterization-covered file.
- On a host with no `lsof`, a stalled relay still cannot be enumerated as the holder; it is now retried rather than declared held, but a relay frozen past the backoff budget still ends in the existing "reconnect manually" banner.
|
||
|
|
06a607a1d7 |
feat(orchestration): make multi-agent workflows durable (#16904)
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->
| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 225 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$21666 | $\color{#cf222e}{\Huge{\mathbf{−}}}$2820 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$18846 |
| Prod | 348 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$17107 | $\color{#cf222e}{\Huge{\mathbf{−}}}$4706 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$12401 |
<!-- /orca-pr-loc -->
## ELI5
Orca now treats orchestration like a durable control plane instead of inferring success from terminal keystrokes. Agents can tell whether a prompt was accepted or a turn started, replay an ambiguous request without sending twice, and recover coordinator mail after a crash. Completed workers can be inspected, released, or retained, and their panes no longer auto-resume as if the work were still running.
## What changed
- **Run receipts** from `run-create/use/current/show/list` are the row without routing plumbing (`home_database`, `coordinator_pane_key`) and without the duplicate `binding` object.
- **`terminal send` receipts are honest and idempotent.** `input_accepted` and `turn_started` are the only stages; `--wait-submit` observes without resending; `--retry-request <uuid>` replays the exact request against the same process incarnation. A transport timeout keeps the retry ID; only a different runtime answering strips it. Value-less or non-UUID `--retry-request` is rejected on the CLI and the SSH shim.
- **Mailbox delivery is committed before wakeup.** Pointer writes are staged in the DB before any PTY byte, replayed once after restart, and never emit a naked Enter. The watermark that parks concurrent deliveries is released with the DB reservation. Restart rescans pointer-pending and `dispatch:` mailboxes.
- **Lifecycle is a guarded transition graph** (`lifecycle-transition.ts`) with a table-driven test over every caller edge. Task reopen/overturn stays in the public contract. A PTY exit during `worker-stop` is the stop succeeding, not a failure.
- **Worker lifecycle CLI:** `worker-start` (`--spec` creates Task + attempt in one call), `worker-show`, `worker-read` (provider transcript first, bounded terminal fallback with a typed reason, local/WSL/SSH), `worker-stop`, `worker-abandon`, `worker-release`, `worker-retain`, `worker-list` (rowid-fenced pagination, fleet liveness, `attention`, literal `nextAction`).
- **Release is an explicit ownership table** (`decideWorkerTerminalRelease`): only an `owned` resource can be settled, the archive is mandatory where reachable, and an owner whose process is proven exited can always get out of `retained` via `archive_status: unavailable`. User-taken-over, external, and transferred panes stay retained.
- **Settled-worker resume fence** (folds in #17651): a settled dispatch whose pane is still open is fenced at settlement, on stop/abandon/exit, and at startup; lifted on release, retain, takeover, and pane reuse.
- **Liveness is `live` / `unverifiable` / `exited` only**, from execution-host evidence. Fleet projection reads the evidence clock, not the relay delivery clock. A host-certified exit outranks the worker's settled state. `unverifiable` never authorizes stop, abandon, retry, or release, in code or in the guide.
- **Federation:** structured reads negotiate by `method_not_found` so every shipped host keeps transcript-first output; exited remote workers are closed before being reported closed; epoch fencing holds across peer restart, downgrade, and pairing rotation; no per-second forced capability probe.
- **Schema v35:** repairs databases stamped v34 by the pre-fix branch (mailbox_handle default, index predicates), drops the write-only `lifecycle_transition_receipts` ledger and five never-read v31 identity columns.
- **Schema v36:** `dispatch:<id>` mailboxes get a real consumer generation on `dispatch_contexts` and `remote_dispatch_attachments`, bumped and fenced in the same transaction on every re-attach (manual inject, worker-start, federated attach). A stale worker whose Dispatch moved to another process now gets `consumer_fenced` instead of silently acking the new worker's Delivery. Run mailboxes already worked this way.
- **Schema v37:** `dispatch_contexts` records its creator (`creator_handle`, `creator_pane_key`), so a coordinator's context-only self-dispatch is bookkeeping rather than a nesting parent; before this, one self-dispatch made every later `worker-start` from that coordinator fail the depth cap. Pre-v37 rows keep counting (fails closed).
- **Dispatch-mailbox ownership is checked, not inferred.** A `check` from a process whose pane no longer holds the Dispatch, or whose last Attempt was abandoned/failed and moved to another terminal, gets `consumer_fenced` instead of an empty inbox that reads as "no mail yet". `--peek`/`--all` stay readable. A paneless caller still gets `stable_pane_required` with the rebind recovery.
- **Liveness certification is stricter:** a `process_exited` stage whose termination reason is `unknown` (a stop that was issued but never observed) projects `unverifiable`, not `exited`. Federated `worker-show` carries the execution host's verdict and host kind instead of a local guess. A live, ready worker with nothing pending has `nextAction: none` rather than pointing at the `worker-show` that produced it.
- **Wire:** `workerShow` keeps `dispatch.task_id` next to `taskId` for shipped CLIs. `ask --json` uses the standard `{ok, result}` envelope like every sibling verb.
- **Migration start-version detection** treats the two v32 recovery columns as versioned. Before this, every shipped database stamped below 32 resolved to the v6 floor and replayed the whole chain (the v23 backfill synthesized 68 phantom retained workers on a real v30 profile). Verified on a copy of a real 62 MB v30 profile: starts at 30, no row delta, integrity ok, 11 ms.
- **Skill guide** rewritten as a ≤200-line kernel plus seven references, to the outcome-first standard (Result / Done / Safe failure first, conditions not case lists, one done bar, references loaded at the point of use). The canonical loop uses `worker-start --spec`, names `worker-list` for completion accounting, documents `--retry-request` / `request-show` / `--wait-submit`, and requires positive evidence before any stall action. The other seven guides get the same treatment in #18724, split out so this PR stays orchestration-only.
- **`rpc/methods/orchestration-*`** (126 flat files) regrouped into `orchestration/{worker,federation,messaging,runs,gates}/`.
## Why
User reports showed the same boundary failures: false `agent_prompt_stalled` causing duplicate sends (#15180), coordinators unable to trust screen scrapes, cold-parked terminals receiving a pointer without the submit, settled workers accumulating as live tabs and auto-resuming after restart, and no way to tell a stalled worker from a working one.
## Linked issues
Fixes #15180. Fixes #17935 (orchestration skill description is 866 characters; a guard now caps every bundled skill at 1,024). Supersedes #17651 (fence folded in). Advances #16660, #16522, #14907, #13047.
## Review record
This PR was reviewed adversarially after revival: eight independent lenses (lifecycle, mailbox, send, worker, federation, transcript, complexity, live ergonomics), each required to prove findings with a failing test. That produced 16 proven blockers, all fixed with red-then-green regression tests, followed by two re-review rounds and a third fix wave that caught 3 regressions introduced by the fixes and 7 fixes that missed their target; all closed. A final pass (five lenses incl. a live built-runtime smoke, then a re-review of the fix wave) found and fixed seven more, chiefly the stale-worker mailbox steal, the self-dispatch depth wedge, and the unproven-exit certification. Three independent Codex (gpt-6-astra) passes followed: the first found nothing new, the second found and fixed 3 defects (task-status reachability, WSL-local host classification, peer-capability epoch), the third found and fixed 6 (production PTY controller never installed settled writes, ambiguous in-flight pointer failures allowed duplicate replay, SSH/relay deadlines cut off a valid `--wait-submit`, stop-vs-exit race during inspection, and two release-recovery paths for vanished or exited terminals). The full record (findings, proof tests, triage, declines with reasons) is archived outside the repo.
**Rework after the live smoke.** A first live cross-host run on the shipped adhoc build (this Mac, a paired Windows host on the same build, a paired Mac on 1.4.195, and an SSH host) found a P1: a running local worker read `unverifiable`/`missing_status` because the fleet snapshot rows lacked the terminal handle the matcher keyed on. A 59-row failure table over every bug fixed during review showed the same two classes recurring: a fact dropped in transit through optional fields, and two authorities for one fact. Two blind designs (Opus, Codex) converged on the same mechanisms, and the scoped tranches landed here with red-then-green seam tests from the real producer to the real consumer, faults injected only at the transport or hook-ingest boundary:
- **Settlement (data-loss class):** one three-valued `WriteSettlement` (`accepted | refused{reason} | unverifiable{reason, bytesHandedToTransport}`) from the SSH multiplexer through daemon client, providers, controller, to pointer staging. No boolean, no rejection-as-third-state. The two silent degrades that fabricated a handoff are deleted; a provider that cannot settle refuses before any effect. Pointer text and Enter share the contract; a partial flush is `unverifiable`, never `refused`.
- **Evidence identity (false-liveness class):** fleet agent-status evidence is a tagged union (`binding: worker | pane | unresolved{reason}`, `clock: observed | delivery`) minted once at ingest, so a hook row captured on one process incarnation can never bind to a later dispatch on the same pane. The matcher's `!worker.paneKey ||` defaults are gone. One host-scope parser replaces two.
- **Small pre-merge items:** `capability_unsupported` from an old peer is no longer relabelled `host_unavailable`; a producer census test asserts every agent-status consumer path projects a pane-only hook row as `live`.
Two ergonomics defects the second live run surfaced on a real database are fixed here too: a pre-v3 dispatch already marked `completed` projected as `outcome_unknown` / `requiresAction: true` forever (three copies of the outcome ladder disagreed on legacy rows; now one resolver, legacy `completed` reads `succeeded` with nothing to act on, legacy `failed` stays actionable on the failure), and an unscoped `worker-list` enumerated the entire database (now defaults to the Run bound to the calling terminal, `--run` overrides, and the receipt's additive `scope` field says which).
A third live round on the shipped adhoc build of `b082443e1f` (same four hosts) plus an unscripted run in the user's own prompt style (a plain Claude Code shell, `/orchestration`, three workers, zero errors, bound-Run default confirmed) found two more branch defects, fixed with red-then-green tests: a worker freshly started on a paired server projected `unverifiable`/`host_indeterminate` with `requiresAction` for ~3 minutes, including after its own `worker_done`, because the host's federation observation returned `missing_liveness_verdict` for any PTY the liveness register had not yet swept (the host now reads a connected pane it owns locally as `live`; disconnected or SSH-scoped panes stay `unverifiable`); and six pre-v3 completed rows still carried an `input` category because settling through the task-status path or `failDispatch` never closed the Dispatch's pending question threads (both paths close them now, and schema v38 closes threads already pending on settled rows). The guide's `worker-start` examples now show `--model sonnet`, since an omitted model inherits the launcher's default.
A Codex adversarial pass on the tranche diff found one real design hole (identity minted at read time instead of ingest, now closed) and two daemon settlement paths that threw instead of settling (fixed). Two `@ts-nocheck` runtime mixins on these paths were extracted into checked modules; the repo-wide `@ts-nocheck` count is unchanged at 171.
Deletions during review: ~1,900 lines (write-only ledger, unread columns, dead v1 archive path, test harnesses shipped in prod, duplicated liveness and state-machine copies, self-capability checks that were compile-time true).
## Testing
- `pnpm typecheck:tsc:node|cli|web` clean
- `pnpm run check:code-quality:changed` 0 findings; `check:react-doctor:changed` 0
- `pnpm verify:bundled-skill-guides`, `verify:skill-bundle-manifest`
- full `pnpm test` on the integrated head: 72,332 pass / 292 skipped; the only failures were three non-PR files (two zsh live-shell suites hit a node-pty spawn-helper ENOENT while a concurrent native rebuild ran, 44/44 in isolation; `release-checkout.unit.test.ts` is a known 30 s load timeout that passes in isolation on `origin/main` too).
- CI on
|
||
|
|
6494f2a4f0 |
fix(native-chat): resume a structured chat from Agent Session History (#18933)
* fix(native-chat): resume a structured chat from Agent Session History Clicking Resume on a chat-UI row could only reveal an already-open tab. If the chat had been closed, or this process had never published it, the click re-read an inventory that did not contain it and toasted "Retry in a moment" — advice that could never come true, because nothing republishes an unpublished tab. The legacy `claude --resume` fallback is deliberately refused for structured-owned rows, so the row had no way back at all. `close` already keeps the record and the journal on disk so a session can be attached again, and the hold path already resurrects one in full. What was missing was the tab: `restoreReadableSessions` is latched to run once, at startup, so nothing could ask for a single session later. Adds `agentSession.reveal`. The host looks up its own record, restores the session readable, and republishes the tab through the same call `agentSession.create` uses. Deliberately narrow: - It takes no hold. A provider child exists because a surface asked, and the chat pane asks when it binds. - A journal it cannot read is not a refusal. A chat whose journal predates the SQLite store restores to nothing here, but attach still recovers it, so the tab is published and the pane's hold finishes the job. - Workspace and provider come from the record, never the client, so a session id alone cannot aim the publication at another workspace. Claude and Codex both, by construction: eligibility is `adapterSupportsRecord`, which the router answers from the record's own provider. Gated on a new advertised capability rather than probing for method_not_found, matching agent-session.structured.hold.v1 — absence is visible during negotiation instead of by calling. * fix(native-chat): negotiate reveal against the host that owns the workspace The capability gate read the LOCAL runtime's advertised capabilities while the call went to the host that owns the workspace, which for a paired workspace is a different build. On desktop the renderer and its local host are always the same build, so the gate passed unconditionally and proved nothing about the host being called: an older paired host still received the unknown method and its method_not_found was reported to the user as 'this chat is no longer on this host'. The cache it read also starts empty and resets to empty when status.get fails, so 'not fetched yet' and 'unsupported' were the same value. Gate on the environment that will answer, the way agentSession.close already does, and skip the round trip entirely for a local host. Reveal now reports four outcomes instead of a boolean, so a host that is merely too old is not reported as a chat that is gone, and a host we could not reach keeps the retryable message. Also syncs the localization catalog: the 'gone' key shipped without an en.json entry, which reddens static analysis and verify while typecheck stays green. * fix(native-chat): tell a refused reveal apart from a missing chat The host raises two refusals here and they mean opposite things to a user: it holds no such record, or it holds one no adapter of its own can open. The client collapsed both into 'this chat is no longer on this host', which is a eulogy for a chat still sitting on disk. Read the refusal code, and fold the host-side case in with the too-old host under one honest message, since the remedy for both is the same. Adds the coverage the readiness pass found missing: the host's reveal answer itself (workspace and provider from the record, both refusals, an unreadable journal, a live session), and the activation branches for a host that cannot open the chat and for one that never answered. * fix(native-chat): read a host version block as the host's age, not a lost link The capability probe reaches assertRuntimeStatusCompatible, which throws a runtime_compat_block error. Treating that as unreachable told a user with an out-of-date host to retry, which is the one thing that cannot help. Branch on isRuntimeCompatBlockError the way remote-agent-session-launch already does for the same probe. Also adds the refusal-code case a previous commit claimed and did not deliver: nothing drove a structured_agent_session_unsupported reply through the reveal client, which is the branch that commit existed to add. Corrects a doc comment that reveal made wrong: attach is no longer the only call that builds the host. * fix(native-chat): let a dragged history row reach the same reveal as a click Dropping an Agent Session History row onto a pane activated the tab by id and, on a miss, raised the very toast this PR exists to remove — so the same row answered a click and a drop differently, and the drop kept the advice that can never come true. The structured branch never used the drop pane, so routing it through the shared activation loses nothing and gains the reveal. The helper only ever read one field, so its parameter narrows to that field and the drag payload satisfies it directly. A source ratchet holds both entry points to the reveal-capable path, since a mounted drag harness does not exist for this layer and what regresses is a call site, not a rendering. * fix(native-chat): stop an advisory refresh ending the click, and one click per row Manual QA found the reveal never ran: the inventory refresh that precedes it is an optimization, but its failure returned early with 'not available yet, retry in a moment' — reinstating the dead end this PR removes, one step earlier. A failed refresh now falls through to the reveal, which is the repair and does not need the refresh to have worked. The click can chain a refresh, a capability probe, a reveal and a second refresh, each with its own timeout, while nothing on the row says it is working. A per-session in-flight guard keeps an impatient second click from running the whole sequence again and landing its own toast. Also drops an unreachable owner scope: the snapshot apply discards any worktree whose execution host is not local before it reads one, so naming a remote scope there described a synchronisation that cannot happen. * fix(native-chat): bound the capability probe and stop naming the wrong machine The in-flight guard releases when the activation settles, so an await that never settles holds the row for the life of the process. The capability probe was the one call in the chain not raced against a deadline: on a cache hit it awaits a promise an earlier probe created, which may carry no deadline of its own. Race it like the two calls around it. A version block can name either side — evaluateRuntimeCompat reports client-too-old as well as host-too-old — so a message that blamed the host pointed half of those at the wrong machine. Name the remedy instead of the machine, which is true for every case that reaches it. * chore: remove a scratch repro file committed by mistake It was swept into the previous commit by a broad `git add` while a diagnostic ran in this worktree. It asserts the current renderer-sync defect as expected behaviour, so it would fail the moment that defect is fixed. * fix(native-chat): stop a reveal's own inventory refresh discarding its republished tab Manual QA: the host answered reveal with ok:true and republished the tab, and the chat still did not reopen — only a renderer reload brought it back. The renderer publishes under one epoch string for its whole lifetime, and a frame recorded under a different lineage retires that epoch permanently with nothing to un-retire it. The Resume click asks for an inventory first, and a worktree the host holds no entry for answers with the none/v0 sentinel; the structured path recorded it, retiring the renderer's own epoch, so the tab the reveal published a moment later was dropped. A reload minted a new epoch, which is why reloading appeared to fix it. A frame that carries no publication is not a later publication to fence against. Treat the sentinel and a removal frame as a cursor reset, the way the mainstream session-tabs path already clears its tracking — its comment names this exact hazard: recording that sentinel would retire the host epoch and reject the next live frame. Pre-existing, and it swallows an ordinary new-tab launch on an empty worktree too; the reveal is what turned a silent invisibility into a visible failure. * fix(native-chat): let a retraction prune its rows without retiring the epoch Correcting the previous commit. Skipping a retraction frame outright stopped it pruning the mirrored rows, so a worktree the host no longer publishes would have kept a chat on screen with nothing behind it. Apply the frame as before and clear its cursors instead of recording them, which is what the mainstream session-tabs path does. The unpublished sentinel keeps its cursor now too: it is skipped rather than cleared, so a stale frame arriving late is still fenced. Adds the case the earlier version would have broken. * fix(native-chat): keep the retraction's fences, and fence the reveal's refresh Correcting the retraction handling again. Clearing its cursors was more than the bug needed and cost a guard: the host mints a fresh epoch when it rebuilds a pruned entry, so a republication is never gated by the retained cursor, while dropping it left an inventory response issued before the close free to land afterwards and strand a chat row for a worktree the host no longer publishes. Skip only the recording. The mainstream path keeps its epoch history for the same reason, as a tombstone fence. The test that justified the stronger clearing asserted a host behaviour that does not exist — a rebuilt entry republishing under the renderer's epoch with a restarted counter. It now uses what publishStructuredAgentSessionTab actually mints for a pruned entry, and a new case covers the frame that would strand. Also fences the reveal's inventory refresh on the sync generation, which every other caller that applies an inventory already does: structured chat can be switched off mid-flight, and the answer would otherwise re-seed a row into a renderer that just discarded them. * fix(native-chat): drop the retraction's epoch history, keep its version cursor Third and final shape for this branch, and the only one of the three that holds. Keeping both maps re-poisons the epoch one cycle later: the consumer here is also the publisher, so the history's current is the renderer's own lifetime epoch, and recording the reveal's fresh epoch retires it. The next chat the renderer publishes is then dropped — this bug again, one close later. Deleting both loses the guard that stops a frame issued before the close landing after it and stranding a row nothing republishes. So: clear the history, keep the cursor. The mainstream path keeps its history as a tombstone because there the epochs belong to a remote publisher; that reasoning does not carry to a path that publishes under its own. Each of the three variants now fails a different test. * fix(native-chat): a retraction forgets what is current, not the tombstones The delete lost a fence the cursor cannot replace: the version cursor only compares within a lineage, so a delayed frame from an already-superseded epoch had nothing left to stop it putting a chat row back for a worktree the host no longer publishes. Keeping the record intact had the opposite fault — the renderer's own epoch is the history's current, so the next frame under any other epoch retired it. Clearing only current does neither: noteRetiredValue retires nothing when there is nothing current, and the tombstones stay. Each of the four shapes now fails a different test. * fix(native-chat): narrow the retraction frame through its own type Typecheck caught what the tests could not: `removed` is not on RuntimeMobileSessionTabsResult. The repo already names the shape — RuntimeMobileSessionTabsRemovedResult — so this reads it through a guard rather than the inline cast the mainstream path uses. --------- Co-authored-by: Orca Worker <orca-worker@localhost> Co-authored-by: Merge Sim <sim@local> |
||
|
|
cff202c16a |
fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell (#17880)
* fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell
MDE flags `-ExecutionPolicy Bypass` paired with base64 `-EncodedCommand` as a
behavioural signal. Measured on Windows 11: neither `-Command` nor
`-EncodedCommand` is execution-policy gated (both run under an explicit
`-ExecutionPolicy Restricted` and `AllSigned`; only `-File` fails), so the
switch was a pure no-op on every one of these command lines.
Removes the switch from all four sites that spelled it, and de-encodes the one
site whose payload never passes through a re-parsing shell:
- ssh-remote-powershell: one chokepoint for ~40 remote-Windows call sites.
Base64 kept — the remote sshd DefaultShell re-parses this string.
- setup-agent-sequencing / windows-cmd-runner-delayed-launch: base64 kept —
these strings are typed into a terminal pane.
- windows-interactive-login-spawn: base64 kept — `cmd.exe /c start` re-parses,
and the cmd-safe-token guard rejects the `&` and `"` in the raw relay script.
- windows-mobile-firewall local runner: `-EncodedCommand` -> `-Command`, since
execFile reaches CreateProcess with no shell in between.
The setup startup gate keeps execution-policy relief in-payload (process scope),
because it evals a user-authored startup command that may invoke a `.ps1`, and a
`.ps1` IS gated. Caught by the real-process suite; mirrors the agent-hooks
launcher's trade.
The elevated firewall child deliberately stays encoded: `Start-Process
-ArgumentList` joins its array into one ShellExecuteEx string without quoting
and PowerShell re-splits on whitespace, measured to collapse `C:\My App\...`
to `C:\My App\...` — a firewall rule for the wrong program.
* test(ssh): enforce the no-script-file invariant remote payloads rely on
Dropping `-ExecutionPolicy Bypass` from `powerShellCommand` is a no-op only
while no remote payload loads a PowerShell script file — execution policy has
never gated anything else. That invariant held by inspection and was guarded by
nothing, so a future payload that dot-sourced, used `-File`, or imported a
`.psm1` would break only on a remote host with a Restricted/AllSigned
LocalMachine policy and no GPO: a failure on someone else's machine.
States the invariant at the wrapper, and adds a ratchet that scans every module
importing it for `.ps1`/`.psm1`, `Import-Module`, `-File`, and dot-sourcing.
The scan discovers importers itself (13 today) so new ones are covered, and
asserts it found some, so an emptied list cannot pass vacuously.
Mutation-checked: injecting each construct into a real importer fails the
matching case and names the file. The first dot-source pattern passed a
`;`-prefixed sample but missed `powerShellCommand(". '$x'")` — the likelier
shape — so the pattern now accepts a string-literal start and the self-test
samples carry their surrounding quotes.
* test(ssh): close two blind spots in the remote-payload ratchet
Both found by independent mutation testing of the ratchet itself, and both let
a real violation pass while the guard reported green.
`-File` was matched case-sensitively, so `-file $scriptVar` slipped through —
PowerShell switches are case-insensitive, and with a variable path the `.ps1`
pattern does not cover for it, so that shape escaped both nets. The naive fix
is wrong: bare /-File\b/i matches `--credential-file`, `--log-file` and
`--body-file`, which occur in three of these importers. Anchoring to a token
boundary catches the lowercase, odd-spacing and argv-element forms with zero
offenders across all 14.
Comment stripping paired a `/*` appearing inside a string (a glob such as
'src/*.ts') with any later comment close and deleted everything between, hiding
violations in the gap. Anchoring the block strip to line start, as the `//`
strip already was, fixes it — verified by injecting an `Import-Module` after a
glob string: the unanchored form misses it, the anchored form catches it.
Extends the same case-insensitivity to `.ps1`/`.psm1` and `Import-Module`,
which had the identical flaw (`import-module`, `DEPLOY.PS1` are legitimate
spellings); measured to add no false positive.
Each construct now carries the fixtures it must catch AND the near-misses it
must not, so a future tightening cannot quietly trade one for the other — the
negative fixtures are what would have caught the naive `-File` fix. Non-vacuity
bound tightened to >10 against 14 importers.
* docs(ssh): state what the remote-payload ratchet cannot see
The scan matches source text, so a script file reached only through a variable
(`& $scriptPath`) never appears in source and no pattern can catch it. The
ratchet narrows the hole; the invariant note on `powerShellCommand` covers the
remainder.
Recorded because a guard that reads as complete coverage when it is not is
worse than one that states its edge: the next author trusts it further than it
deserves, and should learn this limit from the test rather than an incident.
* test(ssh): scan remote payloads with the shared source walk
The ratchet had its own tree walk and comment stripper. The walk skipped
neither node_modules/dist/.git nor dot-directories and excluded tests by
`.test.ts` alone, so its importer count -- the guard's own goalpost -- could
be wrong about what it scanned. The stripper was anchored to line start to
dodge a `/*` inside a glob string, which silently skipped trailing comments;
`stripComments` tracks quote state and handles both.
Importer set re-derived against the shared walk: 15, floor unchanged at 10.
* fix(setup): report a failed execution-policy relief instead of swallowing it
The in-payload Set-ExecutionPolicy carried -ErrorAction SilentlyContinue and
an empty catch, so any failure vanished. A Windows PowerShell 5.1 install with
duplicate extended type data fails every cmdlet in Microsoft.PowerShell.Security
-- autoload, not policy -- and the user then saw only their own .ps1 being
refused, with no trace that the relief had been attempted or why.
-ErrorAction Stop is what routes a non-terminating failure into the catch at
all; the catch reports the FullyQualifiedErrorId to stderr and deliberately
does not rethrow, so a broken policy cmdlet cannot take down the startup this
gate exists to run. Success path is unchanged and stays stderr-clean.
Verified by execution on a clean child environment: success -> policy=Bypass,
stderr empty; shadowed failing cmdlet -> diagnostic on stderr and the gate
still continues; the old empty catch -> silent.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
|
||
|
|
5cc432eead | perf(ssh): reuse streamed response idle timers (#18956) | ||
|
|
56626e7daa |
perf(ssh): reuse and release relay startup buffers (#18953)
* perf(ssh): reuse the searched relay startup prefix * perf(ssh): release startup banners after relay readiness |
||
|
|
54a8afc91d |
fix(orchestration): typed error codes for dispatch and worker-start refusals (#18902)
* fix(orchestration): typed error codes for dispatch and worker-start refusals orchestration dispatch (and worker-start, which composes it) surfaced task not found, task not ready, and inject rejected as the same bare runtime_error, so an agent reading the receipt could not choose between creating the task, waiting on dependencies, or picking another terminal. Add task_not_found (data.taskId), task_not_ready (data.status, data.unmetDependencies), and inject_rejected (data.terminal, data.reason), each carrying data.nextSteps so every shipped CLI already prints the recovery. worker-start's not-ready refusal moves from task_not_startable to task_not_ready with the same detail. runtime_error stays for genuinely unexpected failures. Proven red-first from RpcDispatcher through the CLI's own failure formatting, plus an SSH bridge test that the host CLI's typed refusal relays unchanged. * test(orchestration): load CLI formatter at runtime in the dispatch-code test The composite node typecheck (config/tsconfig.node.json without --composite false, as CI runs it) rejects a static import of src/cli from a main test with TS6307. Load the formatter and error class dynamically behind narrow structural types, as the CLI/runtime boundary test does. * fix(orchestration): keep task_not_startable and split the CLI-format proof Review on #18902: - Drop task_not_ready. worker-start already published task_not_startable for a not-ready Task, so renaming it would change an existing receipt value under old clients. dispatch now emits task_not_startable too (it was a bare runtime_error before, so this is purely additive), with the new data.status / data.unmetDependencies / data.nextSteps. - Move the refusal receipts (code, message, data) into src/shared/orchestration-dispatch-refusal-contract.ts so the runtime emits them and the CLI test formats the identical envelope. The RPC test under src/main asserts toEqual against the contract; the new src/cli/orchestration-dispatch-refusal-format.test.ts feeds those same receipts to formatCliError / reportCliError. Neither tsconfig widens and the composite typecheck CI runs is clean. * fix(orchestration): keep published refusal messages and type the DB claim guards Codex review of #18902: - Every call site keeps the exact message it published on main ("Task not found: <id>", "only a ready Task can start.", "cannot retry from Dispatch"); the shared contract now takes the message per site and only owns the code and data. Baseline strings are pinned as literals. - createDispatchContext's own missing/non-ready guards, including the atomic-claim loser, now emit the same typed receipt instead of a bare Error, so a dispatch that races a status change no longer flattens to runtime_error. Covered by a dispatcher-level race test. - Invalid --retry-of keeps task_not_startable but now carries status, unmetDependencies, retryOf, and a retry-specific next step. - Dependency recovery text distinguishes waiting on running deps from retrying/unblocking failed ones. - CLI test adds an unknown-code case so the old-client claim rests on an assertion, not a comment; SSH test asserts exact stdout. - Guide table narrowed to the covered preflight cases; occupancy stays runtime_error and is named as such. |
||
|
|
36a826ff48 |
fix(ssh): compile node-pty from the host's own Node headers instead of nodejs.org (STA-6674) (#18774)
* fix(ssh): compile node-pty from the host's own Node headers instead of nodejs.org
STA-6674: a Linux SSH host that cannot reach nodejs.org never came up. node-pty
ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp's default is
to download node-v<ver>-headers.tar.gz before configuring. The host refused
that connection (ECONNREFUSED) and the relay deploy failed inside npm install,
which the UI showed only as "Disconnected".
Every official Node build and every version manager that unpacks one already
has those exact headers at <prefix>/include/node. Export node-gyp's nodedir to
that prefix, on every command that can compile node-pty (npm install, npm
rebuild, the cloexec patch's rebuild), when the shipped node_version.h matches
the running Node. Both npm_config_nodedir (node-gyp 10, Node 20) and
npm_package_config_node_gyp_nodedir (node-gyp >= 11.4) are set so every Node
the relay runs on reads it. A version mismatch leaves it unset, which is the
existing behaviour.
When a host is both header-less and offline, name that in the deploy error
instead of forty lines of gyp http output, with the two remedies.
Reproduced and verified with a Docker sshd whose nodejs.org resolves to
127.0.0.1, on node:24.12.0 (the user's version), node:20 and node:26:
ssh-relay-offline-node-headers.docker.test.ts.
* fix(ssh): fail loudly when node-gyp ignores the exported Node headers dir
The headers export relies on npm forwarding npm_config_nodedir /
npm_package_config_node_gyp_nodedir into lifecycle scripts. If a future npm
drops that, node-gyp would silently fall back to downloading, and an offline
host would fail with the same "install an official Node" diagnosis -- wrong,
since the host did ship headers.
The prefix now echoes ORCA-NODE-HEADERS:<dir|none> into the command's output
before the compile, and the download-failure diagnosis reads it back: an
exported dir plus a download attempt is reported as an Orca defect naming
the dir, not as a host problem. Nothing else changes when it works.
* fix(ssh): address review on the relay node-headers export
- Unset any inherited npm_config_nodedir / npm_package_config_node_gyp_nodedir
before the conditional export, so a stale header dir from the remote profile
cannot bypass the version check and build a wrong-ABI binding (CodeRabbit).
- Require `gyp ERR! configure error` and a real network errno in the
headers-download matcher; node-gyp's fetch client logs retried attempts it
recovers from, and a FetchError can be a non-2xx mirror answer (pullfrog).
- Say "no local headers matching its own version", since the probe also
rejects a version mismatch, not only absent headers (CodeRabbit).
- Log the same diagnosis from the non-fatal `npm rebuild` fallback (CodeRabbit).
- Docker test waits for the SSH banner on the mapped port before connecting
instead of trusting `docker run -d` (CodeRabbit).
* fix(ssh): read the node-headers marker from the host output, not the quoted command
execCommand rejects with `Command "<command>" failed (exit N): <output>`, and
<command> quotes the whole prefix, marker echo included. The first-match
parser hit that copy and returned `${ORCA_NODE_HEADERS_DIR:-none}"; ...` as a
"dir", so every real no-headers failure was misreported as an Orca defect
(measured by an independent Docker exercise of
|
||
|
|
766b5b153c |
fix(relay): release the ConPTY conin handle after teardown, not before it (#18601)
A Windows SSH relay leaked one Windows File handle per terminal, for the life of the relay process, across reconnects. node-pty's `kill()` flips `readable` on the conin and conout sockets and destroys neither; `_cleanUpProcess` destroys `_outSocket`, so only conin is stranded, and it wraps a real named-pipe handle from `fs.openSync(term.conin, 'w')`. The obvious fix -- and the one config/patches/node-pty@1.1.0.patch ships for the desktop -- releases it at the top of the branch, before `_getConsoleProcessList()` forks and before the native kill. Measured against a real Windows SSH host, that is three times worse than leaving the leak alone: teardown aborts partway, the forked console-list agent is never reaped, and both pipe handles stay alive. Releasing it at the end of the branch instead is flat. 20 spawn/kill cycles, handles bucketed by NT object type, identical numbers standalone and through a real relay: published node-pty File +1/terminal, Process flat desktop patch placement File +2/terminal, Process +1/terminal released last (this) File flat, Process flat `windowsTerminal.js` takes the desktop's error-listener hunks verbatim. The conin listener is not what fixes the leak -- adding it alone changed nothing -- but it is what keeps a pipe error retiring one terminal instead of the host. The desktop patch has the early placement and therefore the regression, measured against its exact installed tree. Correcting it there needs its own verification on a Windows desktop build, so the trees diverge on this one hunk deliberately and a test pins that so a future patch sync cannot copy the bug back. |
||
|
|
2ee507d744 |
fix(ssh): move Windows file writes off PowerShell 5.1 stdin onto sftp (#18596)
* fix(ssh): move Windows file writes off PowerShell 5.1 stdin onto sftp #16432 was fixed by chunking writes to 32KB, on the belief that a `DefaultShell=cmd.exe` host caps one stdin at roughly 50KB. Re-measured on Windows 11 26200.9168 / OpenSSH_for_Windows_10.0p2, that premise is wrong in both directions, and the chunking does not fix the hang. The real constraint: a read on Windows PowerShell 5.1's redirected-stdin handle over a non-pty ssh exec can die permanently when it finds the stream momentarily empty, taking both the remaining data and the EOF with it. It is probabilistic per such read — not a size threshold, and not certain on the first one. Measured by swapping the copy loop for a counting reader: a 1.5s gap before any byte -> 0 bytes received, 6 of 6 1 byte, 1.5s gap, then 32767 -> exactly 1 byte 32768, 1.5s gap, then 32768 -> exactly 32768 a continuous 2MB -> 167936 / 270336 / 372736 Those three 2MB figures are one payload run three times under the same conditions, which is what rules out a threshold. Independently reproduced by a second harness where one 1.9MB counted read completed through 39 reads and another died after 11. A payload that fits one burst usually presents only one read that can find the stream empty, which is why 32KB mostly works — and it still failed 15 times in 120 under load, and 1 in 40 on a quiet host. Neither rate survives the 62 execs a 1.9MB file needs: even 2.5% compounds to about four uploads in five failing. No chunk size helps, because the defect is per blocking read, not per byte. Three controls on the same host, same DefaultShell, rule out both a size limit and cmd.exe: `findstr` took 2,016,000 bytes through one exec's stdin, sftp moved 1.9MB 5/5, and PowerShell 7 took 2MB in one exec. Windows writes now go over the sftp subsystem, whose batch script is read by the *local* client, so no remote process reads a pipe at all. PowerShell 7 is the fallback where sftp is unavailable, and Windows PowerShell 5.1 is last, still bounded, and now reports the host limitation and its remedy instead of a bare timeout. Measured on the same host, through this code: 1.9MB x20 all succeeded, hash-verified, median 315ms, against 0/6 before. 32KB x120 zero hangs, against 15/120. Also: - Stage under a unique name per attempt. An abandoned write leaves a remote process that may still hold the staging file, and losing contact is not evidence it died (docs/reference/ssh-execution-boundary.md), so a retry must not reuse a name its predecessor may own. Sweep is best-effort and never treated as proof of anything. - Create upload directories over sftp too; the JSON mkdir batch rode the same defective read. - Cover makeWindowsWriteFileCommand and the publish command against the 8000-char budget, which F11 flagged as untested. * fix(ssh): replace the staged Windows write atomically, and translate ssh -l Three review findings, all on the failure path that the success-path measurements say nothing about. CodeRabbit, Critical: the publish deleted the destination before moving the staged file onto it, so a failed move destroyed the user's existing file and left a window where a reader saw no file at all. That is worse than the truncated partial the staging discipline exists to prevent. Now File.Replace (Win32 ReplaceFile, atomic), falling back to a plain Move only when the destination is absent — and that race is safe, because a destination appearing in between makes Move throw with the staged file preserved. The exclusive branch already had it right: Move throwing on an existing destination is the exclusive contract. Append stays non-atomic and now says why. buildSshArgs can emit '-l <username>' for a config alias no Host block claims, and the translator threw on it. isSftpUnavailableError read that throw as 'this host cannot do sftp', so those hosts fell back to the defective PowerShell 5.1 path and had the refusal cached against them for 30 minutes, silently. '-l' now maps to '-o User=', with a test for the exact argument shape buildSshArgs produces in that case. CodeRabbit, minor: two assertions passed on an absent observation — an unmatched regex yields '' and every() is true of an empty list. Both now assert the positive form first, and the same audit was applied to the three other some()/every() assertions in the file. The temp-file test now asserts mode 0600 rather than only that the file is cleaned up. * fix(ssh): keep a path sftp cannot spell from becoming a verdict about the host Audit of isSftpUnavailableError, prompted by the '-l' gap having the same shape: a per-operation condition being written into a per-host cache that holds for 30 minutes. It had a second instance, and this one was mine. UnsupportedSftpPathError was classified as 'this host cannot do sftp', but it is thrown for a UNC or relative destination and for any path sftp's batch lexer cannot quote -- including a *local* filename containing a newline, which POSIX clients allow. One such file would have routed every later Windows write to that host down the defective PowerShell 5.1 path for the rest of the cache window. The host verdict is now only the errors that really are host-scoped: a refused subsystem, a client that will not start, and an untranslatable argument list. A path refusal falls back for that one write and leaves the cache alone, in both the file-write and directory-creation paths. Revert-tested. Removing the operation-scoped catch fails all three new tests, whether or not the predicate is also widened. Widening the predicate alone does not fail them, correctly: with the catch in place the predicate no longer gates that path, so keeping it narrow is defence-in-depth rather than the live mechanism. Flag audit at the same time: -F, -o, -T, -S, -p, -i, -J, -l and -- are now the complete set buildSshArgs can emit, and all are handled. * fix(ssh): make the atomic publish actually run, and unroll the mkdir batch Two runtime defects that only a real host could surface. Both were invisible to unit tests that assert the shape of the generated command string, because both are PowerShell rejecting an argument at execution time. File.Replace was passed a bare $null for destinationBackupFileName. PowerShell coerces $null to an empty string when binding a .NET string parameter, and Replace rejects that with 'The path is not of a legal form' -- so every create-mode publish failed. The Critical fix was inert as shipped. Now [NullString]::Value, which is the construct that exists for this. Measured on awin, same staging-file lock, opposite outcomes: old publish rc=1 destination MISSING <- prior contents destroyed new publish rc=1 destination PRESENT, sha 7f06b7e0... unchanged control, destination present, no lock rc=0 replaced exactly control, destination absent, no lock rc=0 Move fallback created it End-to-end through the real uploader afterwards: 1.9MB x15 all hashes exact, median 303ms; overwrite of an existing destination exact both times. Separately, the PowerShell mkdir fallback could not create a tree of more than one directory. '@($json | ConvertFrom-Json)' wraps the parsed array in another array, so the loop variable binds to the whole thing and [string] of it is the paths joined by spaces. It only ever worked for a one-element batch, where stringifying a single-element array happens to yield the element -- which is why no existing test caught it. Pre-existing on main; fixed here because this PR puts that command on the fallback tier and claims the ladder works. Both tiers now verified live against a three-directory tree. |
||
|
|
561a94038c |
fix(ssh): stop the daemon's own services from blocking the superseded-relay reap (#18586)
`isReapableRelayHusk` required `childCount === 0`, where `childCount` came from `pgrep -P <relay> | grep -c .`. But the relay forks service children of its own, and `relay-ai-vault-service.js` never exits once spawned. Any relay that had served a single AI Vault request therefore reported a non-zero child count forever, so the sweep answered `retained-live-work` for a superseded, disconnected relay holding no user work at all — and its version directory stayed pinned against GC by its own live socket. The probe now censuses each direct child instead of counting them, and the reap gate reads the count of children it could *not* positively identify as relay infrastructure. The asymmetry is the safety argument (docs/reference/ssh-execution-boundary.md): subtracting a child we can name is positive knowledge, assuming about one we cannot is not. An unrecognised argv, an argv `ps` would not print, and a host without `pgrep` all keep the relay unreapable. `reapEmptyRelayHuskCommand` re-runs the same census on the host immediately before signalling. Fixes #13614 |