From e3639ef8d69209b8278b41c0e722811960ea7ec0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 8 Oct 2026 05:36:25 -0700 Subject: [PATCH] Phase 3.5: fix the top SSH/remote issues at the root cause (#26483) * fix(remote-runtime): stop arm64 Linux servers dropping clients every heartbeat (empty WebSocket write EFAULT) (#26200) * fix(remote-runtime): never write an empty WebSocket payload on its own (arm64 Electron EFAULT) Patch ws@8.22.0 Sender.sendFrame so a frame with an empty payload is written as its header alone. Under Electron on arm64 kernels with 39-bit virtual addresses, socket.write() of a zero-length buffer fails with EFAULT and kills the connection, so every empty heartbeat ping, auto-pong and close dropped the remote runtime with 1006. Wire bytes are unchanged. * test(remote-runtime): run the ws empty-payload guard on real Node Bun resolves 'ws' to its built-in client, so under the Bun test project the guard exercised Bun's implementation instead of the patched package. --------- Co-authored-by: m4air * fix(orcad): ship the session scanner child so agent session history lists on orcad hosts (#26309) orcad built every forked child except session-scanner-service-entry.js, so aiVault.listSessions always failed on an orcad host. The resolver also skipped the adjacent entry whenever isPackaged was true, so shipping the file alone would not have been enough. - build every ORCAD_CHILD_ENTRY_POINTS entry from one loop and add the scanner - add it to ORCAD_ARTIFACTS so a deploy missing it fails integrity checks - share one forked-child path resolver (asar layout vs flat app root) between the watcher and the scanner - smoke-fork the built scanner during build-orcad under both runtimes Fixes #24105 Co-authored-by: m4air * fix(remote-runtime): one owner for a remote pane's binding and recovery (#26308) A remote pane no longer latches in connecting on terminal_not_found, holds keystrokes typed during same-terminal recovery instead of dropping them, reports transport-owned retries so the pane is not remounted mid-retry, and no longer infers absence from an inventory that predates the leaf. Co-authored-by: m4air * ci(e2e): move apt off the Azure mirror on runner images that use a mirror list (#26335) Ubuntu 24.04 runner images resolve apt sources through /etc/apt/apt-mirrors.txt, so rewriting only the source lists left package downloads on azure.archive.ubuntu.com, which intermittently fails ('Ign:' on every package). Rewrite the mirror list too, and retry the install once with --fix-missing. Co-authored-by: m4air * fix(terminal): remote snapshot replay keeps scrollback it cannot replace (#26319) Desktop subscribers of a remote terminal received screen-only snapshots, and every pushed snapshot replay cleared the pane's scrollback with ESC[3J. A reconnect, a remount, or a recovery after a dropped frame therefore wiped history the host never resent. - Host: desktop subscribe snapshots use the same scrollback-row ladder as mobile, and SnapshotStart now carries an optional scrollbackRows field. - Client: a pushed snapshot clears scrollback only when the host says it carries history; screen-only images (scrollbackRows 0, or older hosts that omit the field) repaint the screen and keep the pane's history. Co-authored-by: m4air Co-authored-by: Claude Opus 5.5 (1M context) * fix(daemon): clean launch env, validate client frames, fit the socket path (#26306) * fix(daemon): clean launch env, validate client frames, fit the socket path - Drop Chromium's DBUS_SESSION_BUS_ADDRESS=disabled: marker from the daemon, its systemd-run scope launch and every PTY env (#25580, #21119). - Use the durable user-manager scope only when that manager outlives the caller: lingering on, or the caller already runs inside it (#24201). - Shape-check hello and request frames, and catch request rejections, so a malformed frame closes one connection instead of the daemon (#17841). - Move the daemon socket to a short per-uid private dir when the data root would overflow sun_path (#17840). * fix(daemon): validate a relocated socket dir before any connect, not just before bind --------- Co-authored-by: m4air Co-authored-by: m4air * fix(ssh): keep crediting a PTY after its recovery delivery is canceled (#26341) A canceled recovery delivery released its source spans while the renderer and model could still settle them; the stale projection then threw on every later ACK, so the next delivery was never credited and the relay paused the PTY for good. Settling or transferring a span of a closed delivery is now a no-op. Co-authored-by: m4air * fix(rpc): deny-by-default caller permissions and a scoped SSH bridge credential (#26337) * fix(rpc): deny-by-default caller permissions and a scoped SSH bridge credential Every RPC method now declares a required permission, and RpcDispatcher checks it against the caller's scope on every transport (owner, mobile, paired runtime, SSH bridge). The SSH relay bridge no longer spawns the client CLI with owner authority: each invocation gets a short-lived credential bound to its SSH target, revoked when the child exits. Without the new per-host opt-in, that credential reaches only its own host's terminals; with it, workspace methods but never desktop control, accounts, settings writes, skill install/share, pairing or host admin. Paired runtime clients need a pairing-time grant (orca serve --grant-desktop-control) for computer.* actions. Fixes #23835 Fixes #25259 Refs #18269 * fix(rpc): let a bridged SSH worker report to its own coordinator; allowlist paired-runtime permissions Without the per-host opt-in, orchestration send/ask/reply/check/inbox now pass when the caller is a terminal on the bridged host, binding pane key, payload Dispatch, run and recipient to that caller's own orchestration (recipient off-host only as the creator of its active Dispatch). Paired runtime clients get an explicit standard permission set; pairing-admin and desktop-control are grant-only. * fix(rpc): resolve a bridged SSH caller's own Run and Dispatch mailboxes before admission Mail routing stores a worker's follow-ups under dispatch: and worker questions under run:, so comparing the original recipient to the caller's terminal handle refused an SSH worker replying to its coordinator, an SSH coordinator answering its own Run's question, and explicit sends to the caller's own run:/dispatch: address. The bridge now resolves the exact mailboxes the caller's live pane and process read (as check does) and admits replies and recipients against those. * refactor(rpc): parse SSH bridge selectors with a typed schema instead of Reflect.get --------- Co-authored-by: m4air * fix(projects): remove a project only on the host the user picked (#26340) * fix(projects): remove a project only on the host the user picked removeProject now requires a hostId and matches that host's row exactly. The bare-id fallbacks (unique candidate, focused host) could resolve to another host's live project and delete it. Orphaned SSH purge and group deletion now pass each row's own host. Fixes #13071 * fix(projects): group delete removes only the group's member rows Same-id rows on other hosts outside the group were removed because membership was rebuilt from bare ids after the cascade cleared it. Capture (id, host) member pairs before deletion and remove only those. * fix(projects): never let a stale catalog widen a project removal to every host The renderer chose id-only `repos:remove` when its own catalog showed no same-id row on another host. A stale catalog (another window, a paired client, or a pending refresh) hid that row, and main then deleted every host's registration and saved workspace metadata. - The local catalog route now always calls `repos:removeForHost` with the owner row's host, for local, direct SSH, git and folder projects. - `Store.removeProject` refuses an id that rows on more than one host share, so no id-only caller can delete another host's row. - Setup rollbacks remove the just-created row by its host. --------- Co-authored-by: m4air * fix(serve): load SSH targets on a headless serve; a user Quit only closes serve-hosted windows (#26348) * fix(serve): load SSH targets on a headless serve; a user Quit only closes serve-hosted windows - launchServeMode registers the SSH handler layer before RPC binds, so a windowless 'orca serve' lists and connects the profile's SSH targets (#25886, #8489). - Under serve, the app-menu Quit/Exit, tray Quit and renderer-recovery Quit close the desktop windows instead of quitting the process (#15537). Signals, supervisor stops, relaunches and update installs still quit normally; on macOS a signal reaches before-quit the same way Cmd-Q does, so the guard lives on the user commands, not in before-quit. * fix(serve): SSH profiles serve on Electron; native macOS quits and frozen windows no longer trap or stop serve - orca serve: a profile with saved SSH targets stays on the Electron host by default, since orcad has no SSH stack yet (#25886, #8489). ORCA_SERVE_RUNTIME=orcad still forces orcad. - macOS serve: Dock > Quit, app-switcher Cmd-Q and other native terminates only close the desktop windows (#15537). Signals, supervisor stops, relaunches, update installs and logout/shutdown still quit. - The serve signal listeners now reclaim SIGINT/SIGTERM/SIGHUP from Electron's native handler. Any signal that had a Node listener before app ready was left with Electron's handler, so these listeners never ran. - A serve user Quit closing a frozen renderer's window arms the same 10s ack deadline as a desktop quit, and skips minimize-to-tray. * fix(serve): stay on Electron when the profile's SSH targets cannot be read yet A valid older-schema profile is rejected by the read-only probe, and the selector took that as "no SSH", so a default serve before the first migration started orcad with an empty SSH registry. Fail closed instead. * refactor(window): type user-Quit close marks as a named window slice, not object * test(serve): pass the desktop-control grant default in the headless SSH launch fixture * test(serve): run the serve-selection SQLite fixtures in the Node runtime project --------- Co-authored-by: m4air * fix(terminal): keep the main-process terminal tail linear under TUI redraw floods (#26316) * fix(terminal): keep the main-process terminal tail linear under TUI redraw floods Full-width in-place row rewrites rebuilt the row string per character (O(width^2)), a long chunk past the line cap spliced the row array once per newline (O(lines x cap)), and multi-frame chunks summed every cursor-up so they fell back to the whole-tail path. Rows now hold mutable cells joined once, capped rows are dropped in batches, and the redraw window uses the net cursor reach. Fixes #11315 * fix(terminal): do not rebuild a redraw row the cursor only revisits Rows stayed cell arrays once touched and re-joined on every newline, so a cursor revisiting one wide row thousands of times cost O(width) per newline. Runs now splice into the row string, a row switches to cells only after 32 edits between snapshots, and cells are joined once and dropped. * fix(terminal): keep one cached row per chunk so unchanged redraw revisits cost O(1) A row now keeps its cells, right-trimmed snapshot and content end across newlines and only invalidates them on a real content change; identical writes leave it clean. Adds a work-accounting matrix over restore, the append path and live onPtyData, plus a per-character row oracle fuzz. * fix(terminal): return redraw rows to flat strings with cached snapshots Cells made every changed snapshot a full-width join, which was far slower than the old flat-string splice. Rows are flat strings again, written one run at a time; each keeps a cached right-trimmed snapshot and content end that only a real change invalidates, so identical rewrites and cursor-only revisits stay O(1). A wall-time test now times every review probe against a vendored copy of the old row model on restore, append and onPtyData. * test(terminal): read runtime leaf and PTY tail through typed members --------- Co-authored-by: m4air Co-authored-by: m4air * fix(ssh): relay fallback runs Orca's pinned Node by default, never a host npm install (#26303) * fix(ssh): relay fallback runs the pinned Node ladder by default, never a host npm install A host whose connect falls back to the relay route now gets the A/B/C ladder (Orca's uploaded Node and prebuilt addons) unless it opts into Host Node. A refused rung C steps to rung D with an actionable reason instead of compiling native deps on the host. * fix(ssh): mark the opt-in Host Node relay runtime as unsupported on the SSH host card The relay fallback now defaults to Orca's pinned Node ladder; the host-npm path is reachable only by an explicit per-host Runtime or ORCA_SSH_REMOTE_RUNTIME override. When a relay actually launches on it, main publishes hostNodeRuntime on the connection state (optional, admitted field-by-field for paired clients) and the SSH host card shows an unsupported-configuration warning. * fix(ssh): keep the relay runtime ladder honest about what it cannot verify - NixOS's stub loader refusing Orca's generic Linux Node (exit 127, 'Could not start dynamically linked executable') is classified wrong_libc, so the ladder steps on to the host Node instead of failing the connect at rung A. - In plain SSH mode the shell provider answers absent only for shells it saw exit; a PTY id it never minted (one an earlier relay may still run) is unverifiable, never exited. - The rung D message offers Host Node only when the host's own Node refused the prebuilt addons, and calls it an unsupported configuration; a download, unresolved-host or Windows refusal gets no Host Node advice. * fix(ssh): keep the host-Node relay where the ladder can't judge the host A client without Orca's runtime artifacts (a dev, e2e or source build with no orcad template, or an offline client) refused rungs A and C with artifacts_unavailable, and the connect settled on rung D. That reason describes the client, so it now lands on the host-Node relay it had before the ladder, marked unsupported, and persists no host decision. A proved noexec still lands on D. Windows hosts have no rung B or C yet, so any rung A refusal there keeps the host-Node relay too. The rung D message is worded from the host OS: Windows gets no noexec-mount wording and is offered Host Node. * fix(ssh): a host-Node fallback with no Node settles rung D, and mutable refusals expire The host-Node relay the ladder chooses in place of D (a client artifact gap, or a Windows rung A refusal) hard-failed the connect when the host had no Node: the resolver threw a plain Error, which plain SSH mode does not admit. The fallback now proves host Node with a strict probe first; an answered "no Node" settles D with a classified, host-OS worded reason, and an unanswered probe stays a retryable failure. A gap no longer rewrites D to host Node once rung C proved host_node_missing. Mutable pinned-runtime refusals (missing_lib, security_software, and noexec on Windows) are no longer persisted and replay from memory for ten minutes only, so a repaired host gets rung A back. The in-memory refusal cache moves to ssh-relay-pinned-refusal-cache.ts. * fix(ssh): no host does worse than the pre-ladder host-Node default Restructure the relay runtime ladder around one invariant: A, B and C are tried first, and any refusal past them falls back to exactly the pre-ladder default, the host-Node relay marked unsupported. That covers an unidentified libc, a client artifact gap, Windows, mutable refusals and a C N-API floor the npm build would not have hit. Rung D is reached only when that fallback proves the host has no usable Node, or a proved noexec home would defeat it too. Strict host-Node resolution now treats only a command that ran and exited as an answer, so a refused channel (MaxSessions), session limit or timeout stays a retryable failure instead of a 'no Node' verdict. The launch reuses the Node the fallback probe found. Mutable refusals (missing_lib, security_software, Windows noexec) persist again with their proof time and replay for 24 hours, so a host that still blocks Orca's runtime is not re-uploaded to on every connect, while a repaired one gets rung A back. A table-driven test asserts the invariant per host class. * fix(ssh): every answered rung failure reaches the host-Node default, and D only past it Two shortcuts skipped the pre-ladder default. An answered but unclassified install failure (tar ENOSPC, an extract or hash marker, an unverified Windows promote, a self-test that failed outside every refusal class) aborted the connect on every attempt; it now steps down as a non-persisted install_failed. Only unanswered failures (abort, timeout, channel-open, session limit, an unconfirmed termination, SFTP connection loss, ssh exit 255) stay retryable, and nothing steps down once a relay launch was issued. A proved noexec no longer jumps to D: exec denial can be per binary (AppArmor allowing /usr/bin/node), so it skips B and C, which load from the same tree, and reaches the host-Node fallback. D now follows only that fallback answering with a failure, so its outcome is a superset of the default's by construction. The never-worse test is now the cross-product of every A, B, C and host-Node answer on Linux and Windows, plus named review cases. * fix(ssh): system-ssh upload failures and a broken cached runtime step down before launch System-ssh directory upload and system sftp threw plain Errors that dropped the host's exit status, so a remote tar ENOSPC or a Windows sftp permission refusal aborted the connect instead of stepping down. The remote side now throws SystemSshCommandExitError (ssh exit 255, signals and lost-connection diagnostics stay retryable), and a local tar failure is still blamed on the client rather than the remote tar that saw its truncated stream. The warm runtime check only tested that the verified executable existed, so a cached runtime that the host's exec policy or a removed library had since broken was launched and hung on every reconnect. The same command now also runs it and reports a refusal the way promotion does, so it steps down before any launch, with no extra round trip. * fix(ssh): keep the remote answer when local tar only failed writing to it When the host's tar exits early (ENOSPC, a refusal), BSD tar on macOS exits 1 with 'Write error' and no signal, so the upload rethrew the local error and dropped the typed remote exit, aborting the connect. The remote answer now wins whenever local tar finished, was signalled, failed after ssh closed, or reported a write or pipe error; a local tar that failed on its own input first is still the client's fault. * test(ssh): hostile-host cells allow read-only version probes, never installs; probe each Node once The noexec-home cell failed on 'npm --version': under the never-worse ladder a proved noexec reaches the strict host-Node fallback, whose probe asks npm its version before settling D. The cells now judge install and compile calls (npm install/ci, node-gyp, cc, make) and allow ' --version' / '-v'. The probe ran twice because the login shell named the Node the path probe had already rejected. Candidate checks are now memoized per resolution, for the host-Node fallback and rung C alike. --------- Co-authored-by: m4air Co-authored-by: m4air * chore(i18n): regenerate the runtime-required English catalog after the main merge * fix(serve): serve on the profile the CLI selected, and refuse any other before the instance lock (#26424) Co-authored-by: m4air * fix(remote-runtime): open terminal tabs reattach after a managed server update re-pairs the host (#26433) * fix(remote-runtime): a remote pane rebinds after a server update re-pairs its host A pane captured the host's pairing revision once, when it mounted. A forced managed-server update re-pairs the host under the same environment id, so every later request from that pane was refused as stale, and the refusal counted as fatal: the pane stopped retrying and Reconnect replayed the same stale revision. Each request now carries the current revision, a pairing refusal re-reads the catalog before the retry, and the pane treats it like lost contact. * fix(remote-runtime): close a stream left on the old pairing and ignore a 0x0 grid hold A stream that reopened before main re-paired stayed open while sendFrame refused its stale revision, so typed keys vanished. A revision change now closes it as recoverable so the pane rebinds on the new pairing. A just-restarted server also published a 0x0 hold that shrank the visible pane to one cell; holds with no grid are ignored. * fix(remote-runtime): a pane follows only its own machine's re-pair A re-pair under the same environment id to a different host key published its revision before the catalog retired the old tabs, and the pane recovered at the new revision, so its next call was authenticated as the replacement peer. The catalog now classifies each re-pair before publishing revisions: a replaced or removed machine's terminal streams end for good first, and a pane only advances its pairing along a proven same-host rotation (same registration and host key). An unresolved identity holds the pane until it is proven. --------- Co-authored-by: m4air * refactor(rpc-permissions): Phase 3.5 elegance pass (#26471) * refactor(rpc-permissions): drop never-read incarnation and invocation id from bridge credentials * refactor(rpc-permissions): define runtime device grants once and reuse bridge scope fixtures * refactor(rpc-permissions): decide terminal host membership in one predicate and share the opt-in hint * refactor(rpc-permissions): parse bridge selectors once per call * refactor(rpc-permissions): admit and bind unopted bridge methods from one table --------- Co-authored-by: m4air * refactor(remote-pane-binding): Phase 3.5 elegance pass (#26470) * refactor(remote-pane-binding): keep the pane's pairing revision in one slot * refactor(remote-pane-binding): share one hold decision across the input entry points * refactor(remote-pane-binding): define the persisted-handle adopt once in attachPane * refactor(remote-pane-binding): build the pushed-snapshot meta once * refactor(remote-pane-binding): drop the test-only peerReplacedEnvironmentIds wrapper * test(remote-pane-binding): share the input and subscribe frame readers via stream fixtures --------- Co-authored-by: m4air * refactor(relay-ladder): Phase 3.5 elegance pass (#26474) * refactor(relay-ladder): put the host-Node fallback in the ladder before D The Auto ladder now lists legacy as the rung before D, so the step function is one rule plus the next rung. Deletes nextRelayRuntimeStep, the hostOs step context and the mutable hostNodeFallback flag (now a constructor-time laddered field). Windows still ends at legacy: B and C refuse there before any host I/O. * refactor(relay-ladder): one rung-D reason and message over reachable states D is reached only from the host-Node fallback, so its refusal is host_node_missing or install_failed. Merges the reason and message functions, drops three message branches that could not run, and folds noexecRemembered/noexecProved into one noexec field. * test(relay-ladder): one never-worse cross-product with a remembered axis Drops the named cases (each matches a cross-product outcome), the transport case that only checked the model against itself, and the third case the rank check already implies. Adds rung B's noexec answer and per-rung remembered flags for A and B, the only rungs that replay a cached refusal. * refactor(ssh): one answered-miss rethrow for the node resolution probes Four catch blocks repeated the same session-limit, abort and strict checks. They now call rethrowUnlessAnsweredMiss and keep their own fallthrough. Also drops a stale 'Caches nothing' comment. * refactor(ssh): share the host-answered system-ssh exit rule; flatten the upload throw cascade isHostAnsweredSystemSshExit owns the 255/null transport rule for both the upload pipeline and isAnsweredHostFailure. settleUploadPipeline drops a branch that could not run: a failed tar is judged once, then ssh, then pipe. --------- Co-authored-by: m4air * refactor(serve-daemon-misc): Phase 3.5 elegance pass (#26478) * refactor(ssh-pty): build obligation transitions behind one closed-delivery guard * refactor(build-orcad): table-drive the shipped worker smokes * refactor(daemon): pass scope probe seams as one deps object and share cgroup line parsing * refactor(repos): route every project removal through removeProjectForHost * refactor(build-orcad): type the worker smoke table for the type-aware gate --------- Co-authored-by: m4air Co-authored-by: m4air * refactor(terminal-tail): Phase 3.5 elegance pass (#26465) * refactor(terminal-tail): share mulberry32 and compare whole results in redraw equivalence tests * refactor(terminal-tail): keep one splice ceiling at the onPtyData entry point --------- Co-authored-by: m4air * fix(terminal): bound the backing a redraw row can pin across chained erases (#26547) Each erase that keeps at least half the row skipped the copy, so a chain of them left nested slices pinning the original wide row. Rows now track an upper bound on the string they slice and copy once the cumulative shrink passes half. Co-authored-by: m4air --------- Co-authored-by: m4air Co-authored-by: m4air Co-authored-by: Claude Opus 5.5 (1M context) --- .../build-plugins/plain-node-entry-guard.ts | 1 + config/patches/ws@8.22.0.patch | 19 + config/scripts/build-orcad.mjs | 79 ++- ...oreign-sqlite-reader-worker-smoke.test.mjs | 4 +- config/scripts/orcad-entry-build.mjs | 10 +- .../scripts/session-scanner-service-smoke.mjs | 87 +++ .../session-scanner-service-smoke.test.mjs | 74 +++ config/scripts/vitest-node-runtime-files.mjs | 4 + .../scripts/vitest-sqlite-runtime-files.mjs | 2 + pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 1 + src/cli/handlers/core.ts | 3 + src/cli/index-serve-command.test.ts | 13 + src/cli/index.ts | 14 +- src/cli/root-help-text-secondary.ts | 2 +- src/cli/runtime/launch-serve-runtime.test.ts | 72 +++ src/cli/runtime/launch-user-data-path.test.ts | 58 ++ src/cli/runtime/launch-user-data-path.ts | 48 ++ src/cli/runtime/launch.test.ts | 24 +- src/cli/runtime/launch.ts | 81 ++- src/cli/runtime/metadata.ts | 8 + src/cli/runtime/serve-orcad-launch.ts | 2 + .../transport-ssh-bridge-credential.test.ts | 53 ++ src/cli/runtime/transport.ts | 4 +- src/cli/specs/serve.ts | 4 +- src/cli/ssh-bridge-command-policy.test.ts | 51 ++ src/cli/ssh-bridge-command-policy.ts | 60 +++ .../session-search-in-process-service.ts | 4 +- ...session-scanner-service-entry-path.test.ts | 25 + .../session-scanner-service-entry-path.ts | 53 +- .../codex/codex-daemon-socket-path-guard.ts | 5 +- src/main/daemon/daemon-cgroup-scope.test.ts | 237 ++++++--- src/main/daemon/daemon-cgroup-scope.ts | 115 ++-- src/main/daemon/daemon-client-connections.ts | 19 +- src/main/daemon/daemon-client-frame-guards.ts | 30 ++ .../daemon-client-frame-validation.test.ts | 144 +++++ .../daemon/daemon-client-socket-connect.ts | 3 + src/main/daemon/daemon-health.ts | 9 + .../daemon-launched-child-spawn.test.ts | 30 ++ .../daemon/daemon-launched-child-spawn.ts | 5 +- .../daemon/daemon-out-of-process-launcher.ts | 4 + src/main/daemon/daemon-server.ts | 10 +- .../daemon-socket-endpoint-path.test.ts | 160 ++++++ .../daemon/daemon-socket-endpoint-path.ts | 81 +++ src/main/daemon/daemon-spawner.ts | 3 +- .../daemon-user-manager-lifetime.test.ts | 103 ++++ .../daemon/daemon-user-manager-lifetime.ts | 85 +++ .../pty-subprocess/spawn-environment.ts | 2 + src/main/forked-child-entry-path.ts | 55 ++ src/main/ipc/app.ts | 3 +- src/main/ipc/orca-profiles.ts | 5 +- src/main/ipc/parcel-watcher-entry-path.ts | 58 +- .../pty-controller-ownership-routing.test.ts | 23 + .../ipc/repos-add-linked-worktree.test.ts | 1 - src/main/ipc/repos-create.test.ts | 1 - .../repos-local-add-and-project-setup.test.ts | 2 +- src/main/ipc/repos-picker.test.ts | 1 - .../ipc/repos-remote-client-events.test.ts | 23 +- src/main/ipc/repos-remote-test-harness.ts | 4 +- src/main/ipc/repos-remote.test.ts | 1 - src/main/ipc/repos-sparse-presets.test.ts | 1 - src/main/ipc/repos.ts | 1 - .../ipc/repos/project-host-setup-handlers.ts | 4 +- src/main/ipc/repos/repo-catalog-handlers.ts | 6 - .../ipc/ssh-host-node-runtime-connect.test.ts | 93 ++++ src/main/ipc/ssh-plain-ssh-connect.test.ts | 4 +- ...utput-intake-recovery-cancellation.test.ts | 119 +++++ .../ipc/ssh-pty-output-source-obligations.ts | 57 +- ...sh-pty-source-ack-session-contract.test.ts | 75 +++ src/main/ipc/ssh-renderer-broadcast.ts | 8 + src/main/ipc/ssh-session-teardown.ts | 2 + src/main/menu/app-menu-quit-item.ts | 23 + src/main/menu/register-app-menu.test.ts | 30 ++ src/main/menu/register-app-menu.ts | 10 +- ...ructured-agent-session-queued-mutations.ts | 2 +- src/main/orcad/orcad-command-arguments.ts | 5 + src/main/orcad/orcad-entry.ts | 2 + .../orcad/orcad-forked-child-paths.test.ts | 10 + .../orcad/orcad-local-serve-selection.test.ts | 113 ++++ src/main/orcad/orcad-local-serve-selection.ts | 21 + .../orcad-runtime-entry-artifacts.test.ts | 20 +- src/main/orcad/orcad-serve-readiness.ts | 3 +- src/main/orcad/orcad-session-search.ts | 5 +- .../orcad/serve-profile-ssh-targets.test.ts | 72 +++ src/main/orcad/serve-profile-ssh-targets.ts | 42 ++ ...tence-duplicate-repo-id-host-scope.test.ts | 23 +- ...ersistence-orcad-migration-catalog.test.ts | 2 +- src/main/persistence-repo-lifecycle.test.ts | 28 +- ...tence-worktree-lineage-and-backups.test.ts | 2 +- ...rsistence-worktree-name-retirement.test.ts | 21 +- .../leasing-ssh-ptys/ssh-normalization.ts | 1 + .../repo-lifecycle-operations.ts | 27 - .../providers/local-pty-spawn-environment.ts | 2 + .../ssh-plain-shell-pty-provider.test.ts | 5 + .../providers/ssh-plain-shell-pty-provider.ts | 12 +- src/main/pty/chromium-session-bus-env.test.ts | 39 ++ src/main/pty/chromium-session-bus-env.ts | 9 + src/main/runtime/device-registry.ts | 51 +- .../orca-runtime-on-pty-data-redraw.test.ts | 57 ++ src/main/runtime/orca-runtime-on-pty-data.ts | 8 +- .../orca-runtime-pty-leaf.test-fixture.ts | 40 ++ .../repository-project-operations.spec.ts | 4 +- ...ned-tail-redraw-window.equivalence.test.ts | 74 ++- .../rpc/core-typed-method-contract.test.ts | 3 + src/main/runtime/rpc/core.ts | 10 + ...spatcher-browser-client-automation.test.ts | 1 + .../rpc/dispatcher-computer-errors.test.ts | 6 + .../dispatcher-feature-interactions.test.ts | 16 + .../rpc/dispatcher-request-parsing.test.ts | 3 + .../runtime/rpc/dispatcher-stream-options.ts | 3 + src/main/runtime/rpc/dispatcher.ts | 37 +- src/main/runtime/rpc/methods/accounts.ts | 18 + src/main/runtime/rpc/methods/agent-hooks.ts | 1 + src/main/runtime/rpc/methods/agent-launch.ts | 2 + src/main/runtime/rpc/methods/agent-session.ts | 2 + src/main/runtime/rpc/methods/ai-vault.ts | 6 + .../rpc/methods/antigravity-accounts.ts | 4 + src/main/runtime/rpc/methods/artifacts.ts | 7 + src/main/runtime/rpc/methods/automations.ts | 7 + .../methods/browser-client-file-channel.ts | 3 + .../rpc/methods/browser-client-host.ts | 3 + src/main/runtime/rpc/methods/browser-core.ts | 93 ++-- .../runtime/rpc/methods/browser-extras.ts | 28 + .../rpc/methods/browser-identity-rpc.ts | 2 + .../rpc/methods/browser-network-tunnel.ts | 1 + .../methods/browser-profile-rpc-methods.ts | 66 +++ .../runtime/rpc/methods/browser-screencast.ts | 2 + .../rpc/methods/browser-text-rpc-methods.ts | 3 + src/main/runtime/rpc/methods/client-events.ts | 2 + src/main/runtime/rpc/methods/client-ui.ts | 9 + src/main/runtime/rpc/methods/clipboard.ts | 5 + src/main/runtime/rpc/methods/computer.ts | 15 + src/main/runtime/rpc/methods/diagnostics.ts | 1 + src/main/runtime/rpc/methods/emulator.ts | 19 + .../rpc/methods/files-mutation-methods.ts | 10 + .../files-terminal-artifact-methods.ts | 3 + src/main/runtime/rpc/methods/files.ts | 18 + .../runtime/rpc/methods/folder-workspace.ts | 5 + .../git-commit-message-generation-methods.ts | 5 + .../runtime/rpc/methods/git-diff-methods.ts | 3 + src/main/runtime/rpc/methods/git.ts | 27 + .../methods/github-account-binding-methods.ts | 2 + .../rpc/methods/github-issue-methods.ts | 4 + .../rpc/methods/github-project-methods.ts | 16 + .../methods/github-pull-request-methods.ts | 9 + .../github-pull-request-update-methods.ts | 10 + .../methods/github-repo-work-item-methods.ts | 11 + src/main/runtime/rpc/methods/gitlab.ts | 21 + .../runtime/rpc/methods/host-capabilities.ts | 5 + src/main/runtime/rpc/methods/hosted-review.ts | 4 + src/main/runtime/rpc/methods/jira.ts | 24 + .../rpc/methods/linear-agent-access.ts | 16 + .../rpc/methods/linear-issue-list-method.ts | 2 + .../rpc/methods/linear-project-create.ts | 1 + src/main/runtime/rpc/methods/linear.ts | 22 + .../runtime/rpc/methods/managed-server.ts | 6 + .../methods/mobile-markdown-tab-methods.ts | 2 + .../runtime/rpc/methods/mobile-web-bundle.ts | 3 + src/main/runtime/rpc/methods/native-chat.ts | 3 + src/main/runtime/rpc/methods/notifications.ts | 6 + .../runtime/rpc/methods/orcad-migration.ts | 5 + .../rpc/methods/orcad-terminal-census.ts | 1 + .../rpc/methods/orchestration/caller-show.ts | 3 + .../federation/federation-control.ts | 6 + .../federation/federation-relay.ts | 3 + .../orchestration/federation/federation.ts | 1 + .../rpc/methods/orchestration/gates/gates.ts | 5 + .../orchestration/messaging/ask-methods.ts | 1 + .../orchestration/messaging/check-methods.ts | 1 + .../messaging/message-methods.ts | 5 + .../orchestration/messaging/send-methods.ts | 1 + .../messaging/terminal-owned-mailboxes.ts | 45 ++ .../orchestration/runs/dispatch-methods.ts | 2 + .../runs/mutation-request-show.ts | 1 + .../orchestration/runs/reset-methods.ts | 1 + .../rpc/methods/orchestration/runs/runs.ts | 5 + .../orchestration/worker/worker-control.ts | 3 + .../worker/worker-list-method.ts | 1 + .../orchestration/worker/worker-release.ts | 3 + .../orchestration/worker/worker-stop.ts | 1 + .../methods/orchestration/worker/workers.ts | 1 + src/main/runtime/rpc/methods/pairing.ts | 2 + src/main/runtime/rpc/methods/plugins.ts | 6 + src/main/runtime/rpc/methods/preflight.ts | 5 + .../methods/project-runtime-rpc-methods.ts | 8 + src/main/runtime/rpc/methods/repo.ts | 26 + .../methods/runtime-client-capabilities.ts | 1 + .../rpc/methods/session-tab-close-methods.ts | 2 + .../methods/session-tab-markdown-methods.ts | 2 + .../methods/session-tab-mutation-methods.ts | 4 + src/main/runtime/rpc/methods/session-tabs.ts | 7 + src/main/runtime/rpc/methods/skills.ts | 15 + src/main/runtime/rpc/methods/speech.ts | 8 + src/main/runtime/rpc/methods/ssh.ts | 5 + src/main/runtime/rpc/methods/stats.ts | 1 + src/main/runtime/rpc/methods/status.ts | 1 + .../structured-agent-session-agents.ts | 1 + .../structured-agent-session-attachments.ts | 5 + ...ured-agent-session-conversation-outline.ts | 1 + .../methods/structured-agent-session-hold.ts | 2 + .../structured-agent-session-options-read.ts | 4 + ...tructured-agent-session-prompt-response.ts | 41 ++ ...structured-agent-session-queued-methods.ts | 3 + ...structured-agent-session-restart-resume.ts | 5 + .../structured-agent-session-reveal.ts | 1 + .../structured-agent-session-status-stream.ts | 1 + .../structured-agent-session-thread-goal.ts | 1 + ...ed-agent-session-turn-completion-stream.ts | 2 + .../structured-agent-session-visual.ts | 1 + .../rpc/methods/structured-agent-session.ts | 40 +- .../runtime/rpc/methods/terminal-orphan.ts | 1 + .../terminal-legacy-simple-subscriptions.ts | 2 +- .../terminal-legacy-subscribe-snapshot.ts | 3 - .../terminal/terminal-lifecycle-methods.ts | 13 + .../terminal-multiplex-flow-control.ts | 2 + .../terminal-multiplex-initial-snapshot.ts | 5 +- .../terminal/terminal-multiplex-method.ts | 1 + .../terminal-multiplex-slot-frames.ts | 1 + .../terminal/terminal-query-methods.ts | 13 + .../methods/terminal/terminal-send-method.ts | 1 + ...terminal-snapshot-json-byte-budget.test.ts | 44 +- .../terminal/terminal-snapshot-payload.ts | 6 +- .../terminal/terminal-snapshot-publication.ts | 16 +- .../terminal-snapshot-scrollback-rows.test.ts | 50 ++ .../methods/terminal/terminal-stream-types.ts | 2 + .../terminal/terminal-subscribe-method.ts | 1 + .../terminal/terminal-viewer-colors-method.ts | 1 + .../terminal/terminal-viewport-methods.ts | 7 + src/main/runtime/rpc/methods/updater.ts | 4 + .../runtime/rpc/methods/workspace-ports.ts | 2 + .../rpc/methods/worktree-catalog-methods.ts | 4 + src/main/runtime/rpc/methods/worktree.ts | 13 + .../rpc/orchestration-contract-fence.test.ts | 1 + .../rpc/orchestration-mutation-ledger.test.ts | 4 + ...rchestration-mutation-request-show.test.ts | 2 + src/main/runtime/rpc/rpc-caller-scope.test.ts | 300 +++++++++++ src/main/runtime/rpc/rpc-caller-scope.ts | 89 ++++ src/main/runtime/rpc/rpc-method-permission.ts | 37 ++ .../runtime/rpc/rpc-streaming-dispatcher.ts | 29 +- .../runtime/rpc/ssh-bridge-credentials.ts | 32 ++ .../runtime/rpc/ssh-bridge-host-binding.ts | 155 ++++++ .../ssh-bridge-orchestration-binding.test.ts | 371 +++++++++++++ .../rpc/ssh-bridge-orchestration-binding.ts | 166 ++++++ src/main/runtime/rpc/streaming.test.ts | 8 + ...l-multiplex-desktop-resize-routing.test.ts | 3 +- .../rpc/ws-empty-payload-frame-write.test.ts | 143 +++++ ...time-pairing-desktop-control-grant.test.ts | 123 +++++ .../runtime-project-host-setup-controller.ts | 6 +- .../runtime-remove-project-host-scope.test.ts | 17 +- .../runtime-repository-settings-controller.ts | 15 +- ...runtime-rpc-browser-host-admission.test.ts | 3 + .../runtime-rpc-ssh-bridge-credential.test.ts | 62 +++ .../runtime-rpc/runtime-rpc-pairing.ts | 8 +- .../runtime-rpc-request-admission.ts | 23 +- .../runtime-rpc-websocket-dispatch.ts | 20 +- src/main/runtime/runtime-store-contract.ts | 1 - src/main/runtime/terminal-tail-buffer.ts | 25 +- ...-tail-per-character-redraw.test-fixture.ts | 253 +++++++++ .../runtime/terminal-tail-redraw-buffer.ts | 127 +++-- .../runtime/terminal-tail-redraw-reach.ts | 72 +++ ...rminal-tail-redraw-row.equivalence.test.ts | 147 ++++++ src/main/runtime/terminal-tail-redraw-row.ts | 179 +++++++ .../terminal-tail-redraw-scaling.test.ts | 135 +++++ .../terminal-tail-redraw-wall-time.test.ts | 206 ++++++++ .../terminal-tail-row-retention.test.ts | 44 +- src/main/serve-update-handoff.ts | 3 +- .../ssh/orcad-migration-delta-move.test.ts | 4 +- .../ssh/orcad-remote-node-runtime-report.ts | 9 +- .../ssh/orcad-remote-node-runtime-windows.ts | 18 +- src/main/ssh/orcad-remote-node-runtime.ts | 18 +- src/main/ssh/relay-socket-path-limit.test.ts | 5 +- src/main/ssh/relay-socket-path-limit.ts | 8 +- .../ssh-bridge-caller-scope.test-fixture.ts | 26 + src/main/ssh/ssh-host-node-runtime-mode.ts | 17 + src/main/ssh/ssh-hostile-host-cells.test.ts | 21 +- src/main/ssh/ssh-hostile-host-cells.ts | 39 +- src/main/ssh/ssh-hostile-host-test-fixture.ts | 4 +- ...h-relay-answered-failure-producers.test.ts | 208 ++++++++ .../ssh-relay-cross-version-isolation.test.ts | 5 +- ...ssh-relay-deploy-incumbent-verdict.test.ts | 5 +- .../ssh/ssh-relay-deploy-pinned-node.test.ts | 416 +++++++++++++-- .../ssh-relay-deploy-staged-upload.test.ts | 5 +- .../ssh/ssh-relay-deploy-windows-pipe.test.ts | 5 +- src/main/ssh/ssh-relay-deploy.test.ts | 5 +- src/main/ssh/ssh-relay-deploy.ts | 72 ++- src/main/ssh/ssh-relay-gc-retry.test.ts | 5 +- .../ssh-relay-host-answered-failure.test.ts | 47 ++ .../ssh/ssh-relay-host-answered-failure.ts | 43 ++ ...ssh-relay-native-deps-cache-deploy.test.ts | 3 + ...-native-deps-install-staged-upload.test.ts | 5 +- .../ssh/ssh-relay-native-deps-install.test.ts | 3 + ...sh-relay-native-deps-probe-verdict.test.ts | 5 +- .../ssh-relay-node-pty-spawn-repair.test.ts | 3 + .../ssh/ssh-relay-pinned-node-install.test.ts | 44 +- src/main/ssh/ssh-relay-pinned-node-install.ts | 47 +- src/main/ssh/ssh-relay-pinned-node.test.ts | 75 ++- src/main/ssh/ssh-relay-pinned-node.ts | 53 +- .../ssh/ssh-relay-pinned-refusal-cache.ts | 78 +++ ...h-relay-pty-master-cloexec-install.test.ts | 5 +- src/main/ssh/ssh-relay-runtime-ladder.test.ts | 118 ++++- src/main/ssh/ssh-relay-runtime-ladder.ts | 106 ++-- .../ssh/ssh-relay-runtime-never-worse.test.ts | 169 ++++++ .../ssh/ssh-relay-runtime-resolution.test.ts | 20 +- src/main/ssh/ssh-relay-runtime-resolution.ts | 74 ++- .../ssh/ssh-relay-runtime-self-test.test.ts | 14 + src/main/ssh/ssh-relay-runtime-self-test.ts | 4 + src/main/ssh/ssh-relay-runtime-step-plan.ts | 20 + .../ssh/ssh-relay-session-plain-ssh.test.ts | 4 +- .../ssh/ssh-relay-session-test-fixtures.ts | 1 + src/main/ssh/ssh-relay-session.ts | 16 +- .../ssh-relay-sftp-namespace-install.test.ts | 6 + .../ssh-remote-cli-bridge-credential.test.ts | 170 ++++++ ...e-cli-dispatch-refusal-passthrough.test.ts | 2 + .../ssh-remote-cli-host-passthrough.test.ts | 34 +- .../ssh/ssh-remote-cli-host-passthrough.ts | 34 +- ...ssh-remote-cli-terminal-host-scope.test.ts | 8 +- src/main/ssh/ssh-remote-linear-cli.test.ts | 2 +- .../ssh/ssh-remote-linear-save-issue.test.ts | 4 + .../ssh-remote-node-addon-resolution.test.ts | 4 +- .../ssh/ssh-remote-node-addon-resolution.ts | 11 +- .../ssh/ssh-remote-node-resolution.test.ts | 74 ++- src/main/ssh/ssh-remote-node-resolution.ts | 84 +-- src/main/ssh/ssh-remote-orca-cli.test.ts | 65 ++- src/main/ssh/ssh-remote-orca-cli.ts | 6 +- ...remote-orchestration-compatibility.test.ts | 12 + .../ssh-remote-orchestration-post-output.ts | 7 +- .../ssh-system-transport.integration.test.ts | 4 +- src/main/ssh/system-ssh-file-transfer.ts | 53 +- .../ssh/system-ssh-operation-lifecycle.ts | 28 +- src/main/ssh/system-ssh-sftp-transfer.ts | 11 +- src/main/startup/configure-process.ts | 15 +- .../headless-serve-ssh-registration.test.ts | 78 +++ .../headless-serve-ssh-registration.ts | 14 + src/main/startup/main-process-i18n-menu.ts | 2 + ...-process-runtime-launch-activation.test.ts | 53 +- .../startup/main-process-runtime-launch.ts | 8 +- src/main/startup/main-process-serve.ts | 3 +- .../main-window-actions-user-quit.test.ts | 70 +++ src/main/startup/main-window-actions.ts | 32 +- src/main/startup/process-quit-request.ts | 25 + src/main/startup/serve-mode-argv.ts | 1 + .../startup/serve-native-quit-guard.test.ts | 94 ++++ src/main/startup/serve-native-quit-guard.ts | 29 + src/main/startup/serve-options.test.ts | 2 + src/main/startup/serve-options.ts | 5 + src/main/startup/serve-quit-handling.ts | 20 + .../startup/serve-signal-handlers.test.ts | 39 +- src/main/startup/serve-signal-handlers.ts | 27 +- .../serve-user-data-path-guard.test.ts | 62 +++ .../startup/serve-user-data-path-guard.ts | 27 + .../createMainWindow-user-quit-close.test.ts | 154 ++++++ .../window/main-window-close-lifecycle.ts | 7 +- src/main/window/user-quit-window-close.ts | 20 + .../worktree-removal-close-records.test.ts | 2 +- src/preload/api/repos-bridge.ts | 2 - src/preload/api/repository-api.ts | 1 - .../native-chat/NativeChatComposer.tsx | 8 +- .../NativeChatInterruptedContinue.test.tsx | 4 +- .../settings/SshHostAdvancedFields.tsx | 30 ++ .../settings/SshTargetCard.test.tsx | 42 +- .../src/components/settings/SshTargetCard.tsx | 17 + .../components/settings/ssh-target-draft.ts | 13 +- .../settings/ssh-target-save-payload.test.ts | 31 +- .../settings/ssh-target-save-payload.ts | 4 +- .../components/sidebar/RemoveFolderDialog.tsx | 13 +- .../sidebar/delete-worktree-flow.ts | 5 +- ...connection-spawn-left-pane-unbound.test.ts | 19 + .../deferred-cold-restore-and-snapshot.ts | 3 +- .../pty-connection/pane-transport-options.ts | 112 ++++ .../pty-connection/pty-input-recovery.ts | 111 +--- .../pty-connection/replay-data-drain.ts | 9 +- .../unbound-pane-spawn-recovery.test.ts | 20 + .../unbound-pane-spawn-recovery.ts | 10 +- .../terminal-pane/pty-output-processor.ts | 4 +- .../terminal-pane/pty-transport-types.ts | 4 + ...e-runtime-pty-snapshot-source-grid.test.ts | 4 +- ...-pty-transport-host-session-launch.test.ts | 5 +- ...transport-host-surface-replacement.test.ts | 9 +- ...ntime-pty-transport-input-fallback.test.ts | 4 +- ...ime-pty-transport-pairing-rotation.test.ts | 346 ++++++++++++ ...y-transport-pane-binding-ownership.test.ts | 247 +++++++++ ...y-transport-pane-handle-resolution.test.ts | 25 + ...time-pty-transport-snapshot-replay.test.ts | 18 +- ...e-runtime-pty-transport-stream-fixtures.ts | 18 + ...ime-pty-transport-stream-reconnect.test.ts | 39 +- .../remote-runtime-pty-transport.ts | 499 ++++++++++++------ ...remote-runtime-recovery-input-hold.test.ts | 82 +++ .../remote-runtime-recovery-input-hold.ts | 104 ++++ ...ote-snapshot-alt-screen-host-chain.test.ts | 10 +- ...e-snapshot-scrollback-preservation.test.ts | 76 +++ .../terminal-snapshot-replay-paint.ts | 9 +- .../src/i18n/en-runtime-required.json | 3 + src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...emote-runtime-terminal-binary-snapshots.ts | 52 +- ...runtime-terminal-frame-drop-resync.test.ts | 16 +- ...emote-runtime-terminal-multiplexer-base.ts | 9 +- ...mote-runtime-terminal-multiplexer-types.ts | 4 + .../remote-runtime-terminal-multiplexer.ts | 25 +- ...te-runtime-terminal-response-controller.ts | 5 +- .../remote-runtime-terminal-snapshot-state.ts | 17 + .../runtime-environment-pairing-refresh.ts | 4 + .../runtime/runtime-environment-revision.ts | 83 ++- .../runtime/runtime-terminal-stream.test.ts | 22 +- .../project-groups/project-group-mutations.ts | 49 +- src/renderer/src/store/repos/repo-removal.ts | 28 +- src/renderer/src/store/repos/repo-state.ts | 4 +- .../slices/project-group-removal-targets.ts | 15 +- .../slices/project-removal-owner-host.test.ts | 238 +++++++++ ...epos-cross-host-project-collisions.test.ts | 7 +- ...pos-ephemeral-vm-cleanup-retention.test.ts | 10 +- .../repos-host-identity-routing.test.ts | 14 +- .../repos-project-groups-delete.test.ts | 20 +- ...repos-project-groups-owner-routing.test.ts | 5 +- .../repos-remove-project-purge-leak.test.ts | 7 +- .../slices/repos-runtime-routing-fixture.ts | 6 +- .../store/slices/repos-stale-fetch.test.ts | 7 +- src/renderer/src/store/slices/repos.test.ts | 27 +- ...ntime-environment-peer-replacement.test.ts | 45 +- .../runtime-environment-peer-replacement.ts | 58 +- .../src/store/slices/runtime-status.ts | 18 +- ...se-presets-repo-removal-purge-leak.test.ts | 5 +- .../slices/ssh-connection-state-equality.ts | 1 + .../slices/store-session-cascades.test.ts | 4 +- .../slices/store-session-test-harness.ts | 10 +- .../workspace-chat-draft-removal.test.ts | 6 +- .../orphaned-runtime-ssh-project-purge.ts | 18 +- .../web/preload-api/web-repositories-api.ts | 4 - src/shared/agent-tui-ansi-fuzz-stream.ts | 2 +- src/shared/cli-argument-boundary.ts | 1 + src/shared/orcad-artifacts.ts | 4 + src/shared/serve-option-validation.ts | 6 + src/shared/serve-user-data-path.ts | 27 + src/shared/ssh-bridge-credential-env.ts | 12 + .../ssh-retained-payload-admission.test.ts | 9 + src/shared/ssh-retained-payload-admission.ts | 2 + src/shared/ssh-types.ts | 15 +- .../telemetry-ssh-runtime-event-schemas.ts | 3 +- src/shared/terminal-mode-reset-profiles.ts | 9 +- ...al-partial-escape-tail-ground-scan.test.ts | 19 +- src/shared/unix-socket-path-limit.ts | 4 + ...rminal-initial-snapshot-scrollback.spec.ts | 118 +++++ .../e2e/windows-project-runtime-smoke.spec.ts | 3 +- 447 files changed, 11738 insertions(+), 1706 deletions(-) create mode 100644 config/patches/ws@8.22.0.patch create mode 100644 config/scripts/session-scanner-service-smoke.mjs create mode 100644 config/scripts/session-scanner-service-smoke.test.mjs create mode 100644 src/cli/runtime/launch-user-data-path.test.ts create mode 100644 src/cli/runtime/launch-user-data-path.ts create mode 100644 src/cli/runtime/transport-ssh-bridge-credential.test.ts create mode 100644 src/cli/ssh-bridge-command-policy.test.ts create mode 100644 src/cli/ssh-bridge-command-policy.ts create mode 100644 src/main/daemon/daemon-client-frame-guards.ts create mode 100644 src/main/daemon/daemon-client-frame-validation.test.ts create mode 100644 src/main/daemon/daemon-socket-endpoint-path.test.ts create mode 100644 src/main/daemon/daemon-socket-endpoint-path.ts create mode 100644 src/main/daemon/daemon-user-manager-lifetime.test.ts create mode 100644 src/main/daemon/daemon-user-manager-lifetime.ts create mode 100644 src/main/forked-child-entry-path.ts create mode 100644 src/main/ipc/ssh-host-node-runtime-connect.test.ts create mode 100644 src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts create mode 100644 src/main/menu/app-menu-quit-item.ts create mode 100644 src/main/orcad/serve-profile-ssh-targets.test.ts create mode 100644 src/main/orcad/serve-profile-ssh-targets.ts create mode 100644 src/main/pty/chromium-session-bus-env.test.ts create mode 100644 src/main/pty/chromium-session-bus-env.ts create mode 100644 src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts create mode 100644 src/main/runtime/orca-runtime-pty-leaf.test-fixture.ts create mode 100644 src/main/runtime/rpc/methods/browser-profile-rpc-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/terminal-owned-mailboxes.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-prompt-response.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts create mode 100644 src/main/runtime/rpc/rpc-caller-scope.test.ts create mode 100644 src/main/runtime/rpc/rpc-caller-scope.ts create mode 100644 src/main/runtime/rpc/rpc-method-permission.ts create mode 100644 src/main/runtime/rpc/ssh-bridge-credentials.ts create mode 100644 src/main/runtime/rpc/ssh-bridge-host-binding.ts create mode 100644 src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts create mode 100644 src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts create mode 100644 src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts create mode 100644 src/main/runtime/runtime-pairing-desktop-control-grant.test.ts create mode 100644 src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts create mode 100644 src/main/runtime/terminal-tail-per-character-redraw.test-fixture.ts create mode 100644 src/main/runtime/terminal-tail-redraw-reach.ts create mode 100644 src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts create mode 100644 src/main/runtime/terminal-tail-redraw-row.ts create mode 100644 src/main/runtime/terminal-tail-redraw-scaling.test.ts create mode 100644 src/main/runtime/terminal-tail-redraw-wall-time.test.ts create mode 100644 src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts create mode 100644 src/main/ssh/ssh-host-node-runtime-mode.ts create mode 100644 src/main/ssh/ssh-relay-answered-failure-producers.test.ts create mode 100644 src/main/ssh/ssh-relay-host-answered-failure.test.ts create mode 100644 src/main/ssh/ssh-relay-host-answered-failure.ts create mode 100644 src/main/ssh/ssh-relay-pinned-refusal-cache.ts create mode 100644 src/main/ssh/ssh-relay-runtime-never-worse.test.ts create mode 100644 src/main/ssh/ssh-remote-cli-bridge-credential.test.ts create mode 100644 src/main/startup/headless-serve-ssh-registration.test.ts create mode 100644 src/main/startup/headless-serve-ssh-registration.ts create mode 100644 src/main/startup/main-window-actions-user-quit.test.ts create mode 100644 src/main/startup/process-quit-request.ts create mode 100644 src/main/startup/serve-native-quit-guard.test.ts create mode 100644 src/main/startup/serve-native-quit-guard.ts create mode 100644 src/main/startup/serve-quit-handling.ts create mode 100644 src/main/startup/serve-user-data-path-guard.test.ts create mode 100644 src/main/startup/serve-user-data-path-guard.ts create mode 100644 src/main/window/createMainWindow-user-quit-close.test.ts create mode 100644 src/main/window/user-quit-window-close.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-snapshot-scrollback-preservation.test.ts create mode 100644 src/renderer/src/store/slices/project-removal-owner-host.test.ts create mode 100644 src/shared/serve-user-data-path.ts create mode 100644 src/shared/ssh-bridge-credential-env.ts create mode 100644 tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index cc0ab39bc1d..b98a86dec22 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -41,6 +41,7 @@ export const CLI_MAIN_ENTRY_NAMES = [ const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', + 'session-scanner-service-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', 'orcad/orcad-local-serve-selection-entry', diff --git a/config/patches/ws@8.22.0.patch b/config/patches/ws@8.22.0.patch new file mode 100644 index 00000000000..2f211591048 --- /dev/null +++ b/config/patches/ws@8.22.0.patch @@ -0,0 +1,19 @@ +diff --git a/lib/sender.js b/lib/sender.js +index 2c767e2c62a7fb50a987454f0c148ec5b4a0a523..48549a5c0fde4347a3fde83a32e56c2bbc81d0ce 100644 +--- a/lib/sender.js ++++ b/lib/sender.js +@@ -561,7 +561,13 @@ class Sender { + * @private + */ + sendFrame(list, cb) { +- if (list.length === 2) { ++ // ++ // Never write an empty payload on its own: `socket.write()` of a ++ // zero-length buffer fails with EFAULT under Electron on arm64 kernels with ++ // 39-bit virtual addresses, which kills the connection (stablyai/orca#23398). ++ // The header already encodes the zero length, so the wire bytes are unchanged. ++ // ++ if (list.length === 2 && list[1].length !== 0) { + this._socket.cork(); + this._socket.write(list[0]); + this._socket.write(list[1], cb); diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 96a46677626..5d6d556bb5e 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -7,7 +7,8 @@ import { buildOrcadLauncher, externalNativeAddons, ORCAD_EXTERNAL_MODULES, - ORCAD_CHILD_ENTRY_POINTS + ORCAD_CHILD_ENTRY_POINTS, + orcadChildOutputFilename } from './orcad-entry-build.mjs' import { createRequire } from 'node:module' import { @@ -25,12 +26,11 @@ import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' +import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, - ORCAD_FOREIGN_SQLITE_READER_ENTRY, - ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY, ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME, ORCAD_NODE_PTY_DIR, @@ -52,24 +52,13 @@ const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = process.env.ORCAD_OUT_DIR ? resolve(process.env.ORCAD_OUT_DIR) : join(ROOT, 'out', 'orcad') -// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher -// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath` -// looks for it in the app root. A deployment has no desktop out/main to fall back to. -const WATCHER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.watcher) -const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') -// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process, -// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every -// orcad restart would SIGKILL every running terminal. -const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon) -const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') -// Why beside orcad.js: the hook server's OpenCode binder and the OpenCode history scanner -// start this worker from the module dir, since orcad has no Electron resources tree. -const FOREIGN_SQLITE_READER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader) -const FOREIGN_SQLITE_READER_OUT_FILE = join(OUT_DIR, ORCAD_FOREIGN_SQLITE_READER_ENTRY) -// Why beside orcad.js: workspace port detection runs its probe commands on this worker thread, -// and `resolveWorkerEntryPath` looks for it next to the running bundle. -const PORT_SCAN_WORKER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.portScanCommandWorker) -const PORT_SCAN_WORKER_OUT_FILE = join(OUT_DIR, ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY) +// Why beside orcad.js: every forked child and worker resolves its entry in the app root, and a +// deployment has no desktop out/main to fall back to. A missing one only shows on a host as a +// feature that never starts (the daemon's absence makes every restart kill every terminal). +const childOutFile = (role) => + join(OUT_DIR, orcadChildOutputFilename(ORCAD_CHILD_ENTRY_POINTS[role])) +const WATCHER_OUT_FILE = childOutFile('watcher') +const DAEMON_OUT_FILE = childOutFile('daemon') const OUT_FILE = join(OUT_DIR, ORCAD_LAUNCHER_FILENAME) const SERVER_OUT_FILE = join(OUT_DIR, ORCAD_SERVER_ENTRY_FILENAME) const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET @@ -220,18 +209,11 @@ function buildForkedChild(entryPoint, outfile) { }) } -const childResults = await Promise.all([ - buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), - buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE), - buildForkedChild(FOREIGN_SQLITE_READER_ENTRY, FOREIGN_SQLITE_READER_OUT_FILE), - buildForkedChild(PORT_SCAN_WORKER_ENTRY, PORT_SCAN_WORKER_OUT_FILE), - ...['writer', 'backup'].map((role) => - buildForkedChild( - join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]), - join(OUT_DIR, `profile-state-${role}-worker-entry.js`) - ) +const childResults = await Promise.all( + Object.values(ORCAD_CHILD_ENTRY_POINTS).map((entryPoint) => + buildForkedChild(join(ROOT, entryPoint), join(OUT_DIR, orcadChildOutputFilename(entryPoint))) ) -]) +) const result = await buildOrcadEntry(SERVER_OUT_FILE) const launcherResult = await buildOrcadLauncher(OUT_FILE) @@ -355,24 +337,23 @@ if (graphErrors.length > 0) { } } -try { - await smokeProfileStateWorkers(OUT_DIR) - if (nodeRuntimePath) { - await smokeProfileStateWorkers(OUT_DIR, { runtimePath: nodeRuntimePath }) +/** @type {{ label: string, smoke: (outDir: string, options?: { runtimePath?: string }) => unknown }[]} */ +const workerSmokes = [ + { label: 'profile state worker', smoke: smokeProfileStateWorkers }, + { label: 'foreign SQLite reader worker', smoke: smokeForeignSqliteReaderWorker }, + { label: 'session scanner service', smoke: smokeSessionScannerService } +] +// Each shipped child is checked under the build's Node and, when set, the pinned runtime. +for (const { label, smoke } of workerSmokes) { + try { + await smoke(OUT_DIR) + if (nodeRuntimePath) { + await smoke(OUT_DIR, { runtimePath: nodeRuntimePath }) + } + } catch (error) { + console.error(`[build-orcad] ${label} check failed:`, error) + process.exitCode = 1 } -} catch (error) { - console.error('[build-orcad] profile state worker check failed:', error) - process.exitCode = 1 -} - -try { - smokeForeignSqliteReaderWorker(OUT_DIR) - if (nodeRuntimePath) { - smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath }) - } -} catch (error) { - console.error('[build-orcad] foreign SQLite reader worker check failed:', error) - process.exitCode = 1 } // Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs index 36121ad88d4..43f42aca655 100644 --- a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs @@ -57,9 +57,9 @@ describe('foreign SQLite reader build smoke', () => { it('runs in the orcad build under both runtimes', () => { const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') - expect(source).toContain('smokeForeignSqliteReaderWorker(OUT_DIR)') expect(source).toContain( - 'smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath })' + "{ label: 'foreign SQLite reader worker', smoke: smokeForeignSqliteReaderWorker }" ) + expect(source).toContain('await smoke(OUT_DIR, { runtimePath: nodeRuntimePath })') }) }) diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index a746ac4bb5b..1267ef9664a 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -1,7 +1,7 @@ import { build } from 'esbuild' import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts' const root = join(import.meta.dirname, '..', '..') @@ -14,7 +14,13 @@ export const ORCAD_CHILD_ENTRY_POINTS = { writer: 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts', backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts', foreignSqliteReader: 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts', - portScanCommandWorker: 'src/main/ports/port-scan-command-worker-entry.ts' + portScanCommandWorker: 'src/main/ports/port-scan-command-worker-entry.ts', + sessionScanner: 'src/main/ai-vault/session-scanner-service-entry.ts' +} + +/** Each child ships flat beside orcad.js under its source basename; the runtime resolvers look there. */ +export function orcadChildOutputFilename(entryPoint) { + return basename(entryPoint).replace(/\.ts$/, '.js') } export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] diff --git a/config/scripts/session-scanner-service-smoke.mjs b/config/scripts/session-scanner-service-smoke.mjs new file mode 100644 index 00000000000..d226e2f09b7 --- /dev/null +++ b/config/scripts/session-scanner-service-smoke.mjs @@ -0,0 +1,87 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { ORCAD_SESSION_SCANNER_SERVICE_ENTRY } from '../../src/shared/orcad-artifacts.ts' +import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from '../../src/main/ai-vault/session-scanner-service-protocol.ts' + +// Why fork from a probe: the child must run under the runtime orcad ships, and it forks with +// that runtime's execPath. The verdict is the exit code, never matched output. +const PROBE = ` +const { fork } = require('node:child_process') +const [entry, home, protocol] = process.argv.slice(2) +const fail = (code, message) => { + console.error(message) + process.exit(code) +} +const child = fork(entry, [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home }, + windowsHide: true +}) +let stderr = '' +child.stderr.on('data', (chunk) => { stderr += String(chunk) }) +setTimeout(() => { child.kill('SIGKILL'); fail(3, 'session scanner did not answer\\n' + stderr) }, 30000).unref() +child.on('error', (error) => fail(4, String(error && error.stack || error))) +child.on('exit', (code, signal) => fail(5, 'session scanner exited ' + code + ' ' + signal + '\\n' + stderr)) +child.on('message', (message) => { + if (message && message.type === 'ready') { + child.send({ type: 'request', id: 1, operation: 'scan', options: { limit: 5 } }) + return + } + if (!message || message.id !== 1) { + return + } + const sessions = message.type === 'result' ? message.value.result.sessions : [] + if (!sessions.some((session) => session.agent === 'claude' && session.sessionId === 'smoke-session')) { + fail(6, 'scan answered ' + JSON.stringify(message).slice(0, 2000)) + } + child.removeAllListeners('exit') + child.kill() + process.exit(0) +}) +child.send({ type: 'init', protocol: Number(protocol) }) +` + +/** + * Fork the built session scanner child and list a seeded Claude session through it. + * @param outDir - orcad output directory holding the entry. + * @param options.runtimePath - Node to run under; the build's own Node when omitted. + */ +export function smokeSessionScannerService(outDir, { runtimePath, timeoutMs = 60_000 } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-session-scanner-smoke-')) + try { + const home = join(directory, 'home') + const project = join(home, '.claude', 'projects', 'smoke-project') + mkdirSync(project, { recursive: true }) + writeFileSync( + join(project, 'smoke-session.jsonl'), + `${JSON.stringify({ + type: 'user', + sessionId: 'smoke-session', + timestamp: '2026-01-01T00:00:00.000Z', + cwd: '/smoke', + message: { role: 'user', content: 'smoke' } + })}\n` + ) + const probe = join(directory, 'probe.cjs') + writeFileSync(probe, PROBE) + const result = runProcessSync({ + program: runtimePath ?? process.execPath, + args: [ + probe, + resolve(outDir, ORCAD_SESSION_SCANNER_SERVICE_ENTRY), + home, + String(AI_VAULT_SERVICE_PROTOCOL_VERSION) + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs, + maxOutputBytes: 64 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + throw new Error(`Session scanner service smoke failed: ${describeProcessFailure(result)}`) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/session-scanner-service-smoke.test.mjs b/config/scripts/session-scanner-service-smoke.test.mjs new file mode 100644 index 00000000000..b2e05158247 --- /dev/null +++ b/config/scripts/session-scanner-service-smoke.test.mjs @@ -0,0 +1,74 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs' +import { + externalNativeAddons, + ORCAD_CHILD_ENTRY_POINTS, + ORCAD_EXTERNAL_MODULES, + orcadChildOutputFilename +} from './orcad-entry-build.mjs' + +const ENTRY = orcadChildOutputFilename(ORCAD_CHILD_ENTRY_POINTS.sessionScanner) +const directories = [] +let builtDirectory + +function fixtureDirectory() { + const directory = mkdtempSync(join(tmpdir(), 'orca-session-scanner-smoke-test-')) + directories.push(directory) + return directory +} + +beforeAll(async () => { + builtDirectory = fixtureDirectory() + await build({ + entryPoints: [resolve(ORCAD_CHILD_ENTRY_POINTS.sessionScanner)], + outfile: join(builtDirectory, ENTRY), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + external: ORCAD_EXTERNAL_MODULES, + plugins: [externalNativeAddons], + logLevel: 'silent' + }) +}, 60_000) + +afterAll(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('session scanner service build smoke', () => { + it('lists a seeded session through the built entry', () => { + expect(() => smokeSessionScannerService(builtDirectory)).not.toThrow() + }, 60_000) + + it('fails when the entry is missing', () => { + expect(() => smokeSessionScannerService(fixtureDirectory())).toThrow('smoke failed') + }) + + it('fails when the child answers without scanning', () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, ENTRY), + `process.on('message', (m) => { + if (m.type === 'init') process.send({ type: 'ready', protocol: m.protocol, pid: process.pid }) + if (m.type === 'request') process.send({ type: 'result', id: m.id, operation: 'scan', + value: { result: { sessions: [], issues: [] }, durationMs: 0 } }) + })` + ) + expect(() => smokeSessionScannerService(directory)).toThrow('smoke failed') + }) + + it('runs in the orcad build under both runtimes', () => { + const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') + expect(source).toContain( + "{ label: 'session scanner service', smoke: smokeSessionScannerService }" + ) + expect(source).toContain('await smoke(OUT_DIR, { runtimePath: nodeRuntimePath })') + }) +}) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 09029f3c6d2..1233564b961 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -35,6 +35,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/**/*expiry*.test.ts', 'src/renderer/src/lib/flatten-retained-slice.test.ts', 'src/shared/own-retained-string.test.ts', + // Times V8 string flattening, which is what Electron main runs. + 'src/main/runtime/terminal-tail-redraw-wall-time.test.ts', 'src/main/agent-hooks/server-transport-interference.test.ts', 'src/main/plugins/plugin-worker-supervision.integration.test.ts', 'src/main/usage/usage-scan-worker-event-loop.test.ts', @@ -71,6 +73,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/structured-session-mail-redrive-wiring.test.ts', 'src/main/runtime/rpc/ws-transport.test.ts', + // Bun resolves 'ws' to its built-in client, which bypasses the patched package. + 'src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts', 'src/shared/remote-runtime-client.test.ts', 'src/shared/remote-runtime-connect-bound.test.ts', 'src/shared/remote-runtime-subscription-connect-bound.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 1655071b95a..64fd1a0181a 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -153,6 +153,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts', 'src/main/opencode/opencode-acp-stored-messages.test.ts', 'src/main/orcad/orcad-automations.test.ts', + 'src/main/orcad/orcad-local-serve-selection.test.ts', + 'src/main/orcad/serve-profile-ssh-targets.test.ts', 'src/main/pi/rpc-launch-resolution.test.ts', 'src/main/runtime/agent-session-conversation-clear-commit.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8501286145..67c57bfc30b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,7 @@ patchedDependencies: i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 node-pty@1.1.0: 92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192 + ws@8.22.0: 9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14 importers: @@ -255,7 +256,7 @@ importers: version: 1.0.3 ws: specifier: ^8.22.0 - version: 8.22.0 + version: 8.22.0(patch_hash=9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14) yaml: specifier: ^2.9.1 version: 2.9.1 @@ -14757,7 +14758,7 @@ snapshots: dependencies: inspect-webkit: 0.0.5(typescript@7.0.2) sonner: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - ws: 8.22.0 + ws: 8.22.0(patch_hash=9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14) transitivePeerDependencies: - '@types/react' - bufferutil @@ -15406,7 +15407,7 @@ snapshots: ws@8.21.3: {} - ws@8.22.0: {} + ws@8.22.0(patch_hash=9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14): {} wsl-utils@0.3.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e3c133b29ee..2d673455655 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,3 +63,4 @@ patchedDependencies: lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch '@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch i18next-cli@1.74.2: config/patches/i18next-cli@1.74.2.patch + ws@8.22.0: config/patches/ws@8.22.0.patch diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 49450d7c19b..ac9097b2d13 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -102,10 +102,12 @@ export const CORE_HANDLERS: Record = { const projectRoot = typeof projectRootValue === 'string' ? projectRootValue : null const noPairing = flags.get('no-pairing') === true const mobilePairing = flags.get('mobile-pairing') === true + const grantDesktopControl = flags.get('grant-desktop-control') === true const recipeJson = flags.get('recipe-json') === true const validationError = getServeOptionValidationError({ noPairing, mobilePairing, + grantDesktopControl, recipeJson, projectRoot }) @@ -120,6 +122,7 @@ export const CORE_HANDLERS: Record = { pairingAddress: typeof pairingAddressValue === 'string' ? pairingAddressValue : null, noPairing, mobilePairing, + grantDesktopControl, recipeJson, projectRoot }) diff --git a/src/cli/index-serve-command.test.ts b/src/cli/index-serve-command.test.ts index 5b204a4220d..3db82cc2684 100644 --- a/src/cli/index-serve-command.test.ts +++ b/src/cli/index-serve-command.test.ts @@ -68,11 +68,22 @@ describe('orca cli worktree awareness', () => { pairingAddress: '100.64.1.20', noPairing: true, mobilePairing: false, + grantDesktopControl: false, recipeJson: false, projectRoot: null }) }) + it('forwards the pairing-time desktop-control grant', async () => { + serveOrcaAppMock.mockResolvedValue(0) + + await main(['serve', '--grant-desktop-control'], '/tmp/repo') + + expect(serveOrcaAppMock).toHaveBeenCalledWith( + expect.objectContaining({ grantDesktopControl: true, mobilePairing: false }) + ) + }) + it('starts a foreground headless server with mobile pairing enabled', async () => { serveOrcaAppMock.mockResolvedValue(0) @@ -87,6 +98,7 @@ describe('orca cli worktree awareness', () => { pairingAddress: '100.64.1.20', noPairing: false, mobilePairing: true, + grantDesktopControl: false, recipeJson: false, projectRoot: null }) @@ -113,6 +125,7 @@ describe('orca cli worktree awareness', () => { pairingAddress: 'wss://sandbox.example.com', noPairing: false, mobilePairing: false, + grantDesktopControl: false, recipeJson: true, projectRoot: '/workspace/repo' }) diff --git a/src/cli/index.ts b/src/cli/index.ts index be527727876..b51041edec3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,10 @@ import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' import { refuseConflictingSessionCallerFlags } from './session-caller-flags' +import { + refuseOwnerOnlySshBridgeCommand, + refuseSshBridgeEnvironmentRouting +} from './ssh-bridge-command-policy' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -112,10 +116,10 @@ export async function main( // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) - refuseConflictingSessionCallerFlags( - findCommandSpec(COMMAND_SPECS, parsed.commandPath), - parsed.flags - ) + const commandSpec = findCommandSpec(COMMAND_SPECS, parsed.commandPath) + // Why: the canonical path, so an alias cannot slip an owner-only command past the bridge. + refuseOwnerOnlySshBridgeCommand(commandSpec?.path ?? parsed.commandPath, parsed.flags) + refuseConflictingSessionCallerFlags(commandSpec, parsed.flags) const RuntimeClientClass = await loadRuntimeClientClass() const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') @@ -152,6 +156,7 @@ export async function main( // Why: --host runtime: is canonicalized to the environment's id so downstream host-id // comparisons against stored rows still match; rewrite the flag once, here, rather than // resolving the name again at every consumer. + refuseSshBridgeEnvironmentRouting(hostEnvironmentId) if (hostEnvironmentId !== null) { parsed.flags.set('host', `runtime:${hostEnvironmentId}`) } @@ -196,6 +201,7 @@ export async function main( async function runClaudeTeams(argv: string[], cwd: string): Promise { try { + refuseOwnerOnlySshBridgeCommand(['claude-teams'], new Map()) // Why: everything after `orca claude-teams` belongs to Claude Code, not // Orca's own flag parser, so new Claude flags work without Orca changes. const client = new (await loadRuntimeClientClass())(undefined, undefined, null, null) diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 68cb770ec2d..4b61499ffc1 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -36,7 +36,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [ '', 'Common Commands:', ' orca open [--json]', - ' orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json]', + ' orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--grant-desktop-control] [--project-root ] [--recipe-json] [--json]', ' orca status [--json]', ' orca diagnostics memory [--json]', ' orca agent-context [--json]', diff --git a/src/cli/runtime/launch-serve-runtime.test.ts b/src/cli/runtime/launch-serve-runtime.test.ts index 9d1fc96802a..b1d34435bdb 100644 --- a/src/cli/runtime/launch-serve-runtime.test.ts +++ b/src/cli/runtime/launch-serve-runtime.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events' +import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { spawnMock, resolveLocalServeRuntimeMock, serveWithOrcadMock } = vi.hoisted(() => ({ @@ -50,6 +51,9 @@ describe('orca serve host selection', () => { vi.restoreAllMocks() delete process.env.ORCA_APP_EXECUTABLE delete process.env.ORCA_SERVE_RUNTIME + delete process.env.ORCA_USER_DATA_PATH + delete process.env.ORCA_DEV_USER_DATA_PATH + delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT }) it('serves on orcad by default', async () => { @@ -90,4 +94,72 @@ describe('orca serve host selection', () => { expect(spawnMock).toHaveBeenCalledOnce() expect(stderr.join('')).not.toContain('[serve]') }) + + describe('serves the profile it selected on', () => { + function electronSpawn(): { args: string[]; env: NodeJS.ProcessEnv } { + const [, args, options] = spawnMock.mock.calls[0] + return { args, env: options.env } + } + + it('starts the Electron fallback on ORCA_USER_DATA_PATH, not the default profile', async () => { + const profile = resolve('/isolated/profile-r') + process.env.ORCA_USER_DATA_PATH = profile + resolveLocalServeRuntimeMock.mockResolvedValue({ kind: 'electron', reason: 'ssh targets' }) + electronChild() + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(resolveLocalServeRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ userDataPath: profile }) + ) + const child = electronSpawn() + expect(child.args).toContain(`--user-data-dir=${profile}`) + expect(child.env).toMatchObject({ + ORCA_USER_DATA_PATH: profile, + ORCA_SERVE_USER_DATA_PATH: profile + }) + }) + + it('pins the opt-out Electron serve to the same profile', async () => { + const profile = resolve('/isolated/profile-e') + process.env.ORCA_USER_DATA_PATH = profile + process.env.ORCA_SERVE_RUNTIME = 'electron' + electronChild() + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(electronSpawn().args).toContain(`--user-data-dir=${profile}`) + }) + + it('hands orcad the absolute profile the selector read', async () => { + process.env.ORCA_USER_DATA_PATH = join('relative', 'profile') + const selection = { kind: 'orcad', runtime: '/node', entry: '/slot/orcad.js', version: '1' } + resolveLocalServeRuntimeMock.mockResolvedValue(selection) + serveWithOrcadMock.mockResolvedValue(0) + + await expect(serveOrcaApp({})).resolves.toBe(0) + const profile = resolve('relative', 'profile') + expect(resolveLocalServeRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ userDataPath: profile }) + ) + expect(serveWithOrcadMock).toHaveBeenCalledWith(selection, {}, profile, expect.any(Object)) + }) + + it('uses ORCA_DEV_USER_DATA_PATH for both the selector and a dev Electron child', async () => { + const profile = resolve('/isolated/dev-profile') + delete process.env.ORCA_USER_DATA_PATH + process.env.ORCA_DEV_USER_DATA_PATH = profile + process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1' + resolveLocalServeRuntimeMock.mockResolvedValue({ kind: 'electron', reason: 'dev' }) + electronChild() + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(resolveLocalServeRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ userDataPath: profile }) + ) + // A dev build ignores --user-data-dir and reads this instead. + expect(electronSpawn().env).toMatchObject({ + ORCA_DEV_USER_DATA_PATH: profile, + ORCA_SERVE_USER_DATA_PATH: profile + }) + }) + }) }) diff --git a/src/cli/runtime/launch-user-data-path.test.ts b/src/cli/runtime/launch-user-data-path.test.ts new file mode 100644 index 00000000000..eb301ddacd3 --- /dev/null +++ b/src/cli/runtime/launch-user-data-path.test.ts @@ -0,0 +1,58 @@ +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { getPlatformUserDataPath } from './metadata' +import { getMacOpenArgs } from './launch' +import { pinLaunchUserDataPath, resolveLaunchUserDataPath } from './launch-user-data-path' + +describe('resolveLaunchUserDataPath', () => { + it('prefers ORCA_USER_DATA_PATH, made absolute', () => { + expect( + resolveLaunchUserDataPath({ + ORCA_USER_DATA_PATH: 'rel/profile', + ORCA_DEV_USER_DATA_PATH: '/dev', + ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' + }) + ).toBe(resolve('rel/profile')) + }) + + it("falls back to the dev app's own profile for a dev executable", () => { + expect(resolveLaunchUserDataPath({ ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' })).toBe( + resolve(join(getPlatformUserDataPath(), '..', 'orca-dev')) + ) + }) + + it('ignores ORCA_DEV_USER_DATA_PATH for a packaged executable', () => { + expect(resolveLaunchUserDataPath({ ORCA_DEV_USER_DATA_PATH: '/dev' })).toBe( + getPlatformUserDataPath() + ) + }) +}) + +describe('pinLaunchUserDataPath', () => { + it('sets the dev override only for a dev executable', () => { + expect(pinLaunchUserDataPath(['--no-sandbox'], {}, '/p')).toEqual({ + args: ['--no-sandbox', '--user-data-dir=/p'], + env: { ORCA_USER_DATA_PATH: '/p' } + }) + expect( + pinLaunchUserDataPath([], { ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' }, '/p').env + ).toMatchObject({ ORCA_DEV_USER_DATA_PATH: '/p' }) + }) +}) + +describe('getMacOpenArgs', () => { + it('reopens the bundle as before on the default profile', () => { + expect(getMacOpenArgs('/Applications/Orca.app', getPlatformUserDataPath())).toEqual([ + '/Applications/Orca.app' + ]) + }) + + it('starts a new instance on another profile instead of activating the running app', () => { + expect(getMacOpenArgs('/Applications/Orca.app', '/isolated/p')).toEqual([ + '-n', + '/Applications/Orca.app', + '--args', + '--user-data-dir=/isolated/p' + ]) + }) +}) diff --git a/src/cli/runtime/launch-user-data-path.ts b/src/cli/runtime/launch-user-data-path.ts new file mode 100644 index 00000000000..658108a0860 --- /dev/null +++ b/src/cli/runtime/launch-user-data-path.ts @@ -0,0 +1,48 @@ +import { dirname, join, resolve } from 'node:path' +import { SERVE_USER_DATA_PATH_ENV } from '../../shared/serve-user-data-path' +import { getPlatformUserDataPath } from './metadata' + +/** An unpackaged checkout build, which takes its profile from ORCA_DEV_USER_DATA_PATH, not --user-data-dir. */ +function launchesDevApp(env: NodeJS.ProcessEnv): boolean { + return env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' +} + +/** + * The one profile a CLI launch both reads and hands to the app it starts. + * Absolute, because the child runs from the app root, not the caller's cwd. + */ +export function resolveLaunchUserDataPath(env: NodeJS.ProcessEnv = process.env): string { + if (env.ORCA_USER_DATA_PATH) { + return resolve(env.ORCA_USER_DATA_PATH) + } + if (launchesDevApp(env)) { + // Mirrors the dev app's own default (`/orca-dev`). + return resolve( + env.ORCA_DEV_USER_DATA_PATH || join(dirname(getPlatformUserDataPath()), 'orca-dev') + ) + } + return getPlatformUserDataPath() +} + +/** Pins a launched app to `userDataPath` in every form a packaged or dev build reads. */ +export function pinLaunchUserDataPath( + args: string[], + env: NodeJS.ProcessEnv, + userDataPath: string +): { args: string[]; env: NodeJS.ProcessEnv } { + const pinnedEnv: NodeJS.ProcessEnv = { ...env, ORCA_USER_DATA_PATH: userDataPath } + if (launchesDevApp(env)) { + pinnedEnv.ORCA_DEV_USER_DATA_PATH = userDataPath + } + return { args: [...args, `--user-data-dir=${userDataPath}`], env: pinnedEnv } +} + +/** Serve additionally refuses to start, before the instance lock, if it lands anywhere else. */ +export function pinServeUserDataPath( + args: string[], + env: NodeJS.ProcessEnv, + userDataPath: string +): { args: string[]; env: NodeJS.ProcessEnv } { + const pinned = pinLaunchUserDataPath(args, env, userDataPath) + return { args: pinned.args, env: { ...pinned.env, [SERVE_USER_DATA_PATH_ENV]: userDataPath } } +} diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 584ab18e503..0f74cea652e 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -70,6 +70,8 @@ const SSH_RECIPE_JSON = JSON.stringify({ }) const INVALID_SSH_RECIPE_JSON = SSH_RECIPE_JSON.replace('/workspace/repo', 'relative/repo') const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' +const PROFILE = resolve('/profiles/serve') +const PIN_PROFILE = `--user-data-dir=${PROFILE}` function startRecipeJsonServer() { const child = new FakeChildProcess() @@ -92,6 +94,7 @@ describe('serveOrcaApp', () => { process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca' // These cover Electron serve itself; the orcad default is in launch-serve-runtime.test.ts. process.env.ORCA_SERVE_RUNTIME = 'electron' + process.env.ORCA_USER_DATA_PATH = PROFILE }) afterEach(() => { @@ -349,7 +352,7 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/Applications/Orca.app/Contents/MacOS/Orca', - ['--serve', '--serve-json'], + [PIN_PROFILE, '--serve', '--serve-json'], expect.objectContaining({ cwd: resolve(__dirname, '../../..') }) @@ -382,6 +385,7 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/Applications/Orca.app/Contents/MacOS/Orca', [ + PIN_PROFILE, '--serve', '--serve-json', '--serve-port', @@ -416,7 +420,14 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/repo/node_modules/.bin/electron', - [resolve(__dirname, '../../..'), '--serve', '--serve-json', '--serve-port', '6768'], + [ + resolve(__dirname, '../../..'), + PIN_PROFILE, + '--serve', + '--serve-json', + '--serve-port', + '6768' + ], expect.objectContaining({ cwd: resolve(__dirname, '../../..') }) @@ -466,7 +477,7 @@ describe('serveOrcaApp', () => { ) expect(spawnMock).toHaveBeenCalledWith( executable, - [...expectedPrefix, '--serve', '--serve-json'], + [...expectedPrefix, PIN_PROFILE, '--serve', '--serve-json'], // Foreground serve must share POSIX job-control signals with its CLI supervisor. expect.objectContaining({ detached: false }) ) @@ -502,6 +513,7 @@ describe('serveOrcaApp', () => { expect(spawnMock).toHaveBeenCalledWith( '/Applications/Orca.app/Contents/MacOS/Orca', [ + PIN_PROFILE, '--serve', '--serve-pairing-address', 'wss://sandbox.example.com', @@ -622,7 +634,7 @@ describe('serveOrcaApp', () => { await expect(serveOrcaApp({ json: true })).resolves.toBe(0) expect(spawnMock).toHaveBeenCalledWith( 'C:\\repo\\node_modules\\.bin\\electron.cmd', - ['--serve', '--serve-json'], + [PIN_PROFILE, '--serve', '--serve-json'], expect.objectContaining({ shell: true }) @@ -639,9 +651,11 @@ describe('launchOrcaApp', () => { beforeEach(() => { spawnMock.mockReset() spawnSyncMock.mockReset() + process.env.ORCA_USER_DATA_PATH = PROFILE }) afterEach(() => { + delete process.env.ORCA_USER_DATA_PATH delete process.env.ORCA_OPEN_COMMAND delete process.env.ORCA_APP_EXECUTABLE delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT @@ -684,7 +698,7 @@ describe('launchOrcaApp', () => { ) expect(spawnMock).toHaveBeenCalledWith( executable, - ['--no-sandbox'], + ['--no-sandbox', PIN_PROFILE], expect.objectContaining({ detached: true, stdio: 'ignore', diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index afce6264154..37e53053ae5 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -6,8 +6,14 @@ import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath } from '../../shared/serve-update-handoff' -import { getDefaultUserDataPath } from './metadata' +import { + pinLaunchUserDataPath, + pinServeUserDataPath, + resolveLaunchUserDataPath +} from './launch-user-data-path' import { getMacAppBundlePath } from './mac-app-update-bundle' +import { getPlatformUserDataPath } from './metadata' +import { isSameUserDataPath } from '../../shared/serve-user-data-path' import { readServeUpdateHandoffSync, resumeInterruptedServeUpdate, @@ -31,11 +37,18 @@ export function launchOrcaApp(): void { return } + // Why: `openOrca` waits on this profile's metadata, so the app must start on it too. + const userDataPath = resolveLaunchUserDataPath() const overrideExecutable = process.env.ORCA_APP_EXECUTABLE if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { - spawnDetached(overrideExecutable, getExecutableAppArgs(overrideExecutable), { + const pinned = pinLaunchUserDataPath( + getExecutableAppArgs(overrideExecutable), + stripElectronRunAsNode(process.env), + userDataPath + ) + spawnDetached(overrideExecutable, pinned.args, { ...getExecutableSpawnOptions(overrideExecutable), - env: stripElectronRunAsNode(process.env) + env: pinned.env }) return } @@ -47,16 +60,19 @@ export function launchOrcaApp(): void { // Why: launching the inner MacOS binary directly can trigger macOS app // launch failures and bypass normal bundle lifecycle. The public // packaged CLI should re-open the .app the same way Finder does. - spawnDetached('open', [appBundlePath], { + spawnDetached('open', getMacOpenArgs(appBundlePath, userDataPath), { env: stripElectronRunAsNode(process.env) }) return } } - spawnDetached(process.execPath, getExecutableAppArgs(process.execPath), { - env: stripElectronRunAsNode(process.env) - }) + const pinned = pinLaunchUserDataPath( + getExecutableAppArgs(process.execPath), + stripElectronRunAsNode(process.env), + userDataPath + ) + spawnDetached(process.execPath, pinned.args, { env: pinned.env }) return } @@ -66,6 +82,17 @@ export function launchOrcaApp(): void { ) } +/** + * Plain `open` activates whichever instance already runs, which is the default profile's. + * Why `-n` only off the default: `open` drops our env, and `--args` reach only a new instance. + */ +export function getMacOpenArgs(appBundlePath: string, userDataPath: string): string[] { + if (isSameUserDataPath(userDataPath, getPlatformUserDataPath())) { + return [appBundlePath] + } + return ['-n', appBundlePath, '--args', `--user-data-dir=${userDataPath}`] +} + function spawnDetached(command: string, args: string[], options: SpawnOptions): void { const child = spawnProcess(command, args, { detached: true, @@ -83,40 +110,47 @@ export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise { if (args.recipeJson && !args.projectRoot) { throw new RuntimeClientError('invalid_argument', 'Recipe JSON output requires --project-root.') } + // Why one value: the selector, orcad and Electron must all serve the profile the caller chose. + const userDataPath = resolveLaunchUserDataPath() // Why synchronous on the opt-out: it must spawn Electron exactly as before, without asking. if (process.env[SERVE_RUNTIME_ENV] === SERVE_RUNTIME_ELECTRON) { - return serveWithElectron(executable, args) + return serveWithElectron(executable, args, userDataPath) } - return serveWithSelectedRuntime(executable, args) + return serveWithSelectedRuntime(executable, args, userDataPath) } async function serveWithSelectedRuntime( executable: string, - args: ServeOrcaAppArgs + args: ServeOrcaAppArgs, + userDataPath: string ): Promise { const selection = await resolveLocalServeRuntime({ executable, appRoot: resolveAppRoot(), - userDataPath: getDefaultUserDataPath(), + userDataPath, usesMacUpdateHandoff: args.recipeJson !== true && getMacAppBundlePath(executable) !== null }) if (selection.kind === 'orcad') { process.stderr.write(`[serve] running on orcad ${selection.version}\n`) - return serveWithOrcad( - selection, - args, - getDefaultUserDataPath(), - stripElectronRunAsNode(process.env) - ) + return serveWithOrcad(selection, args, userDataPath, stripElectronRunAsNode(process.env)) } if (selection.reason) { process.stderr.write(`[serve] using Electron serve: ${selection.reason}\n`) } - return serveWithElectron(executable, args) + return serveWithElectron(executable, args, userDataPath) } -function serveWithElectron(executable: string, args: ServeOrcaAppArgs): Promise { - const childArgs = [...getExecutableAppArgs(executable)] +function serveWithElectron( + executable: string, + args: ServeOrcaAppArgs, + userDataPath: string +): Promise { + const pinned = pinServeUserDataPath( + getExecutableAppArgs(executable), + stripElectronRunAsNode(process.env), + userDataPath + ) + const childArgs = pinned.args childArgs.push('--serve') if (args.json) { childArgs.push('--serve-json') @@ -133,15 +167,18 @@ function serveWithElectron(executable: string, args: ServeOrcaAppArgs): Promise< if (args.mobilePairing) { childArgs.push('--serve-mobile-pairing') } + if (args.grantDesktopControl) { + childArgs.push('--serve-grant-desktop-control') + } if (args.recipeJson && args.projectRoot) { childArgs.push('--serve-recipe-json', '--serve-project-root', args.projectRoot) } const handoffPath = args.recipeJson !== true && getMacAppBundlePath(executable) - ? getServeUpdateHandoffPath(getDefaultUserDataPath()) + ? getServeUpdateHandoffPath(userDataPath) : null - const childEnv = stripElectronRunAsNode(process.env) + const childEnv = pinned.env if (handoffPath) { childEnv[SERVE_UPDATE_HANDOFF_PATH_ENV] = handoffPath } diff --git a/src/cli/runtime/metadata.ts b/src/cli/runtime/metadata.ts index de1a8122fc2..7a0d4f04ca9 100644 --- a/src/cli/runtime/metadata.ts +++ b/src/cli/runtime/metadata.ts @@ -50,6 +50,14 @@ export function getDefaultUserDataPath( if (process.env.ORCA_USER_DATA_PATH) { return process.env.ORCA_USER_DATA_PATH } + return getPlatformUserDataPath(platform, homeDir) +} + +/** The packaged app's own default profile, ignoring any CLI override. */ +export function getPlatformUserDataPath( + platform: NodeJS.Platform = process.platform, + homeDir = homedir() +): string { if (platform === 'darwin') { return join(homeDir, 'Library', 'Application Support', 'orca') } diff --git a/src/cli/runtime/serve-orcad-launch.ts b/src/cli/runtime/serve-orcad-launch.ts index 3379518d829..7716de68242 100644 --- a/src/cli/runtime/serve-orcad-launch.ts +++ b/src/cli/runtime/serve-orcad-launch.ts @@ -21,6 +21,7 @@ export type ServeOrcaAppArgs = { pairingAddress?: string | null noPairing?: boolean mobilePairing?: boolean + grantDesktopControl?: boolean recipeJson?: boolean projectRoot?: string | null } @@ -128,6 +129,7 @@ export function orcadServeArgs(args: ServeOrcaAppArgs): string[] { ...(args.pairingAddress ? ['--pairing-address', args.pairingAddress] : []), ...(args.noPairing ? ['--no-pairing'] : []), ...(args.mobilePairing ? ['--mobile-pairing'] : []), + ...(args.grantDesktopControl ? ['--grant-desktop-control'] : []), ...(args.recipeJson && args.projectRoot ? ['--recipe-json', '--project-root', args.projectRoot] : []) diff --git a/src/cli/runtime/transport-ssh-bridge-credential.test.ts b/src/cli/runtime/transport-ssh-bridge-credential.test.ts new file mode 100644 index 00000000000..bcfcdbde90f --- /dev/null +++ b/src/cli/runtime/transport-ssh-bridge-credential.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createServer } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' +import { sendRequest } from './transport' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +async function captureAuthToken(): Promise { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-bridge-transport-')) + const endpoint = join(userDataPath, 'runtime.sock') + let authToken = '' + const server = createServer((socket) => { + socket.once('data', (data) => { + const request: { id: string; authToken: string } = JSON.parse(String(data).trim()) + authToken = request.authToken + socket.end( + `${JSON.stringify({ id: request.id, ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })}\n` + ) + }) + }) + await new Promise((resolve) => server.listen(endpoint, resolve)) + const metadata: RuntimeMetadata = { + runtimeId: 'runtime-1', + pid: 123, + transports: [{ kind: 'unix', endpoint }], + authToken: 'owner-token', + startedAt: 1 + } + try { + await sendRequest(metadata, 'status.get', undefined, 5_000) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + return authToken +} + +describe.skipIf(process.platform === 'win32')('runtime transport credentials', () => { + it('presents the SSH bridge credential instead of the owner token when bridged', async () => { + vi.stubEnv(ORCA_SSH_BRIDGE_CREDENTIAL_ENV, 'sshb_scoped') + expect(await captureAuthToken()).toBe('sshb_scoped') + }) + + it('presents the owner token outside the bridge', async () => { + vi.stubEnv(ORCA_SSH_BRIDGE_CREDENTIAL_ENV, '') + expect(await captureAuthToken()).toBe('owner-token') + }) +}) diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 2e5a047d037..f17eb7db92e 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,3 +1,4 @@ +import { readSshBridgeCredential } from '../../shared/ssh-bridge-credential-env' import { createConnection } from 'node:net' import { randomUUID } from 'node:crypto' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' @@ -196,7 +197,8 @@ export async function sendRequest( socket.write( `${JSON.stringify({ id: requestId, - authToken: metadata.authToken, + // Why: a bridged SSH invocation must present its scoped credential, never the owner token. + authToken: readSshBridgeCredential() ?? metadata.authToken, method, params, orchestrationCapability: envelope?.orchestrationCapability, diff --git a/src/cli/specs/serve.ts b/src/cli/specs/serve.ts index eefbb34a1cb..cc7369ec0e8 100644 --- a/src/cli/specs/serve.ts +++ b/src/cli/specs/serve.ts @@ -6,13 +6,14 @@ export const SERVE_COMMAND_SPECS: CommandSpec[] = [ path: ['serve'], summary: 'Start an Orca runtime server without opening a desktop window', usage: - 'orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json]', + 'orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--grant-desktop-control] [--project-root ] [--recipe-json] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'port', 'pairing-address', 'mobile-pairing', 'no-pairing', + 'grant-desktop-control', 'project-root', 'recipe-json' ], @@ -21,6 +22,7 @@ export const SERVE_COMMAND_SPECS: CommandSpec[] = [ '--pairing-address changes only the client-advertised address; use a reachable LAN, Tailscale, SSH-forward, or reverse-proxy endpoint.', 'Use --recipe-json with --project-root from VM recipes to print the recipe result JSON and leave the server running.', 'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.', + "A paired client cannot drive this machine's desktop (orca computer) unless the offer was created with --grant-desktop-control.", 'When the web client bundle is available, the server also prints a browser URL with the pairing data embedded.' ], examples: [ diff --git a/src/cli/ssh-bridge-command-policy.test.ts b/src/cli/ssh-bridge-command-policy.test.ts new file mode 100644 index 00000000000..2b54cb72d68 --- /dev/null +++ b/src/cli/ssh-bridge-command-policy.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../shared/ssh-bridge-credential-env' +import { + refuseOwnerOnlySshBridgeCommand, + refuseSshBridgeEnvironmentRouting +} from './ssh-bridge-command-policy' + +const BRIDGED = { [ORCA_SSH_BRIDGE_CREDENTIAL_ENV]: 'sshb_test' } + +describe('owner-only commands over the SSH bridge', () => { + it.each([ + [['account', 'add']], + [['environment', 'add']], + [['skills', 'install']], + [['agent', 'hooks', 'on']], + [['profile', 'state', 'rollback']], + [['serve']], + [['claude-teams']] + ])('refuses %j, which acts on the Orca host directly', (commandPath) => { + expect(() => refuseOwnerOnlySshBridgeCommand(commandPath, new Map(), BRIDGED)).toThrow( + /cannot run from an SSH host/ + ) + }) + + it.each([['environment'], ['pairing-code']])( + 'refuses --%s, which routes through the host’s paired servers', + (flag) => { + expect(() => + refuseOwnerOnlySshBridgeCommand(['terminal', 'list'], new Map([[flag, 'prod']]), BRIDGED) + ).toThrow(/paired servers/) + } + ) + + it('refuses --host runtime: routing', () => { + expect(() => refuseSshBridgeEnvironmentRouting('env-1', BRIDGED)).toThrow(/paired servers/) + }) + + it('leaves runtime-routed commands to the scoped runtime socket', () => { + expect(() => + refuseOwnerOnlySshBridgeCommand(['terminal', 'list'], new Map(), BRIDGED) + ).not.toThrow() + expect(() => + refuseOwnerOnlySshBridgeCommand(['skills', 'list'], new Map(), BRIDGED) + ).not.toThrow() + }) + + it('does nothing outside the bridge', () => { + expect(() => refuseOwnerOnlySshBridgeCommand(['account', 'add'], new Map(), {})).not.toThrow() + expect(() => refuseSshBridgeEnvironmentRouting('env-1', {})).not.toThrow() + }) +}) diff --git a/src/cli/ssh-bridge-command-policy.ts b/src/cli/ssh-bridge-command-policy.ts new file mode 100644 index 00000000000..8ac11e614f7 --- /dev/null +++ b/src/cli/ssh-bridge-command-policy.ts @@ -0,0 +1,60 @@ +import { readSshBridgeCredential } from '../shared/ssh-bridge-credential-env' +import { RuntimeClientError } from './runtime/types' + +// Why: these act on this machine directly (its files, accounts, processes) rather than through the +// scoped runtime socket, so the runtime's per-caller permissions can never see them. +const OWNER_MACHINE_COMMANDS: readonly (readonly string[])[] = [ + ['account'], + ['agent', 'hooks'], + ['claude-teams'], + ['environment'], + ['host', 'list'], + ['profile'], + ['serve'], + ['skills', 'install'], + ['skills', 'update'], + ['skills', 'share'], + ['vm'] +] + +function isSshBridgeInvocation(env: NodeJS.ProcessEnv = process.env): boolean { + return readSshBridgeCredential(env) !== null +} + +/** Throws when an SSH-bridged invocation asks for something only this machine's owner may do. */ +export function refuseOwnerOnlySshBridgeCommand( + commandPath: readonly string[], + flags: ReadonlyMap, + env: NodeJS.ProcessEnv = process.env +): void { + if (!isSshBridgeInvocation(env)) { + return + } + const command = commandPath.join(' ') + if (OWNER_MACHINE_COMMANDS.some((prefix) => prefix.every((part, i) => commandPath[i] === part))) { + throw new RuntimeClientError( + 'forbidden', + `orca ${command} acts on the Orca host machine itself and cannot run from an SSH host. Run it on the Orca host.` + ) + } + for (const flag of ['environment', 'pairing-code']) { + if (flags.has(flag)) { + throw new RuntimeClientError( + 'forbidden', + `--${flag} would route through the Orca host's own paired servers and cannot be used from an SSH host.` + ) + } + } +} + +export function refuseSshBridgeEnvironmentRouting( + hostEnvironmentId: string | null, + env: NodeJS.ProcessEnv = process.env +): void { + if (hostEnvironmentId !== null && isSshBridgeInvocation(env)) { + throw new RuntimeClientError( + 'forbidden', + "--host runtime: would route through the Orca host's own paired servers and cannot be used from an SSH host." + ) + } +} diff --git a/src/main/ai-vault-search/session-search-in-process-service.ts b/src/main/ai-vault-search/session-search-in-process-service.ts index 8cb0adbe3e4..bcda7949cce 100644 --- a/src/main/ai-vault-search/session-search-in-process-service.ts +++ b/src/main/ai-vault-search/session-search-in-process-service.ts @@ -11,8 +11,8 @@ import { sessionSearchSqliteAvailable } from './session-search-sqlite-support' * * The desktop puts the index in that child because the child is where the * transcript reader runs, so one read serves both the session list and the index. - * Neither of these hosts has that child: orcad ships only the watcher and daemon - * entries beside `orcad.js`, and the relay's AI Vault sidecar runs the remote + * Neither host gives that child the index: orcad's scanner child gets no search + * data root and only lists sessions, and the relay's AI Vault sidecar runs the remote * scanner, which reads through a filesystem provider and publishes nothing to the * transcript channel. On both, the process that would drive the index's reads is * this one, and it is the only writer, so the two-process rebuild race the diff --git a/src/main/ai-vault/session-scanner-service-entry-path.test.ts b/src/main/ai-vault/session-scanner-service-entry-path.test.ts index 7723a4f8fe7..4a1cfbbd7b6 100644 --- a/src/main/ai-vault/session-scanner-service-entry-path.test.ts +++ b/src/main/ai-vault/session-scanner-service-entry-path.test.ts @@ -37,6 +37,31 @@ describe('resolveAiVaultServiceEntryPath', () => { ) }) + it('uses the entry beside the bundle on a packaged host without an asar', () => { + const orcadRoot = join('/opt', 'orcad', '1.0.0') + const adjacent = join(orcadRoot, 'session-scanner-service-entry.js') + + expect(resolveAiVaultServiceEntryPath(orcadRoot, true, (path) => path === adjacent)).toBe( + adjacent + ) + }) + + it('never forks an adjacent decoy from inside app.asar', () => { + const appPath = join('/opt', 'Orca', 'resources', 'app.asar') + + expect(resolveAiVaultServiceEntryPath(appPath, true, () => true)).toBe( + join( + '/opt', + 'Orca', + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'session-scanner-service-entry.js' + ) + ) + }) + it('uses resourcesPath from packaged Electron-as-Node runtimes', () => { const resourcesPath = join('Applications', 'Orca.app', 'Contents', 'Resources') const entry = join( diff --git a/src/main/ai-vault/session-scanner-service-entry-path.ts b/src/main/ai-vault/session-scanner-service-entry-path.ts index a64676d66b3..ca9d0098212 100644 --- a/src/main/ai-vault/session-scanner-service-entry-path.ts +++ b/src/main/ai-vault/session-scanner-service-entry-path.ts @@ -1,27 +1,24 @@ import { existsSync } from 'node:fs' -import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { join } from 'node:path' +import { + getForkedChildEntryPath, + resolveForkedChildEntryPath, + resolveForkedChildEntryPathWithoutApp +} from '../forked-child-entry-path' -type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } - -// Why the port and not require('electron'): this module is reachable from plain-Node -// fork entries, where the literal text require("electron") fails the build guard even -// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. -function loadElectronApp(): ElectronAppPath | null { - return hasAppEnvironment() ? getAppEnvironment() : null -} +// orcad resolves this literal beside orcad.js; build-orcad.mjs emits it from ORCAD_CHILD_ENTRY_POINTS. +export const AI_VAULT_SERVICE_ENTRY_FILENAME = 'session-scanner-service-entry.js' export function resolveAiVaultServiceEntryPath( appPath: string, isPackaged: boolean, pathExists: (candidate: string) => boolean = existsSync ): string { - const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath - const adjacentEntry = join(basePath, 'session-scanner-service-entry.js') - if (!isPackaged && pathExists(adjacentEntry)) { - return adjacentEntry - } - return join(basePath, 'out', 'main', 'session-scanner-service-entry.js') + return resolveForkedChildEntryPath( + AI_VAULT_SERVICE_ENTRY_FILENAME, + appPath, + isPackaged, + pathExists + ) } export function resolveAiVaultServiceEntryPathWithoutApp( @@ -29,24 +26,14 @@ export function resolveAiVaultServiceEntryPathWithoutApp( resourcesPath: string | undefined, pathExists: (candidate: string) => boolean = existsSync ): string { - if (resourcesPath) { - const packagedEntry = join( - resourcesPath, - 'app.asar.unpacked', - 'out', - 'main', - 'session-scanner-service-entry.js' - ) - if (pathExists(packagedEntry)) { - return packagedEntry - } - } - return resolveAiVaultServiceEntryPath(cwd, false, pathExists) + return resolveForkedChildEntryPathWithoutApp( + AI_VAULT_SERVICE_ENTRY_FILENAME, + cwd, + resourcesPath, + pathExists + ) } export function getAiVaultServiceEntryPath(): string { - const app = loadElectronApp() - return app - ? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged()) - : resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath) + return getForkedChildEntryPath(AI_VAULT_SERVICE_ENTRY_FILENAME) } diff --git a/src/main/codex/codex-daemon-socket-path-guard.ts b/src/main/codex/codex-daemon-socket-path-guard.ts index 98b7b87e0cf..10c74fee0a8 100644 --- a/src/main/codex/codex-daemon-socket-path-guard.ts +++ b/src/main/codex/codex-daemon-socket-path-guard.ts @@ -1,6 +1,6 @@ import { realpathSync } from 'node:fs' import { parseWslUncPath } from '../../shared/wsl-paths' -import { unixSocketPathByteLimit } from '../../shared/unix-socket-path-limit' +import { unixSocketPathFits } from '../../shared/unix-socket-path-limit' import { upsertTableSettingsInContent } from './codex-config-settings-upsert' import { createTomlLineScanState, @@ -46,8 +46,7 @@ export function codexDaemonSocketPathExceedsLimit( ): boolean { // Why: WSL homes run Linux Codex; Windows Codex's uds_windows also uses a 108-byte sun_path. const os = platform === 'darwin' && !parseWslUncPath(homePath) ? 'darwin' : 'linux' - const socketPath = codexDaemonSocketPath(homePath, platform) - return Buffer.byteLength(socketPath, 'utf8') > unixSocketPathByteLimit(os) + return !unixSocketPathFits(codexDaemonSocketPath(homePath, platform), os) } const unguardableHomesWarned = new Set() diff --git a/src/main/daemon/daemon-cgroup-scope.test.ts b/src/main/daemon/daemon-cgroup-scope.test.ts index 0261f85bb9b..bd75bb0b775 100644 --- a/src/main/daemon/daemon-cgroup-scope.test.ts +++ b/src/main/daemon/daemon-cgroup-scope.test.ts @@ -71,33 +71,44 @@ afterEach(() => { describe('isDurableDaemonScopeSupported', () => { it('is false on non-Linux platforms regardless of environment', () => { - expect(isDurableDaemonScopeSupported({ XDG_RUNTIME_DIR: '/run/user/1000' }, 'darwin')).toBe( - false - ) - expect(isDurableDaemonScopeSupported({ XDG_RUNTIME_DIR: '/run/user/1000' }, 'win32')).toBe( - false - ) + expect( + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: '/run/user/1000' }, + platform: 'darwin' + }) + ).toBe(false) + expect( + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: '/run/user/1000' }, + platform: 'win32' + }) + ).toBe(false) }) it('is false when not booted under systemd, even with a reachable bus and a working binary', () => { const perUidDir = fakeRuntimeDirWithBus() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: perUidDir }, - 'linux', - perUidDir, - '/definitely/not/systemd-boot', - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: '/definitely/not/systemd-boot', + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) }) it('is false when there is no runtime dir to resolve at all', () => { expect( - isDurableDaemonScopeSupported({}, 'linux', null, fakeSystemdBootPath(), () => ({ - code: 0, - timedOut: false - })) + isDurableDaemonScopeSupported({ + env: {}, + platform: 'linux', + canonicalRuntimeDir: null, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) }) @@ -107,13 +118,14 @@ describe('isDurableDaemonScopeSupported', () => { // Neither fixture has a `bus` socket written — the probe must fail closed regardless of // which path it looks at first. expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: envDir }, - 'linux', - canonical, - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: envDir }, + platform: 'linux', + canonicalRuntimeDir: canonical, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) }) @@ -121,22 +133,24 @@ describe('isDurableDaemonScopeSupported', () => { const perUidDir = fakeRuntimeDirWithBus() const bootPath = fakeSystemdBootPath() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: perUidDir }, - 'linux', - perUidDir, - bootPath, - () => ({ code: 1, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: bootPath, + runVersionProbe: () => ({ code: 1, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: perUidDir }, - 'linux', - perUidDir, - bootPath, - () => ({ code: null, timedOut: true }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: bootPath, + runVersionProbe: () => ({ code: null, timedOut: true }), + outlivesCaller: () => true + }) ).toBe(false) }) @@ -148,26 +162,28 @@ describe('isDurableDaemonScopeSupported', () => { const hardenedOverrideDir = fakeRuntimeDirWithoutBus() const realPerUidDir = fakeRuntimeDirWithBus() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: hardenedOverrideDir }, - 'linux', - realPerUidDir, - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: hardenedOverrideDir }, + platform: 'linux', + canonicalRuntimeDir: realPerUidDir, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(true) }) it('is true when the caller env XDG_RUNTIME_DIR already points at the correct, reachable per-UID bus', () => { const perUidDir = fakeRuntimeDirWithBus() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: perUidDir }, - 'linux', - perUidDir, - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(true) }) @@ -177,13 +193,14 @@ describe('isDurableDaemonScopeSupported', () => { const canonicalWithoutBus = fakeRuntimeDirWithoutBus() const envDirWithBus = fakeRuntimeDirWithBus() expect( - isDurableDaemonScopeSupported( - { XDG_RUNTIME_DIR: envDirWithBus }, - 'linux', - canonicalWithoutBus, - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }) - ) + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: envDirWithBus }, + platform: 'linux', + canonicalRuntimeDir: canonicalWithoutBus, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(true) }) }) @@ -373,31 +390,89 @@ describe('legacy daemon scope migration', () => { it('migrates only a proven legacy scope and fails closed when systemd rejects it', () => { const runtimeDir = fakeRuntimeDirWithBus() const runMigration = vi.fn(() => ({ code: 0, timedOut: false })) - const migrated = migrateLegacyDaemonScope( - 321, - 'new-nonce', - { XDG_RUNTIME_DIR: runtimeDir }, - 'linux', - runtimeDir, - () => ({ unit: 'app-orca-1420296.scope', pids: [321, 400] }), - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }), - runMigration - ) + const migrated = migrateLegacyDaemonScope(321, 'new-nonce', { + env: { XDG_RUNTIME_DIR: runtimeDir }, + platform: 'linux', + canonicalRuntimeDir: runtimeDir, + readProcesses: () => ({ unit: 'app-orca-1420296.scope', pids: [321, 400] }), + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + runMigration: runMigration, + outlivesCaller: () => true + }) expect(migrated).toBe(true) expect(runMigration).toHaveBeenCalledOnce() expect( - migrateLegacyDaemonScope( - 321, - 'new-nonce', - { XDG_RUNTIME_DIR: runtimeDir }, - 'linux', - runtimeDir, - () => ({ unit: 'app-orca-1420296.scope', pids: [321, 400] }), - fakeSystemdBootPath(), - () => ({ code: 0, timedOut: false }), - () => ({ code: 1, timedOut: false }) - ) + migrateLegacyDaemonScope(321, 'new-nonce', { + env: { XDG_RUNTIME_DIR: runtimeDir }, + platform: 'linux', + canonicalRuntimeDir: runtimeDir, + readProcesses: () => ({ unit: 'app-orca-1420296.scope', pids: [321, 400] }), + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + runMigration: () => ({ code: 1, timedOut: false }), + outlivesCaller: () => true + }) ).toBe(false) }) }) + +describe('durable scope launch environment and lifetime (#25580, #24201)', () => { + it("drops Chromium's disabled: bus marker so systemd-run reaches the user manager", () => { + const perUidDir = fakeRuntimeDirWithBus() + const result = buildDurableDaemonScopeCommand( + '/usr/bin/node', + [], + 'n', + { PATH: '/bin', DBUS_SESSION_BUS_ADDRESS: 'disabled:' }, + perUidDir + ) + expect(result.env).not.toHaveProperty('DBUS_SESSION_BUS_ADDRESS') + expect(result.env.XDG_RUNTIME_DIR).toBe(perUidDir) + }) + + it('keeps a real session bus address', () => { + const result = buildDurableDaemonScopeCommand( + '/usr/bin/node', + [], + 'n', + { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' }, + null + ) + expect(result.env.DBUS_SESSION_BUS_ADDRESS).toBe('unix:path=/run/user/1000/bus') + }) + + it('refuses the scope when the user manager would not outlive the caller', () => { + const perUidDir = fakeRuntimeDirWithBus() + const versionProbe = vi.fn(() => ({ code: 0, timedOut: false })) + expect( + isDurableDaemonScopeSupported({ + env: { XDG_RUNTIME_DIR: perUidDir }, + platform: 'linux', + canonicalRuntimeDir: perUidDir, + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: versionProbe, + outlivesCaller: () => false + }) + ).toBe(false) + expect(versionProbe).not.toHaveBeenCalled() + }) + + it('never migrates an adopted daemon into a manager that dies at logout', () => { + const runtimeDir = fakeRuntimeDirWithBus() + const runMigration = vi.fn(() => ({ code: 0, timedOut: false })) + expect( + migrateLegacyDaemonScope(321, 'new-nonce', { + env: { XDG_RUNTIME_DIR: runtimeDir }, + platform: 'linux', + canonicalRuntimeDir: runtimeDir, + readProcesses: () => ({ unit: 'app-orca-1.scope', pids: [321] }), + systemdBootPath: fakeSystemdBootPath(), + runVersionProbe: () => ({ code: 0, timedOut: false }), + runMigration: runMigration, + outlivesCaller: () => false + }) + ).toBe(false) + expect(runMigration).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/daemon-cgroup-scope.ts b/src/main/daemon/daemon-cgroup-scope.ts index 2f93a711eb8..1ea52e2a677 100644 --- a/src/main/daemon/daemon-cgroup-scope.ts +++ b/src/main/daemon/daemon-cgroup-scope.ts @@ -12,13 +12,16 @@ * manager and execs the command into it, so the daemon's cgroup becomes a *sibling* of the * unit's that no unit-scoped kill reaches; `--collect` drops the unit once it exits. * - * That needs systemd as PID 1 and a reachable `--user` manager (a login session, or - * `loginctl enable-linger ` for a service account). Everywhere else keeps the direct-fork + * That needs systemd as PID 1, a reachable `--user` manager, and one that outlives the caller: + * `loginctl enable-linger `, unless the caller already runs inside that manager (see + * daemon-user-manager-lifetime.ts). Everywhere else keeps the direct-fork * launch, so this module fails closed to "not supported" rather than guessing. */ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' +import { cgroupPathFromProcLine, userManagerOutlivesCaller } from './daemon-user-manager-lifetime' const SYSTEMD_RUN_BINARY = 'systemd-run' const UNIT_NAME_PREFIX = 'orca-daemon-' @@ -105,22 +108,35 @@ function runSystemdRunVersionProbe( binary: string, timeoutMs: number ): Pick { - const { code, timedOut } = runProcessSync({ - program: binary, - args: ['--version'], - stdio: 'ignore', - timeoutMs - }) - return { code, timedOut } + return runProcessSync({ program: binary, args: ['--version'], stdio: 'ignore', timeoutMs }) } -export function isDurableDaemonScopeSupported( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, - canonicalRuntimeDir: string | null = CANONICAL_USER_RUNTIME_DIR, - systemdBootPath: string = SYSTEMD_BOOT_PATH, - runVersionProbe: SystemdRunVersionProbe = runSystemdRunVersionProbe -): boolean { +/** Test seams shared by the scope probe and the legacy migration; each defaults to the real host. */ +type ScopeProbeDeps = { + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + canonicalRuntimeDir?: string | null + systemdBootPath?: string + runVersionProbe?: SystemdRunVersionProbe + outlivesCaller?: () => boolean +} + +// Why not `??`: an explicit null means "no canonical dir", not "use the real one". +function resolveCanonicalRuntimeDir(deps: ScopeProbeDeps): string | null { + return deps.canonicalRuntimeDir !== undefined + ? deps.canonicalRuntimeDir + : CANONICAL_USER_RUNTIME_DIR +} + +export function isDurableDaemonScopeSupported(deps: ScopeProbeDeps = {}): boolean { + const { + env = process.env, + platform = process.platform, + systemdBootPath = SYSTEMD_BOOT_PATH, + runVersionProbe = runSystemdRunVersionProbe, + outlivesCaller = userManagerOutlivesCaller + } = deps + const canonicalRuntimeDir = resolveCanonicalRuntimeDir(deps) if (platform !== 'linux') { return false } @@ -134,6 +150,10 @@ export function isDurableDaemonScopeSupported( // systemd-run --user would just fail to connect. return false } + if (!outlivesCaller()) { + // A bus only proves a login session is open now; without linger the scope dies at logout. + return false + } try { const probe = runVersionProbe(SYSTEMD_RUN_BINARY, SYSTEMD_RUN_PROBE_TIMEOUT_MS) // A non-zero exit is data here rather than a throw, and a timeout kill leaves an exit behind @@ -159,11 +179,8 @@ function cgroupPathFromProc(contents: string): string | null { const paths: { path: string; priority: number }[] = [] for (const line of contents.split('\n')) { const fields = line.split(':') - if (fields.length < 3) { - continue - } - const path = fields.slice(2).join(':').trim() - if (path) { + const path = cgroupPathFromProcLine(line) + if (fields.length >= 3 && path) { const controllers = fields[1]?.split(',') ?? [] const priority = fields[0] === '0' ? 0 : controllers.includes('name=systemd') ? 1 : 2 paths.push({ path, priority }) @@ -247,31 +264,25 @@ export function buildLegacyScopeMigrationCommand( export function migrateLegacyDaemonScope( pid: number, launchNonce: string, - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, - canonicalRuntimeDir: string | null = CANONICAL_USER_RUNTIME_DIR, - readProcesses: typeof readLegacyDaemonScopeProcesses = readLegacyDaemonScopeProcesses, - systemdBootPath: string = SYSTEMD_BOOT_PATH, - runVersionProbe: SystemdRunVersionProbe = runSystemdRunVersionProbe, - runMigration: SystemdScopeMigrationRunner = (command, timeoutMs) => - runProcessSync({ - program: command.command, - args: command.args, - env: command.env, - timeoutMs, - stdio: 'ignore' - }) + deps: ScopeProbeDeps & { + readProcesses?: typeof readLegacyDaemonScopeProcesses + runMigration?: SystemdScopeMigrationRunner + } = {} ): boolean { - if ( - platform !== 'linux' || - !isDurableDaemonScopeSupported( - env, - platform, - canonicalRuntimeDir, - systemdBootPath, - runVersionProbe - ) - ) { + const { + env = process.env, + platform = process.platform, + readProcesses = readLegacyDaemonScopeProcesses, + runMigration = (command, timeoutMs) => + runProcessSync({ + program: command.command, + args: command.args, + env: command.env, + timeoutMs, + stdio: 'ignore' + }) + } = deps + if (platform !== 'linux' || !isDurableDaemonScopeSupported(deps)) { return false } const legacy = readProcesses(pid) @@ -280,7 +291,12 @@ export function migrateLegacyDaemonScope( } try { const result = runMigration( - buildLegacyScopeMigrationCommand(launchNonce, legacy.pids, env, canonicalRuntimeDir), + buildLegacyScopeMigrationCommand( + launchNonce, + legacy.pids, + env, + resolveCanonicalRuntimeDir(deps) + ), SYSTEMD_SCOPE_MIGRATION_TIMEOUT_MS ) return result.code === 0 && !result.timedOut @@ -302,6 +318,11 @@ export function buildDurableDaemonScopeCommand( canonicalRuntimeDir: string | null = CANONICAL_USER_RUNTIME_DIR ): DurableDaemonScopeCommand { const runtimeDir = resolveUserRuntimeDir(env, canonicalRuntimeDir) + const scopeEnv: NodeJS.ProcessEnv = runtimeDir + ? { ...env, XDG_RUNTIME_DIR: runtimeDir } + : { ...env } + // sd-bus prefers this over XDG_RUNTIME_DIR and refuses Chromium's marker (#25580). + removeChromiumDisabledSessionBus(scopeEnv) return { command: SYSTEMD_RUN_BINARY, args: [ @@ -318,7 +339,7 @@ export function buildDurableDaemonScopeCommand( // Explicit, not inherited: the daemon must land in the same user manager the resolution // above just confirmed is reachable, regardless of what this spread `env`'s own // `XDG_RUNTIME_DIR` says (see `resolveUserRuntimeDir` for why that value can be wrong). - env: runtimeDir ? { ...env, XDG_RUNTIME_DIR: runtimeDir } : { ...env } + env: scopeEnv } } diff --git a/src/main/daemon/daemon-client-connections.ts b/src/main/daemon/daemon-client-connections.ts index 7eedef1285b..ce16616dc04 100644 --- a/src/main/daemon/daemon-client-connections.ts +++ b/src/main/daemon/daemon-client-connections.ts @@ -1,9 +1,10 @@ import type { Socket } from 'node:net' import { StringDecoder } from 'node:string_decoder' +import { isDaemonRequestFrame, isHelloFrame } from './daemon-client-frame-guards' import type { DaemonFileLog } from './daemon-file-log' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { createNdjsonParser, encodeNdjson } from './ndjson' -import type { DaemonRequest, HelloMessage } from './types' +import type { DaemonRequest } from './types' // Idle time before the first probe. How long the close then takes is the OS's probe schedule, not // ours, which is why the producer-stall watchdog never waits on it. @@ -108,13 +109,13 @@ export class DaemonClientConnections { } private handleFirstMessage(socket: Socket, message: unknown): void { - const hello = message as HelloMessage - if (hello.type !== 'hello') { + if (!isHelloFrame(message)) { this.options.log.log('client-hello-rejected', { reason: 'expected-hello' }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) socket.destroy() return } + const hello = message if (hello.version !== this.options.protocolVersion) { this.options.log.log('client-hello-rejected', { reason: 'protocol-mismatch', @@ -175,7 +176,7 @@ export class DaemonClientConnections { this.options.onAuthenticatedPair() return } - // Parsed wire data is not made safe by the HelloMessage assertion above. + // The guard checks only that role is a string; anything else was answered above. socket.destroy() } @@ -201,7 +202,15 @@ export class DaemonClientConnections { private setupControlParser(socket: Socket, clientId: string): void { const decoder = new StringDecoder('utf8') const parser = createNdjsonParser( - (message) => this.options.onControlRequest(socket, clientId, message as DaemonRequest), + (message) => { + if (isDaemonRequestFrame(message)) { + this.options.onControlRequest(socket, clientId, message) + return + } + // Unroutable without an id, so no reply is possible; drop only this connection. + this.options.log.log('client-frame-rejected', { clientId }) + socket.destroy() + }, () => {} ) socket.removeAllListeners('data') diff --git a/src/main/daemon/daemon-client-frame-guards.ts b/src/main/daemon/daemon-client-frame-guards.ts new file mode 100644 index 00000000000..6e07b79b2c4 --- /dev/null +++ b/src/main/daemon/daemon-client-frame-guards.ts @@ -0,0 +1,30 @@ +/** + * Shape checks for the frames a daemon client sends. JSON.parse yields anything — `null`, a + * number, an object missing `id` — and one unchecked property read on such a frame throws out of + * the socket handler and takes the daemon, and every PTY in it, down (#17841). + */ +import type { DaemonRequest, HelloMessage } from './types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Field types only; the caller still checks version, token and role values for its replies. */ +export function isHelloFrame(value: unknown): value is HelloMessage { + return ( + isRecord(value) && + value.type === 'hello' && + typeof value.version === 'number' && + typeof value.token === 'string' && + typeof value.clientId === 'string' && + typeof value.role === 'string' + ) +} + +/** + * The routing envelope only: a request needs a string `id` to be answered and a string `type` to + * be routed. Payload faults throw inside the router's try and come back as error replies. + */ +export function isDaemonRequestFrame(value: unknown): value is DaemonRequest { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} diff --git a/src/main/daemon/daemon-client-frame-validation.test.ts b/src/main/daemon/daemon-client-frame-validation.test.ts new file mode 100644 index 00000000000..2c6b025567e --- /dev/null +++ b/src/main/daemon/daemon-client-frame-validation.test.ts @@ -0,0 +1,144 @@ +import './mock-descendant-sweep' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { connect, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DaemonClient } from './client' +import { isDaemonRequestFrame, isHelloFrame } from './daemon-client-frame-guards' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import type { SubprocessHandle } from './session-subprocess-handle' +import { PROTOCOL_VERSION } from './types' + +function createMockSubprocess(): SubprocessHandle { + let notifyExit: ((code: number) => void) | null = null + const exit = (): void => notifyExit?.(0) + return { + pid: 44445, + getForegroundProcess: () => null, + write() {}, + resize() {}, + kill: exit, + terminateOwnedTree: () => 'unavailable' as const, + forceKill: exit, + signal() {}, + onData() {}, + onExit(callback) { + notifyExit = callback + }, + dispose() {} + } +} + +/** Writes raw lines and resolves with whatever came back once the daemon closes the socket. */ +function sendRawFrames(socketPath: string, lines: string[]): Promise { + return new Promise((resolve, reject) => { + const socket: Socket = connect({ path: socketPath }) + let received = '' + const timer = setTimeout(() => { + socket.destroy() + reject(new Error(`daemon kept the connection open; got: ${received}`)) + }, 5_000) + socket.on('data', (chunk) => { + received += chunk.toString('utf8') + }) + socket.on('error', () => {}) + socket.on('close', () => { + clearTimeout(timer) + resolve(received) + }) + socket.on('connect', () => socket.write(lines.map((line) => `${line}\n`).join(''))) + }) +} + +describe('frame guards', () => { + it.each([null, 7, 'hello', [], {}, { type: 'hello' }, { type: 'hello', version: '1' }])( + 'rejects %j as a hello', + (frame) => expect(isHelloFrame(frame)).toBe(false) + ) + + it('accepts a well-formed hello', () => { + expect( + isHelloFrame({ type: 'hello', version: 1, token: 't', clientId: 'c', role: 'control' }) + ).toBe(true) + }) + + it.each([null, {}, { id: 123, type: 'ping' }, { id: 'a' }, { type: 'ping' }])( + 'rejects %j as a request', + (frame) => expect(isDaemonRequestFrame(frame)).toBe(false) + ) +}) + +describe.skipIf(process.platform === 'win32')('malformed client frames (#17841)', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let client: DaemonClient | null = null + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'daemon-frames-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'test.token') + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + await client.request('createOrAttach', { sessionId: 'survivor', cols: 80, rows: 24 }) + }) + + afterEach(async () => { + client?.disconnect() + client = null + await server.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + function validHello(): string { + return JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: 'raw-client', + role: 'control' + }) + } + + async function expectDaemonStillServing(): Promise { + const listed = await client!.request<{ sessions: { sessionId: string; isAlive: boolean }[] }>( + 'listSessions', + undefined + ) + expect(listed.sessions).toContainEqual( + expect.objectContaining({ sessionId: 'survivor', isAlive: true }) + ) + } + + it.each(['null', '42', '"hello"', '[]', '{}', '{"type":"hello","version":"x"}'])( + 'refuses the pre-auth frame %s on that connection only', + async (frame) => { + const reply = await sendRawFrames(socketPath, [frame]) + expect(reply).toContain('Expected hello') + await expectDaemonStillServing() + } + ) + + it.each(['null', '{}', '{"type":"ping"}', '{"id":123,"type":"ping"}'])( + 'drops the authenticated connection that sends %s', + async (frame) => { + const reply = await sendRawFrames(socketPath, [validHello(), frame]) + expect(reply).toContain('"ok":true') + await expectDaemonStillServing() + } + ) + + it('still answers a well-formed request after a bad one on another connection', async () => { + await sendRawFrames(socketPath, [validHello(), '{"method":"x"}']) + await expect(client!.request('ping', undefined)).resolves.toBeDefined() + }) +}) diff --git a/src/main/daemon/daemon-client-socket-connect.ts b/src/main/daemon/daemon-client-socket-connect.ts index e6a2f615e17..1f05d57f8e5 100644 --- a/src/main/daemon/daemon-client-socket-connect.ts +++ b/src/main/daemon/daemon-client-socket-connect.ts @@ -1,8 +1,11 @@ import { connect, type Socket } from 'node:net' import { DaemonProtocolError } from './types' +import { ensureDaemonSocketDir } from './daemon-socket-endpoint-path' export function connectDaemonSocket(socketPath: string, timeoutMs: number): Promise { return new Promise((resolve, reject) => { + // Throws inside the executor, so an untrusted relocated dir rejects before the hello token is sent. + ensureDaemonSocketDir(socketPath) const socket = connect(socketPath) const cleanup = (): void => { clearTimeout(timer) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 04cf322b893..7778a5c95fa 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs' import { connect, type Socket } from 'node:net' import { encodeNdjson } from './ndjson' import { ptySpawnHealthPlatformCoverage } from './daemon-health-identity' +import { daemonSocketDirIsTrusted } from './daemon-socket-endpoint-path' import { PROTOCOL_VERSION, type HelloMessage, @@ -144,6 +145,10 @@ export function checkDaemonHealthWithCoverage( HEALTH_CHECK_TIMEOUT_MS ) + if (!daemonSocketDirIsTrusted(socketPath)) { + settle({ verdict: 'unreachable', coverage: fallbackCoverage }) + return + } sock = connect({ path: socketPath }) sock.on('error', onError) sock.on('connect', onConnect) @@ -272,6 +277,10 @@ export function getMacDaemonSystemResolverHealth( } const timer = setTimeout(() => settle('unknown'), RESOLVER_HEALTH_CHECK_TIMEOUT_MS) + if (!daemonSocketDirIsTrusted(socketPath)) { + settle('unknown') + return + } sock = connect({ path: socketPath }) sock.on('error', onError) sock.on('connect', onConnect) diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index a3b5d0d4252..a28aa222237 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -49,3 +49,33 @@ describe('daemon launch scope ownership', () => { ) }) }) + +describe('daemon launch environment', () => { + const originalBus = process.env.DBUS_SESSION_BUS_ADDRESS + afterEach(() => { + if (originalBus === undefined) { + delete process.env.DBUS_SESSION_BUS_ADDRESS + } else { + process.env.DBUS_SESSION_BUS_ADDRESS = originalBus + } + }) + + it.each([false, true])( + "never hands Chromium's disabled: bus marker to the daemon (scoped=%s)", + (scoped) => { + process.env.DBUS_SESSION_BUS_ADDRESS = 'disabled:' + spawnDaemonChildProcess(options, scoped) + const spec = (scoped ? spawn : fork).mock.calls[0]?.[0] + expect(spec.env).not.toHaveProperty('DBUS_SESSION_BUS_ADDRESS') + expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1') + } + ) + + it('keeps a real session bus address', () => { + process.env.DBUS_SESSION_BUS_ADDRESS = 'unix:path=/run/user/1000/bus' + spawnDaemonChildProcess(options, false) + expect(fork.mock.calls[0]?.[0].env.DBUS_SESSION_BUS_ADDRESS).toBe( + 'unix:path=/run/user/1000/bus' + ) + }) +}) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 1d069b70aa5..bc39d6b3ee6 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -1,6 +1,7 @@ import { forkProcess, type ForkSpec } from '../../shared/child-process/fork-process' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import { getAppEnvironment } from '../../shared/app-environment' +import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' import { buildDurableDaemonScopeCommand } from './daemon-cgroup-scope' import { daemonLogArgs } from './daemon-launch-paths' @@ -60,12 +61,14 @@ export function spawnDaemonChildProcess( const { forkEntryPath, relocatedExecPath, userDataPath, launchNonce } = options const scriptArgs = buildDaemonScriptArgs(options) // Why: run as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper. - const daemonEnv = { + const daemonEnv: NodeJS.ProcessEnv = { ...process.env, ELECTRON_RUN_AS_NODE: '1', // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath } + // Why: this env becomes the daemon's, and so every PTY's (#21119). + removeChromiumDisabledSessionBus(daemonEnv) // Why cwd: detached daemons outlive dev worktrees; userData keeps process.cwd() valid after a repo/worktree is deleted. // Why detached/stdio: detached+unref outlives Electron; stdout 'ignore' (else blocks exit), stderr 'pipe' captures startup crashes lost in v1.4.129-rc.1. const childOptions: Pick = { diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 1aa811c1d5d..87a9289a72a 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -24,6 +24,7 @@ import { } from './daemon-spawner' import { PROTOCOL_VERSION } from './types' import { prepareDaemonReplacement } from './daemon-replacement-preflight' +import { ensureDaemonSocketDir } from './daemon-socket-endpoint-path' // Why: the adapter decides a runtime resolver replacement, but the launcher completes it — and by // then the daemon has usually self-retired (dropping its last authenticated client is enough), so @@ -70,6 +71,9 @@ export function createOutOfProcessLauncher( // what makes a bare module-scoped slot safe — keep it that way or a concurrent launch can steal it. const attributedReason = attributedReplaceReason attributedReplaceReason = null + // Why first: adoption, the preflight probes and health checks all talk to this endpoint, and a + // relocated one is only safe to talk to once its directories are proven private and ours. + ensureDaemonSocketDir(socketPath) let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index a917891f109..02be19b439e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -131,8 +131,14 @@ export class DaemonServer { }, onLastAuthenticatedClientDisconnected: () => this.lifecycle.onLastAuthenticatedClientDisconnected(), - onControlRequest: (socket, clientId, request) => - void this.handleRequest(socket, clientId, request), + onControlRequest: (socket, clientId, request) => { + // Why: a rejection here would be unhandled, and the daemon treats that as fatal. + this.handleRequest(socket, clientId, request).catch((error: unknown) => { + this.log.log('client-request-failed', { + error: error instanceof Error ? error.message : String(error) + }) + }) + }, onControlReplaced: (clientId) => { this.preparations.cancelForClient(clientId) this.historySeedTransfers.clearOwner(clientId) diff --git a/src/main/daemon/daemon-socket-endpoint-path.test.ts b/src/main/daemon/daemon-socket-endpoint-path.test.ts new file mode 100644 index 00000000000..77e7aa19be9 --- /dev/null +++ b/src/main/daemon/daemon-socket-endpoint-path.test.ts @@ -0,0 +1,160 @@ +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { createServer, type Server, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { DaemonClient } from './client' +import { checkDaemonHealthWithCoverage } from './daemon-health' +import { createOutOfProcessLauncher } from './daemon-out-of-process-launcher' +import { unixSocketPathFits } from '../../shared/unix-socket-path-limit' +import { getDaemonSocketBindPath } from './daemon-endpoint-ownership' +import { getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' +import { + ensureDaemonSocketDir, + resolveDaemonUnixSocketPath, + shortDaemonSocketDir +} from './daemon-socket-endpoint-path' + +const SOCKET_NAME = 'daemon-v99.sock' +const longRuntimeDir = (bytes: number): string => `/home/${'u'.repeat(bytes - 20)}/orca/daemon` + +describe('resolveDaemonUnixSocketPath (#17840)', () => { + it.each(['linux', 'darwin'] as const)( + 'keeps the userData endpoint for a short root on %s', + (os) => { + expect( + resolveDaemonUnixSocketPath('/home/me/.config/orca/daemon', SOCKET_NAME, os, 1000) + ).toBe(`/home/me/.config/orca/daemon/${SOCKET_NAME}`) + } + ) + + it.each(['linux', 'darwin'] as const)( + 'moves a 120-byte root to the per-uid short dir on %s, and the bind name fits too', + (os) => { + const runtimeDir = longRuntimeDir(120) + const resolved = resolveDaemonUnixSocketPath(runtimeDir, SOCKET_NAME, os, 1000) + expect(resolved).toBe(`${shortDaemonSocketDir(runtimeDir, 1000)}/${SOCKET_NAME}`) + expect(unixSocketPathFits(resolved, os)).toBe(true) + expect(unixSocketPathFits(getDaemonSocketBindPath(resolved), os)).toBe(true) + } + ) + + it('relocates on darwin at a length linux still accepts', () => { + // 104-byte darwin sun_path vs 108 on linux. + const runtimeDir = `/h/${'x'.repeat(105 - 3 - 1 - SOCKET_NAME.length)}` + expect(resolveDaemonUnixSocketPath(runtimeDir, SOCKET_NAME, 'linux', 1)).toBe( + `${runtimeDir}/${SOCKET_NAME}` + ) + expect(resolveDaemonUnixSocketPath(runtimeDir, SOCKET_NAME, 'darwin', 1)).not.toContain( + runtimeDir + ) + }) + + it('gives distinct data roots distinct short dirs', () => { + expect(shortDaemonSocketDir(longRuntimeDir(120), 1)).not.toBe( + shortDaemonSocketDir(`${longRuntimeDir(120)}x`, 1) + ) + }) +}) + +describe.skipIf(process.platform === 'win32')('relocated endpoint binds (#17840)', () => { + const uid = process.getuid!() + let base = '' + let runtimeDir = '' + + afterEach(() => { + rmSync(shortDaemonSocketDir(runtimeDir, uid), { recursive: true, force: true }) + rmSync(base, { recursive: true, force: true }) + try { + rmdirSync(dirname(shortDaemonSocketDir(runtimeDir, uid))) + } catch { + // Not empty: another Orca on this machine uses it. + } + }) + + it('creates a private dir the over-long data root can bind its socket in', async () => { + base = mkdtempSync(join(tmpdir(), 'orca-long-')) + runtimeDir = join(base, 'x'.repeat(130)) + mkdirSync(runtimeDir, { recursive: true }) + const socketPath = getDaemonSocketPath(runtimeDir, 99) + expect(socketPath.startsWith(runtimeDir)).toBe(false) + + ensureDaemonSocketDir(socketPath) + expect(lstatSync(shortDaemonSocketDir(runtimeDir, uid)).mode & 0o777).toBe(0o700) + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(getDaemonSocketBindPath(socketPath), resolve) + }) + await new Promise((resolve) => server.close(() => resolve())) + }) + + it('refuses a shared short dir rather than repairing it', () => { + base = mkdtempSync(join(tmpdir(), 'orca-long-')) + runtimeDir = join(base, 'x'.repeat(130)) + const socketPath = getDaemonSocketPath(runtimeDir, 99) + ensureDaemonSocketDir(socketPath) + chmodSync(shortDaemonSocketDir(runtimeDir, uid), 0o777) + expect(() => ensureDaemonSocketDir(socketPath)).toThrow(/not a private directory/) + }) + it('never connects to a listener planted in a shared relocated dir', async () => { + base = mkdtempSync(join(tmpdir(), 'orca-long-')) + runtimeDir = join(base, 'x'.repeat(130)) + mkdirSync(runtimeDir, { recursive: true }) + const socketPath = getDaemonSocketPath(runtimeDir, 99) + const tokenPath = getDaemonTokenPath(runtimeDir, 99) + writeFileSync(tokenPath, 'secret-token') + // Stands in for another user's pre-created, traversable dir; mode is what the guard sees. + mkdirSync(shortDaemonSocketDir(runtimeDir, uid), { recursive: true, mode: 0o700 }) + chmodSync(shortDaemonSocketDir(runtimeDir, uid), 0o777) + const received: string[] = [] + const accepted: Socket[] = [] + const fake: Server = createServer((socket) => { + accepted.push(socket) + socket.on('data', (chunk) => { + received.push(chunk.toString()) + socket.write(`${JSON.stringify({ type: 'hello', ok: true })}\n`) + }) + }) + await new Promise((resolve, reject) => { + fake.once('error', reject) + fake.listen(socketPath, resolve) + }) + setAppEnvironment({ + getPath: () => base, + getAppPath: () => base, + getVersion: () => '0.0.0', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + try { + await expect(createOutOfProcessLauncher(runtimeDir)(socketPath, tokenPath)).rejects.toThrow( + /not a private directory/ + ) + await expect(new DaemonClient({ socketPath, tokenPath }).ensureConnected()).rejects.toThrow( + /not a private directory/ + ) + await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({ + verdict: 'unreachable' + }) + expect(accepted).toHaveLength(0) + expect(received).toEqual([]) + } finally { + for (const socket of accepted) { + socket.destroy() + } + await new Promise((resolve) => fake.close(() => resolve())) + } + }) +}) diff --git a/src/main/daemon/daemon-socket-endpoint-path.ts b/src/main/daemon/daemon-socket-endpoint-path.ts new file mode 100644 index 00000000000..8af2bad57f0 --- /dev/null +++ b/src/main/daemon/daemon-socket-endpoint-path.ts @@ -0,0 +1,81 @@ +/** + * Keeps the daemon's Unix socket inside `sockaddr_un.sun_path`. + * + * The endpoint normally lives at `/daemon/daemon-v.sock`. A long data root pushes + * that past the kernel cap, and the bind then fails or "succeeds" with no directory entry, so the + * daemon never comes up and terminals stop surviving restarts (#17840). Only then does the socket + * move to a fixed-length per-uid base; the token, pid record and history stay under userData. + * Windows binds named pipes, which have no such limit. + */ +import { createHash } from 'node:crypto' +import { lstatSync, mkdirSync } from 'node:fs' +import { posix } from 'node:path' +import { unixSocketPathFits } from '../../shared/unix-socket-path-limit' +import { hasErrorCode } from './daemon-process-inspection' +import { PRIVATE_DIR_MODE } from './daemon-private-file-modes' + +/** `/tmp` is the only POSIX directory whose length does not depend on the user. */ +const SHORT_DAEMON_SOCKET_DIR_PREFIX = '/tmp/.orca-daemon-' + +export function shortDaemonSocketDir(runtimeDir: string, uid: number): string { + const runtimeHash = createHash('sha256').update(runtimeDir).digest('hex').slice(0, 12) + return `${SHORT_DAEMON_SOCKET_DIR_PREFIX}${uid}/${runtimeHash}` +} + +export function resolveDaemonUnixSocketPath( + runtimeDir: string, + socketName: string, + platform: NodeJS.Platform = process.platform, + uid: number | undefined = process.getuid?.() +): string { + const preferred = posix.join(runtimeDir, socketName) + if ( + uid === undefined || + unixSocketPathFits(preferred, platform === 'darwin' ? 'darwin' : 'linux') + ) { + return preferred + } + return posix.join(shortDaemonSocketDir(runtimeDir, uid), socketName) +} + +/** Validate, never repair: a dir planted by another user or a symlink is refused, not chmod'ed. */ +function ensureOwnedPrivateDir(dir: string, uid: number): void { + try { + mkdirSync(dir, { mode: PRIVATE_DIR_MODE }) + } catch (error) { + if (!hasErrorCode(error, 'EEXIST')) { + throw error + } + } + const entry = lstatSync(dir) + if (!entry.isDirectory() || entry.uid !== uid || (entry.mode & 0o077) !== 0) { + throw new Error(`Daemon socket directory ${dir} is not a private directory owned by this user`) + } +} + +/** + * Creates and validates the relocated socket's directories; a no-op for the default endpoint. + * Why before every connect, not just the bind: the `/tmp` path is predictable, so a listener another + * user planted there would otherwise receive the hello token before any bind-time check ran. + */ +export function ensureDaemonSocketDir( + socketPath: string, + uid: number | undefined = process.getuid?.() +): void { + if (uid === undefined || !socketPath.startsWith(`${SHORT_DAEMON_SOCKET_DIR_PREFIX}${uid}/`)) { + return + } + const runtimeDir = posix.dirname(socketPath) + ensureOwnedPrivateDir(posix.dirname(runtimeDir), uid) + ensureOwnedPrivateDir(runtimeDir, uid) +} + +/** Non-throwing form for probes that must fail closed instead of connecting. */ +export function daemonSocketDirIsTrusted(socketPath: string): boolean { + try { + ensureDaemonSocketDir(socketPath) + return true + } catch { + return false + } +} diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 717bef90d7a..960ef7996e0 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -10,6 +10,7 @@ import { } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' +import { resolveDaemonUnixSocketPath } from './daemon-socket-endpoint-path' import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' export type DaemonConnectionInfo = { @@ -136,7 +137,7 @@ export function getDaemonSocketPath( const suffix = createHash('sha256').update(runtimeDir).digest('hex').slice(0, 12) return `\\\\?\\pipe\\orca-terminal-host-v${protocolVersion}-${suffix}` } - return join(runtimeDir, `daemon-v${protocolVersion}.sock`) + return resolveDaemonUnixSocketPath(runtimeDir, `daemon-v${protocolVersion}.sock`) } export function getDaemonTokenPath(runtimeDir: string, protocolVersion = PROTOCOL_VERSION): string { diff --git a/src/main/daemon/daemon-user-manager-lifetime.test.ts b/src/main/daemon/daemon-user-manager-lifetime.test.ts new file mode 100644 index 00000000000..d8667ea9009 --- /dev/null +++ b/src/main/daemon/daemon-user-manager-lifetime.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + cgroupIsInsideUserManager, + userManagerOutlivesCaller +} from './daemon-user-manager-lifetime' + +const dirs: string[] = [] +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function cgroupFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-cgroup-')) + dirs.push(dir) + const path = join(dir, 'cgroup') + writeFileSync(path, contents) + return path +} + +const SYSTEM_SERVICE = '0::/system.slice/orca-serve.service\n' +const SSH_SESSION = '0::/user.slice/user-1000.slice/session-4.scope\n' +const DESKTOP_APP = '0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-orca-12.scope\n' + +describe('cgroupIsInsideUserManager', () => { + it.each([ + [DESKTOP_APP, true], + ['1:name=systemd:/user.slice/user-1000.slice/user@1000.service/app.slice/x.scope\n', true], + [SYSTEM_SERVICE, false], + [SSH_SESSION, false], + ['0::/user.slice/user-1000.slice/user@10000.service/app.slice/x.scope\n', false] + ])('%s -> %s', (contents, expected) => { + expect(cgroupIsInsideUserManager(contents, 1000)).toBe(expected) + }) +}) + +describe('userManagerOutlivesCaller', () => { + it('trusts a caller already inside the user manager without asking logind', () => { + const runLingerProbe = vi.fn() + expect( + userManagerOutlivesCaller({ uid: 1000, cgroupPath: cgroupFile(DESKTOP_APP), runLingerProbe }) + ).toBe(true) + expect(runLingerProbe).not.toHaveBeenCalled() + }) + + it.each([SYSTEM_SERVICE, SSH_SESSION])('requires linger for a caller in %s', (contents) => { + const log = vi.fn() + const runLingerProbe = vi.fn(() => ({ code: 0, timedOut: false, stdout: 'yes\n' })) + expect( + userManagerOutlivesCaller({ + uid: 1000, + cgroupPath: cgroupFile(contents), + runLingerProbe, + log + }) + ).toBe(true) + expect(runLingerProbe).toHaveBeenCalledWith(1000, 2_000) + expect(log).not.toHaveBeenCalled() + }) + + it.each([ + ['linger off', () => ({ code: 0, timedOut: false, stdout: 'no\n' })], + ['unknown user', () => ({ code: 1, timedOut: false, stdout: '' })], + ['timed out', () => ({ code: null, timedOut: true, stdout: 'yes\n' })], + [ + 'loginctl missing', + () => { + throw new Error('spawn loginctl ENOENT') + } + ] + ])('fails closed and says why when %s', (_label, runLingerProbe) => { + const log = vi.fn() + expect( + userManagerOutlivesCaller({ + uid: 1000, + cgroupPath: cgroupFile(SYSTEM_SERVICE), + runLingerProbe, + log + }) + ).toBe(false) + expect(log).toHaveBeenCalledWith( + expect.stringContaining('daemon-scope-unavailable: linger off') + ) + }) + + it('falls back to linger when cgroup membership is unreadable', () => { + expect( + userManagerOutlivesCaller({ + uid: 1000, + cgroupPath: '/definitely/missing/cgroup', + runLingerProbe: () => ({ code: 0, timedOut: false, stdout: 'yes' }) + }) + ).toBe(true) + }) + + it('is false without a uid', () => { + expect(userManagerOutlivesCaller({ uid: null, log: () => {} })).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-user-manager-lifetime.ts b/src/main/daemon/daemon-user-manager-lifetime.ts new file mode 100644 index 00000000000..a2942cdf0e7 --- /dev/null +++ b/src/main/daemon/daemon-user-manager-lifetime.ts @@ -0,0 +1,85 @@ +/** + * Whether a scope under the systemd `--user` manager lives at least as long as the caller. + * + * Without lingering, systemd stops `user@.service` — and every `orca-daemon-*.scope` in it — + * shortly after the user's last login session closes. A caller in a system unit or an SSH + * session's `session-N.scope` outlives that, so moving the daemon into the user manager would + * shorten its life (#24201). A caller already inside the user manager (a desktop app scope) + * dies with it anyway, so the scope costs it nothing. + */ +import { readFileSync } from 'node:fs' +import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' + +const LINGER_PROBE_TIMEOUT_MS = 2_000 + +export type LingerProbe = ( + uid: number, + timeoutMs: number +) => Pick + +function runLoginctlLingerProbe( + uid: number, + timeoutMs: number +): Pick { + return runProcessSync({ + program: 'loginctl', + args: ['show-user', String(uid), '-p', 'Linger', '--value'], + stdio: ['ignore', 'pipe', 'ignore'], + timeoutMs + }) +} + +/** The path field of one `/proc//cgroup` line (`id:controllers:path`); '' when absent. */ +export function cgroupPathFromProcLine(line: string): string { + return line.split(':').slice(2).join(':').trim() +} + +/** True when any of this process's cgroup paths sits inside the uid's user manager. */ +export function cgroupIsInsideUserManager(contents: string, uid: number): boolean { + const unit = `/user@${uid}.service` + return contents.split('\n').some((line) => { + const path = cgroupPathFromProcLine(line) + return path.endsWith(unit) || path.includes(`${unit}/`) + }) +} + +export type UserManagerLifetimeProbeOptions = { + uid?: number | null + cgroupPath?: string + runLingerProbe?: LingerProbe + log?: (message: string) => void +} + +/** Fails closed: an unreadable cgroup plus an unknown linger state means "not durable". */ +export function userManagerOutlivesCaller(options: UserManagerLifetimeProbeOptions = {}): boolean { + const uid = options.uid !== undefined ? options.uid : (process.getuid?.() ?? null) + if (uid === null) { + return false + } + try { + if ( + cgroupIsInsideUserManager( + readFileSync(options.cgroupPath ?? '/proc/self/cgroup', 'utf8'), + uid + ) + ) { + return true + } + } catch { + // Unreadable membership proves nothing; linger alone decides. + } + let lingering = false + try { + const probe = (options.runLingerProbe ?? runLoginctlLingerProbe)(uid, LINGER_PROBE_TIMEOUT_MS) + lingering = probe.code === 0 && !probe.timedOut && probe.stdout.trim() === 'yes' + } catch { + // loginctl missing or unstartable. + } + if (!lingering) { + ;(options.log ?? console.warn)( + '[daemon] daemon-scope-unavailable: linger off; the terminal daemon stays in this ' + + "process's cgroup. Run `loginctl enable-linger` so terminals survive a service restart." + ) + } + return lingering +} diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index b818c2efdaf..428178cf43a 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -4,6 +4,7 @@ import { restoreOrStripOverlayEnv } from '../../../shared/agent-overlay-env' import { delimiter } from 'node:path' import { dropInheritedOrcaFishHistory } from '../../fish-history-session' import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env' +import { removeChromiumDisabledSessionBus } from '../../pty/chromium-session-bus-env' import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env' import { stripPiProcessOwnerEnv } from '../../pty/pi-process-owner-env' import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env' @@ -199,6 +200,7 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record boolean = existsSync +): string { + // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the + // packaged entry must be forked from app.asar.unpacked. + const usesAsarArchive = isPackaged && appPath.includes('app.asar') + const basePath = usesAsarArchive ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const adjacentBuildEntry = join(basePath, entryFileName) + // Why asar and not isPackaged: orcad is a packaged non-Electron host whose app root holds + // orcad.js and its children side by side; only the asar layout nests them under out/main. + // electron-vite's unpackaged appPath is already out/main, so dev also lands here. + if (!usesAsarArchive && pathExists(adjacentBuildEntry)) { + return adjacentBuildEntry + } + return join(basePath, 'out', 'main', entryFileName) +} + +/** For ELECTRON_RUN_AS_NODE, which exposes resourcesPath but no app root. */ +export function resolveForkedChildEntryPathWithoutApp( + entryFileName: string, + cwd: string, + resourcesPath: string | undefined, + pathExists: (candidate: string) => boolean = existsSync +): string { + if (resourcesPath) { + const packagedEntry = join(resourcesPath, 'app.asar.unpacked', 'out', 'main', entryFileName) + if (pathExists(packagedEntry)) { + return packagedEntry + } + } + return resolveForkedChildEntryPath(entryFileName, cwd, false, pathExists) +} + +/** The current process's path for a forked child's built entry. */ +export function getForkedChildEntryPath(entryFileName: string): string { + // Why the port: hasAppEnvironment() gives the same "no app root here" answer as electron.app. + if (hasAppEnvironment()) { + const app = getAppEnvironment() + return resolveForkedChildEntryPath(entryFileName, app.getAppPath(), app.isPackaged()) + } + return resolveForkedChildEntryPathWithoutApp(entryFileName, process.cwd(), process.resourcesPath) +} diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index d056727fdb7..63299bd99d6 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -8,6 +8,7 @@ import type { AppIdentity } from '../../shared/app-identity' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import { relaunchApp } from '../app-relaunch' +import { quitProcess } from '../startup/process-quit-request' import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailableAsync } from '../pwsh' @@ -312,7 +313,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp await runBeforeRelaunchCleanup(options.onBeforeRelaunch) setTimeout(() => { relaunchApp('admin-restart') - app.quit() + quitProcess() }, 150) }) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 694841e544e..1bb41ce63fa 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -1,6 +1,7 @@ -import { app, ipcMain, type WebContents } from 'electron' +import { ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' import { relaunchApp, type AppRelaunchReason } from '../app-relaunch' +import { quitProcess } from '../startup/process-quit-request' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -149,7 +150,7 @@ function scheduleProfileRelaunch(reason: ProfileRelaunchReason, sender: WebConte // Why: app.quit() (not app.exit) so before-quit/will-quit still run — // renderer scrollback capture, PTY kill, stats flush, and daemon final // checkpoints must not be skipped on a profile switch. - app.quit() + quitProcess() }, 150) } diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index 4633739fe15..79fb16e166b 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -1,8 +1,11 @@ import { existsSync } from 'node:fs' -import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { join } from 'node:path' +import { + getForkedChildEntryPath, + resolveForkedChildEntryPath, + resolveForkedChildEntryPathWithoutApp +} from '../forked-child-entry-path' -type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } +const WATCHER_ENTRY_FILENAME = 'parcel-watcher-process-entry.js' export function watcherProcessEntryExists(entryPath: string): boolean { if (existsSync(entryPath)) { @@ -12,32 +15,12 @@ export function watcherProcessEntryExists(entryPath: string): boolean { return false } -// Why the port and not require('electron'): this module is reachable from plain-Node -// fork entries, where the literal text require("electron") fails the build guard even -// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. -function loadElectronApp(): ElectronAppPath | null { - return hasAppEnvironment() ? getAppEnvironment() : null -} - export function resolveWatcherProcessEntryPath( appPath: string, isPackaged: boolean, pathExists: (candidate: string) => boolean = existsSync ): string { - // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the - // packaged entry must be forked from app.asar.unpacked. - const usesAsarArchive = isPackaged && appPath.includes('app.asar') - const basePath = usesAsarArchive ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath - const adjacentBuildEntry = join(basePath, 'parcel-watcher-process-entry.js') - // Why: electron-vite's unpackaged appPath is already out/main. Appending - // out/main again silently disables crash isolation in dev and E2E builds. - // Why asar and not isPackaged: orcad is a packaged non-Electron host whose app root - // holds orcad.js and this child side by side. Only the asar layout nests it under - // out/main, and only there is the adjacent probe guaranteed to miss. - if (!usesAsarArchive && pathExists(adjacentBuildEntry)) { - return adjacentBuildEntry - } - return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') + return resolveForkedChildEntryPath(WATCHER_ENTRY_FILENAME, appPath, isPackaged, pathExists) } export function resolveWatcherProcessEntryPathWithoutApp( @@ -45,27 +28,14 @@ export function resolveWatcherProcessEntryPathWithoutApp( resourcesPath: string | undefined, pathExists: (candidate: string) => boolean = existsSync ): string { - if (resourcesPath) { - const packagedEntry = join( - resourcesPath, - 'app.asar.unpacked', - 'out', - 'main', - 'parcel-watcher-process-entry.js' - ) - // Why: ELECTRON_RUN_AS_NODE exposes resourcesPath but not electron.app. - // Prefer the unpacked packaged entry without breaking dev Node fallbacks. - if (pathExists(packagedEntry)) { - return packagedEntry - } - } - return resolveWatcherProcessEntryPath(cwd, false, pathExists) + return resolveForkedChildEntryPathWithoutApp( + WATCHER_ENTRY_FILENAME, + cwd, + resourcesPath, + pathExists + ) } export function getWatcherProcessEntryPath(): string { - const app = loadElectronApp() - if (app) { - return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged()) - } - return resolveWatcherProcessEntryPathWithoutApp(process.cwd(), process.resourcesPath) + return getForkedChildEntryPath(WATCHER_ENTRY_FILENAME) } diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index cf61b71c9e5..ee3e77fd505 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { LocalPtyProvider } from '../providers/local-pty-provider' +import { SshPlainShellPtyProvider } from '../providers/ssh-plain-shell-pty-provider' import { registerPtyHandlers, registerSshPtyProvider, @@ -207,6 +208,28 @@ describe('registerPtyHandlers', () => { } }) + it('answers unknown for an earlier relay PTY once the target is in plain SSH mode', async () => { + // Why: rung D never saw that PTY; it may still run on the earlier relay. + const connectionId = 'ssh-plain-1' + const ptyId = `ssh:${connectionId}@@prior-relay-pty` + setLocalPtyProvider(new LocalPtyProvider()) + const openShell = vi.fn(async () => { + throw new Error('no shell in this test') + }) + const mode = { reason: 'no_runtime' as const, message: 'plain' } + registerSshPtyProvider( + connectionId, + new SshPlainShellPtyProvider(connectionId, openShell, mode, true, 1) + ) + const controller = registerAgentClaimController() + try { + await expect(controller.probePtyLiveness(ptyId)).resolves.toBeNull() + await expect(controller.attach(ptyId)).resolves.toBe(false) + } finally { + unregisterSshPtyProvider(connectionId) + } + }) + it('answers unknown for remote-scoped ids without consulting local providers', async () => { // Why: a locally routed provider would answer confidently — and wrongly — // for a PTY that lives on a remote Orca host. diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index 6feb56dba56..6b191c5c0a8 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -26,7 +26,6 @@ const { mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn() }, diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index cc9081ce2a2..74797fa3d6a 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -31,7 +31,6 @@ const { mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn(), getSettings: vi.fn() diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index 0ff8b48c773..834c9e97e92 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -343,7 +343,7 @@ describe('repos:add + repos:clone', () => { }) ).rejects.toThrow('Imported folder does not match the selected project identity.') - expect(mockStore.removeProject).toHaveBeenCalledWith(added[0]?.id) + expect(mockStore.removeProjectForHost).toHaveBeenCalledWith(added[0]?.id, 'local') expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() }) diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index 7f7c3a3976d..ce658981574 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -51,7 +51,6 @@ describe('repos folder pickers', () => { const mockStore = { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn() } diff --git a/src/main/ipc/repos-remote-client-events.test.ts b/src/main/ipc/repos-remote-client-events.test.ts index adf2cc2f17b..fbd3197a805 100644 --- a/src/main/ipc/repos-remote-client-events.test.ts +++ b/src/main/ipc/repos-remote-client-events.test.ts @@ -15,7 +15,6 @@ const { handleMock, mockStore } = vi.hoisted(() => ({ getRepo: vi.fn(), addRepo: vi.fn(), updateRepo: vi.fn(), - removeProject: vi.fn(), removeProjectForHost: vi.fn(), reorderRepos: vi.fn().mockReturnValue(true) } @@ -69,23 +68,12 @@ beforeEach(() => { handlers.set(channel, handler) }) mainWindow.webContents.send.mockReset() - mockStore.removeProject.mockReset() mockStore.removeProjectForHost.mockReset() mockStore.reorderRepos.mockReset().mockReturnValue(true) }) describe('repo IPC mutations notify paired clients', () => { - it('broadcasts once for repos:remove and still notifies the local renderer', async () => { - const notify = vi.fn() - await registerHandlersWithNotifier(notify) - - await handlers.get('repos:remove')!(null, { repoId: 'repo-1' }) - - expect(notify).toHaveBeenCalledTimes(1) - expect(mainWindow.webContents.send).toHaveBeenCalledWith('repos:changed') - }) - - it('broadcasts once for repos:removeForHost', async () => { + it('broadcasts once for repos:removeForHost and still notifies the local renderer', async () => { const notify = vi.fn() await registerHandlersWithNotifier(notify) @@ -93,6 +81,7 @@ describe('repo IPC mutations notify paired clients', () => { expect(mockStore.removeProjectForHost).toHaveBeenCalledWith('repo-1', 'ssh:host-1') expect(notify).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('repos:changed') }) it('broadcasts for non-removal mutations too, via the shared helper', async () => { @@ -107,7 +96,9 @@ describe('repo IPC mutations notify paired clients', () => { it('does not throw when no notifier has been set', async () => { await registerHandlersWithoutNotifier() - await expect(handlers.get('repos:remove')!(null, { repoId: 'repo-1' })).resolves.toBeUndefined() + await expect( + handlers.get('repos:removeForHost')!(null, { repoId: 'repo-1', hostId: 'local' }) + ).resolves.toBeUndefined() expect(mainWindow.webContents.send).toHaveBeenCalledWith('repos:changed') }) @@ -116,6 +107,8 @@ describe('repo IPC mutations notify paired clients', () => { throw new Error('client event stream exploded') }) - await expect(handlers.get('repos:remove')!(null, { repoId: 'repo-1' })).resolves.toBeUndefined() + await expect( + handlers.get('repos:removeForHost')!(null, { repoId: 'repo-1', hostId: 'local' }) + ).resolves.toBeUndefined() }) }) diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 3e7cfe783c2..183e3a4ab7b 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -15,7 +15,7 @@ export type ReposIpcMocks = { mockStore: Record< | 'getRepos' | 'addRepo' - | 'removeProject' + | 'removeProjectForHost' | 'getRepo' | 'getProjects' | 'getProjectHostSetups' @@ -52,7 +52,7 @@ export function createReposIpcMocks(): ReposIpcMocks { mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), + removeProjectForHost: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn(), getProjects: vi.fn().mockReturnValue([]), diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 95388193985..dfe9dcf46f5 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -59,7 +59,6 @@ describe('repos:addRemote', () => { captureHandlers(handleMock) mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.addRepo.mockReset() - mockStore.removeProject.mockReset() mockStore.getSshTarget.mockReset() mockStore.updateRepo.mockReset() mockGitProvider.isGitRepoAsync.mockReset() diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index 31843858c05..df660555c0d 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -8,7 +8,6 @@ const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({ mockStore: { getRepos: vi.fn().mockReturnValue([]), addRepo: vi.fn(), - removeProject: vi.fn(), getRepo: vi.fn(), updateRepo: vi.fn(), getSparsePresets: vi.fn(), diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 3c2754507b7..9e7a4175c22 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -24,7 +24,6 @@ export function registerRepoHandlers( ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:listForExecutionHost') ipcMain.removeHandler('repos:add') - ipcMain.removeHandler('repos:remove') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:reorderForHost') diff --git a/src/main/ipc/repos/project-host-setup-handlers.ts b/src/main/ipc/repos/project-host-setup-handlers.ts index 3feb101324c..8898d5ec07a 100644 --- a/src/main/ipc/repos/project-host-setup-handlers.ts +++ b/src/main/ipc/repos/project-host-setup-handlers.ts @@ -14,7 +14,7 @@ import type { } from '../../../shared/project-types' import { getProjectIdForProviderIdentity } from '../../../shared/project-host-setup-projection' import { getProjectHostSetupForRepo } from '../../../shared/project-host-setup-lookup' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' import { emitRepoAdded } from './repo-added-telemetry' @@ -177,7 +177,7 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor } catch (err) { // Why: an import that cannot be linked must not leave a new repo registration or authorization root behind. if (!result.alreadyExisted) { - store.removeProject(result.repo.id) + store.removeProjectForHost(result.repo.id, getRepoExecutionHostId(result.repo)) invalidateAuthorizedRootsCache() } throw err diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index 825daef9850..608d9105f19 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -86,12 +86,6 @@ export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: St } ) - ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { - store.removeProject(args.repoId) - invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) - }) - // Why: forget a project on one execution host without disturbing the same repo id on other hosts (SSH-workspace forget flow). ipcMain.handle( 'repos:removeForHost', diff --git a/src/main/ipc/ssh-host-node-runtime-connect.test.ts b/src/main/ipc/ssh-host-node-runtime-connect.test.ts new file mode 100644 index 00000000000..b2c7941eb66 --- /dev/null +++ b/src/main/ipc/ssh-host-node-runtime-connect.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = await vi.hoisted(async () => { + const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') + return createSshIpcMocks() +}) + +vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) +vi.mock('electron', () => mocks.electron) +vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) +vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) +vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) +vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) +vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) +vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) +vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) +vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) +vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) +vi.mock('./pty', () => mocks.pty) +vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) +vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) +vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) +vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) +vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) + +import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' +import { recordSshRelayRuntimeStep } from '../ssh/ssh-host-node-runtime-mode' +import { setSshHostServerStatus, clearSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { getPublicSshState } from './ssh-renderer-broadcast' +import { createSshIpcHarness } from './ssh-ipc-test-harness' + +const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks + +const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy', + remoteRuntime: 'legacy' +} + +function lastConnectedState(send: ReturnType): SshConnectionState | undefined { + return ( + send.mock.calls + .filter(([channel]) => channel === 'ssh:state-changed') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh:state-changed always carries { targetId, state }. + .map(([, payload]) => (payload as { state: SshConnectionState }).state) + .findLast((state) => state.status === 'connected') + ) +} + +describe('ssh:connect on the opt-in Host Node runtime', () => { + const harness = createSshIpcHarness(mocks) + const { handlers, mockWindow } = harness + + beforeEach(async () => { + await harness.reset() + recordSshRelayRuntimeStep('ssh-1', false) + clearSshHostServerStatus('ssh-1') + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + }) + + it('publishes the connection as running the unsupported Host Node runtime', async () => { + mockDeployAndLaunchRelay.mockImplementationOnce(async () => { + recordSshRelayRuntimeStep('ssh-1', true) + return harness.createRelayLaunchResult() + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + expect(lastConnectedState(mockWindow.webContents.send)?.hostNodeRuntime).toBe(true) + }) + + it('stays quiet on a ladder connect and once the host moved to its managed server', async () => { + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + expect(lastConnectedState(mockWindow.webContents.send)).not.toHaveProperty('hostNodeRuntime') + + recordSshRelayRuntimeStep('ssh-1', true) + setSshHostServerStatus('ssh-1', { + kind: 'managed', + environmentId: 'env-1' + }) + expect(getPublicSshState('ssh-1')).not.toHaveProperty('hostNodeRuntime') + }) +}) diff --git a/src/main/ipc/ssh-plain-ssh-connect.test.ts b/src/main/ipc/ssh-plain-ssh-connect.test.ts index 974eb2eab18..65583ca681c 100644 --- a/src/main/ipc/ssh-plain-ssh-connect.test.ts +++ b/src/main/ipc/ssh-plain-ssh-connect.test.ts @@ -33,9 +33,9 @@ import { createSshIpcHarness } from './ssh-ipc-test-harness' const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks function noexecHome(): RemoteRuntimeUnavailableError { - const run = new RelayRuntimeLadderRun('ssh-1', null) + const run = new RelayRuntimeLadderRun('ssh-1', null, true) run.refused('A', 'noexec') - return new RemoteRuntimeUnavailableError('home_noexec', run) + return new RemoteRuntimeUnavailableError(run) } function connectedStates(send: ReturnType): SshConnectionState[] { diff --git a/src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts b/src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts new file mode 100644 index 00000000000..4a25988bd43 --- /dev/null +++ b/src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createSshPtyOutputIntakeHarness as createHarness, + sshPtyOutputEvent as event +} from './ssh-pty-output-intake-test-harness' +import { SSH_PTY_ACK_FLUSH_MS } from './ssh-pty-source-ack-coalescer' + +const EXIT_EVENT = { id: 'pty-1', code: -1, providerGeneration: 1, ptyIncarnation: 'incarnation-1' } + +// #24367: a canceled recovery delivery left a published projection whose span the ledger had +// already released, so every later renderer ACK threw and the next delivery was never credited. +describe('SshPtyOutputIntake recovery cancellation', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps crediting the next delivery after a recovery cancellation reclaims a published span', async () => { + vi.useFakeTimers() + const { harness, credited } = await cancelPublishedRecoverySpan() + const next = harness.intake.acceptData( + event({ + data: 'bbbb', + source: { + spanId: 'next-span', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'next-token', + sourceStartSu: 0, + sourceEndSu: 4 + } + }) + ) + harness.completions[1]!.resolve() + const nextReceipt = await next + harness.intake.publishProjectionPrefix( + [nextReceipt.projection.identity.projectionSemanticsId], + 4, + 4 + ) + + // The renderer parses the canceled span first; that ACK is owed to nobody. + expect(() => harness.intake.settleProjectionPrefix('pty-1', 4)).not.toThrow() + await vi.advanceTimersByTimeAsync(SSH_PTY_ACK_FLUSH_MS) + expect(credited).toEqual([]) + + expect(() => harness.intake.settleProjectionPrefix('pty-1', 4)).not.toThrow() + await vi.advanceTimersByTimeAsync(SSH_PTY_ACK_FLUSH_MS) + expect(credited).toEqual(['next-token@4']) + expect(harness.intake.getDebugSnapshot().projection.records).toBe(0) + }) + + it('transfers a canceled span without throwing when the renderer drops it', async () => { + vi.useFakeTimers() + const { harness } = await cancelPublishedRecoverySpan() + + expect(() => harness.intake.transferPtyProjections('pty-1', 'renderer-destroyed')).not.toThrow() + expect(harness.intake.getDebugSnapshot().projection.records).toBe(0) + }) + + it('closes the generation while a canceled span is still unACKed', async () => { + vi.useFakeTimers() + const { harness, credited } = await cancelPublishedRecoverySpan() + + expect(() => harness.intake.closeGeneration(1, 'provider-closed')).not.toThrow() + await vi.advanceTimersByTimeAsync(SSH_PTY_ACK_FLUSH_MS) + + expect(harness.intake.getDebugSnapshot()).toMatchObject({ + projection: { records: 0 }, + source: { openedTokens: 0, ptyIdentities: 0 } + }) + expect(credited).toEqual([]) + }) + + it('accepts a recovery span whose model admission finishes after the cancellation', async () => { + const harness = createHarness() + const recovered = harness.intake.acceptData(event({ source: recoverySource() })) + await vi.waitFor(() => expect(harness.completions).toHaveLength(1)) + harness.intake.applySourceRecoveryCancellationProof(EXIT_EVENT, { + sentEndSu: 8, + creditedEndSu: 4 + }) + harness.completions[0]!.resolve() + + await expect(recovered).resolves.toMatchObject({ sequence: 4 }) + }) +}) + +async function cancelPublishedRecoverySpan() { + const credited: string[] = [] + const harness = createHarness({ + publishSourceAck: (_providerGeneration, batch, onSettled) => { + for (const ack of batch.acknowledgements) { + credited.push(`${ack.deliveryToken}@${ack.creditedEndSu}`) + } + onSettled({ ok: true }) + } + }) + const recovered = harness.intake.acceptData(event({ source: recoverySource() })) + harness.completions[0]!.resolve() + const receipt = await recovered + // Sent to the renderer, not yet parsed. + harness.intake.publishProjectionPrefix([receipt.projection.identity.projectionSemanticsId], 4, 4) + harness.intake.applySourceRecoveryCancellationProof(EXIT_EVENT, { + sentEndSu: 8, + creditedEndSu: 4 + }) + return { harness, credited } +} + +function recoverySource() { + return { + spanId: 'recovery-span', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'recovery-token', + sourceStartSu: 4, + sourceEndSu: 8 + } +} diff --git a/src/main/ipc/ssh-pty-output-source-obligations.ts b/src/main/ipc/ssh-pty-output-source-obligations.ts index 410b8c05644..79864e79cb5 100644 --- a/src/main/ipc/ssh-pty-output-source-obligations.ts +++ b/src/main/ipc/ssh-pty-output-source-obligations.ts @@ -14,8 +14,14 @@ import type { SshPtySourceCancellationRequest } from './ssh-pty-output-intake-contract' import { SshPtyRemoteSourceRangeConsumers } from './ssh-pty-remote-source-range-consumers' -import type { SshPtySourceAdmissionReservation } from './ssh-pty-source-obligation-contract' -import { SshPtySourceObligationCoordinator } from './ssh-pty-source-obligation-coordinator' +import type { + SshPtySourceAdmissionReservation, + SshPtySourceConsumerId +} from './ssh-pty-source-obligation-contract' +import { + SshPtySourceObligationCoordinator, + type SshPtySourceObligationTransition +} from './ssh-pty-source-obligation-coordinator' export type SshPtyOutputSourceReservation = Readonly<{ admission: SshPtySourceAdmissionReservation @@ -107,31 +113,22 @@ export class SshPtyOutputSourceObligations { } settleModel(span: PtySourceSpan): void { - this.coordinator.settle({ - identity: span, - spanId: span.spanId, - consumer: 'model', - reason: 'model-accepted' - }) + const transition = this.openTransition(span, 'model', 'model-accepted') + if (transition) { + this.coordinator.settle(transition) + } } settleDesktop(span: DesktopProjectionSpan, reason: string): void { - this.coordinator.settle({ - identity: span, - spanId: span.spanId, - consumer: 'desktop', - reason - }) + const transition = this.openTransition(span, 'desktop', reason) + if (transition) { + this.coordinator.settle(transition) + } } transferDesktop(span: DesktopProjectionSpan, reason: string): void { - const transition = { - identity: span, - spanId: span.spanId, - consumer: 'desktop' as const, - reason - } - if (this.coordinator.beginTransfer(transition, 'model')) { + const transition = this.openTransition(span, 'desktop', reason) + if (transition && this.coordinator.beginTransfer(transition, 'model')) { this.coordinator.commitTransfer(transition) } } @@ -288,6 +285,24 @@ export class SshPtyOutputSourceObligations { return `${event.providerGeneration}\0${event.id}\0${event.ptyIncarnation}` } + // Why: a recovery cancellation closes a delivery whose spans the model or renderer may still + // settle. Nothing is owed to a closed delivery, and a throw would fail every later ACK. + private openTransition( + span: PtySourceDeliveryIdentity & Readonly<{ spanId: string }>, + consumer: SshPtySourceConsumerId, + reason: string + ): SshPtySourceObligationTransition | null { + return this.isDeliveryOpen(span) + ? { identity: span, spanId: span.spanId, consumer, reason } + : null + } + + private isDeliveryOpen( + span: Pick + ): boolean { + return this.openedTokens.has(ptySourceDeliveryKey(span)) + } + private removeIdentity(identity: PtySourceDeliveryIdentity): void { this.openedTokens.delete(ptySourceDeliveryKey(identity)) for (const [key, record] of this.identityByPty) { diff --git a/src/main/ipc/ssh-pty-source-ack-session-contract.test.ts b/src/main/ipc/ssh-pty-source-ack-session-contract.test.ts index 9ee6f90c5a3..aa968e78aa3 100644 --- a/src/main/ipc/ssh-pty-source-ack-session-contract.test.ts +++ b/src/main/ipc/ssh-pty-source-ack-session-contract.test.ts @@ -83,4 +83,79 @@ describe('SSH PTY intake to relay ACK contract', () => { cleanup() } }) + + it('withholds source credit until the main terminal model finishes the chunk', async () => { + vi.useFakeTimers() + const batches: PtySourceCreditAckBatch[] = [] + const cleanup = installSshPtySourceAckPublisher(8, (batch, onSettled) => { + batches.push(batch) + onSettled({ ok: true }) + }) + let finishModel: () => void = () => {} + const projectionIds: string[] = [] + let sequence = 0 + const intake = new SshPtyOutputIntake({ + getModelSequence: () => sequence, + acceptModel: (event) => { + sequence += event.rawLength + return { + sequence, + completion: new Promise((resolve) => { + finishModel = resolve + }) + } + }, + project: (_event, projection) => { + projectionIds.push(projection.identity.projectionSemanticsId) + }, + prepareExit: () => {}, + finalizeExit: () => {}, + publishSourceAck: publishSshPtySourceAck + }) + + try { + const receipt = intake.acceptData({ + id: 'ssh:target@@relay-pty-2', + data: 'frame', + providerGeneration: 8, + ptyIncarnation: 'incarnation-2', + rawLength: 5, + transformed: false, + source: { + relayPtyId: 'relay-pty-2', + spanId: 'token-2:0:5', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-2', + sourceStartSu: 0, + sourceEndSu: 5 + } + }) + // The renderer drains its copy first; a slow main model must still hold the remote credit. + intake.publishProjectionPrefix(projectionIds, 5, 5) + intake.settleProjectionPrefix('ssh:target@@relay-pty-2', 5) + await vi.advanceTimersByTimeAsync(8) + expect(batches).toHaveLength(0) + + finishModel() + await receipt + await vi.advanceTimersByTimeAsync(8) + expect(batches).toEqual([ + { + acknowledgements: [ + { + id: 'relay-pty-2', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-2', + creditedEndSu: 5 + } + ] + } + ]) + } finally { + intake.dispose() + cleanup() + } + }) }) diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 112ad70d6ff..81646454320 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -15,6 +15,7 @@ import { import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' +import { isSshRelayOnHostNodeRuntime } from '../ssh/ssh-host-node-runtime-mode' import { clearSshHostServerStatus, getSshHostServerStatus } from '../ssh/ssh-host-server-status' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { activeSessions } from './ssh-active-relay-sessions' @@ -53,6 +54,12 @@ function withSshRemotePlatform(targetId: string, state: SshConnectionState): Ssh const authority = getSshProviderAuthority(targetId) const plainSsh = state.status === 'connected' ? getSshPlainSshMode(targetId) : undefined const managedServer = state.managedServer ?? getSshHostServerStatus(targetId) + // Why the managed check: a host that moved to its managed server no longer runs that relay. + const hostNodeRuntime = + state.status === 'connected' && + !plainSsh && + managedServer?.kind !== 'managed' && + isSshRelayOnHostNodeRuntime(targetId) return { ...state, targetId, @@ -60,6 +67,7 @@ function withSshRemotePlatform(targetId: string, state: SshConnectionState): Ssh connectionGeneration: authority.connectionGeneration, ...(remotePlatform ? { remotePlatform } : {}), ...(plainSsh ? { plainSsh } : {}), + ...(hostNodeRuntime ? { hostNodeRuntime } : {}), ...(managedServer ? { managedServer } : {}) } } diff --git a/src/main/ipc/ssh-session-teardown.ts b/src/main/ipc/ssh-session-teardown.ts index e8e46f32cb6..e76e19fc2fd 100644 --- a/src/main/ipc/ssh-session-teardown.ts +++ b/src/main/ipc/ssh-session-teardown.ts @@ -3,6 +3,7 @@ import type { SshConnection } from '../ssh/ssh-connection' import type { SshRelaySession } from '../ssh/ssh-relay-session' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { clearSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { recordSshRelayRuntimeStep } from '../ssh/ssh-host-node-runtime-mode' import { isSshConnectionSolelyOwnedBy } from '../ssh/ssh-connection-attribution' import { activeSessions } from './ssh-active-relay-sessions' import { @@ -30,6 +31,7 @@ export async function removeRegisteredSshTarget(targetId: string): Promise } invalidateConnectAttempt(targetId) clearSshHostServerStatus(targetId) + recordSshRelayRuntimeStep(targetId, false) await runTargetLifecycle(targetId, async () => { try { // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. diff --git a/src/main/menu/app-menu-quit-item.ts b/src/main/menu/app-menu-quit-item.ts new file mode 100644 index 00000000000..c31974e6144 --- /dev/null +++ b/src/main/menu/app-menu-quit-item.ts @@ -0,0 +1,23 @@ +import { translateMain } from '../i18n/main-i18n' + +/** + * The app menu's Quit entry: "Quit " in the macOS app menu, else File > Exit. + * Why a handler instead of role 'quit': a host that must not exit on a user Quit + * (`orca serve`, #15537) supplies `onQuit`; the native-quit guard only covers macOS. + */ +export function createAppMenuQuitItem( + onQuit: (() => void) | undefined, + macAppName?: string +): Electron.MenuItemConstructorOptions { + const exitLabel = macAppName === undefined ? translateMain('menu.exit', 'Exit') : undefined + if (!onQuit) { + return { role: 'quit', ...(exitLabel ? { label: exitLabel } : {}) } + } + return { + label: + exitLabel ?? translateMain('menu.quitApp', `Quit ${macAppName}`, { appName: macAppName }), + // Same accelerator Electron gives role 'quit' (none on Windows). + ...(process.platform === 'win32' ? {} : { accelerator: 'CmdOrCtrl+Q' }), + click: () => onQuit() + } +} diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index ee8bf6ccc08..5e10b24d10d 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -583,4 +583,34 @@ describe('registerAppMenu', () => { expect(appearanceSubmenu.find((item) => item.label === leftLabel)?.accelerator).toBeUndefined() expect(appearanceSubmenu.find((item) => item.label === rightLabel)?.accelerator).toBeUndefined() }) + + it.each(['darwin', 'linux', 'win32'] as const)( + 'keeps the native quit role unless the host supplies a quit handler (%s, #15537)', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const quitMenu = platform === 'darwin' ? 'Orca' : 'File' + const findQuit = (): Electron.MenuItemConstructorOptions | undefined => + getSubmenu(getTemplate(), quitMenu).find( + (item) => + item.role === 'quit' || item.accelerator === 'CmdOrCtrl+Q' || item.label === 'Exit' + ) + + registerAppMenu(buildMenuOptions()) + expect(findQuit()?.role).toBe('quit') + + buildFromTemplateMock.mockClear() + const onQuit = vi.fn() + registerAppMenu({ ...buildMenuOptions(), onQuit }) + const quit = findQuit() + expect(quit?.role).toBeUndefined() + expect(quit?.label).toBe(platform === 'darwin' ? 'Quit Orca' : 'Exit') + expect(quit?.accelerator).toBe(platform === 'win32' ? undefined : 'CmdOrCtrl+Q') + // Why untyped: the handler ignores its Electron arguments. + const click: unknown = quit?.click + if (typeof click === 'function') { + click() + } + expect(onQuit).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 3c8bb434c29..77c42b1c385 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -8,6 +8,7 @@ import { import type { UpdateCheckOptions } from '../../shared/update-status-types' import { translateMain } from '../i18n/main-i18n' import { createAppMenuSelectionItem } from './app-menu-selection-item' +import { createAppMenuQuitItem } from './app-menu-quit-item' import { createAppWindowMenu } from './app-menu-window' export type AppearanceMenuState = { @@ -42,6 +43,7 @@ type RegisterAppMenuOptions = { // Why: the macOS app-menu title. Passed the per-branch dev label since // app.name is now pinned to a stable value for Keychain-key stability. appMenuLabel?: string + onQuit?: () => void } function buildAndApplyMenu(options: RegisterAppMenuOptions): void { @@ -160,7 +162,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, - { role: 'quit' } + createAppMenuQuitItem(options.onQuit, options.appMenuLabel ?? app.name) ] } @@ -169,11 +171,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { // Why: on Windows/Linux there is no app-named menu, so Settings and // Quit live under File — matching the common platform convention and // keeping all user-facing actions reachable from the in-window menu bar. - submenu: [ - settingsItem, - { type: 'separator' }, - { role: 'quit', label: translateMain('menu.exit', 'Exit') } - ] + submenu: [settingsItem, { type: 'separator' }, createAppMenuQuitItem(options.onQuit)] } // Why: keep native menu hints while letting non-macOS Ctrl+Z/Ctrl+Y reach the focused terminal or DOM control. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 3ba5bbb612e..e20bd60b9eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -115,7 +115,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId, + carriedFrom: ctx.sessionId }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/orcad/orcad-command-arguments.ts b/src/main/orcad/orcad-command-arguments.ts index 8077b2cb898..346379f72b7 100644 --- a/src/main/orcad/orcad-command-arguments.ts +++ b/src/main/orcad/orcad-command-arguments.ts @@ -23,6 +23,8 @@ export function parseArgs(argv: string[]): OrcadOptions { options.noPairing = true } else if (arg === '--mobile-pairing') { options.mobilePairing = true + } else if (arg === '--grant-desktop-control') { + options.grantDesktopControl = true } else if (arg === '--recipe-json') { options.recipeJson = true } else if (arg === '--project-root') { @@ -50,6 +52,9 @@ export function parseArgs(argv: string[]): OrcadOptions { throw new Error(`Unknown argument: ${arg}`) } } + if (options.grantDesktopControl && (options.noPairing || options.mobilePairing)) { + throw new Error('--grant-desktop-control applies only to the default runtime pairing offer') + } if (options.recipeJson && !options.projectRoot) { throw new Error('--recipe-json requires --project-root') } diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index b89c813774f..55f6ace305f 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -104,6 +104,8 @@ export type OrcadOptions = { pairingAddress?: string /** Desktop `orca serve` parity: a mobile-scoped offer with a terminal QR. */ mobilePairing?: boolean + /** Lets the paired runtime client drive this machine's desktop (computer.*). */ + grantDesktopControl?: boolean /** Desktop `orca serve` parity: print only the ephemeral-VM recipe line. */ recipeJson?: boolean projectRoot?: string diff --git a/src/main/orcad/orcad-forked-child-paths.test.ts b/src/main/orcad/orcad-forked-child-paths.test.ts index 9f6f63fdc82..70fe1009744 100644 --- a/src/main/orcad/orcad-forked-child-paths.test.ts +++ b/src/main/orcad/orcad-forked-child-paths.test.ts @@ -4,6 +4,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { getAppEnvironment } from '../../shared/app-environment' import { getWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path' +import { + AI_VAULT_SERVICE_ENTRY_FILENAME, + getAiVaultServiceEntryPath +} from '../ai-vault/session-scanner-service-entry-path' import { FOREIGN_SQLITE_READER_ENTRY_FILENAME, resolveForeignSqliteReaderEntryPath @@ -26,6 +30,7 @@ describe('orcad forked-child paths', () => { writeFileSync(join(deployRoot, 'orcad.js'), '') writeFileSync(join(deployRoot, 'parcel-watcher-process-entry.js'), '') writeFileSync(join(deployRoot, FOREIGN_SQLITE_READER_ENTRY_FILENAME), '') + writeFileSync(join(deployRoot, AI_VAULT_SERVICE_ENTRY_FILENAME), '') originalArgv = process.argv process.argv = [process.execPath, join(deployRoot, 'orcad.js')] installOrcadHostAdapters() @@ -40,6 +45,11 @@ describe('orcad forked-child paths', () => { expect(getWatcherProcessEntryPath()).toBe(join(deployRoot, 'parcel-watcher-process-entry.js')) }) + it('forks the session scanner child shipped beside orcad.js', () => { + // orcad reports isPackaged with no asar, which used to send this lookup to out/main. + expect(getAiVaultServiceEntryPath()).toBe(join(deployRoot, AI_VAULT_SERVICE_ENTRY_FILENAME)) + }) + it('starts the foreign SQLite reader worker shipped beside orcad.js', () => { // orcad.js is one bundle, so its __dirname is the deploy root. expect(resolveForeignSqliteReaderEntryPath(currentWorkerEntryLayout(deployRoot))).toBe( diff --git a/src/main/orcad/orcad-local-serve-selection.test.ts b/src/main/orcad/orcad-local-serve-selection.test.ts index 9da11cfccf4..f6df27980c4 100644 --- a/src/main/orcad/orcad-local-serve-selection.test.ts +++ b/src/main/orcad/orcad-local-serve-selection.test.ts @@ -11,9 +11,19 @@ import { } from '../../shared/orcad-artifacts' import { formatOrcadNativePreflightReport } from '../../shared/orcad-native-preflight-report' import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../shared/orca-profiles' +import { PROFILE_STATE_DATABASE_FILE_NAME } from '../../shared/profile-state-storage-paths' +import Database from '../sqlite/sync-database' +import { + openProfileStateDatabase, + profileStatePragmaNumber +} from '../persistence/profile-state/profile-state-database' +import { hashProfileStatePayload } from '../persistence/profile-state/profile-state-document-validation' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' import { selectServeRuntime, type ServeRuntimeSelectionInput } from './orcad-local-serve-selection' const TARGET = 'linux-x64-glibc' +const SSH_TARGET = { id: 'ssh-1', label: 'box', host: 'box.example', port: 22, username: 'me' } const SHA = NODE_RUNTIME_ASSETS[TARGET].executableSha256 let root = '' @@ -37,6 +47,47 @@ function slotFixture(): string { return slot } +/** A valid schema-1 profile, as an Orca from before the normalized run tables left it. */ +function seedSchemaV1Profile(dbPath: string, state: Record): void { + const db = new Database(dbPath) + db.exec(` + PRAGMA user_version = 1; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + `) + db.prepare('INSERT INTO profile_state_meta (key, value) VALUES (?, ?)').run( + 'profile_id', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + db.prepare('INSERT INTO profile_state_meta (key, value) VALUES (?, ?)').run('revision', '1') + const insert = db.prepare( + `INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, 1, 1, 100, ?)` + ) + for (const [domain, value] of Object.entries(state)) { + const payload = JSON.stringify(value) + insert.run(domain, payload, hashProfileStatePayload(payload)) + } + db.close() +} + +function schemaVersion(dbPath: string): number { + const db = new Database(dbPath, { readonly: true }) + try { + return profileStatePragmaNumber(db, 'user_version') + } finally { + db.close() + } +} + function input(overrides: Partial = {}): ServeRuntimeSelectionInput { const template = join(root, 'orcad-template') mkdirSync(template, { recursive: true }) @@ -70,6 +121,68 @@ describe('orca serve runtime selection', () => { } }) + it('stays on Electron by default when the profile has SSH targets orcad cannot serve (#25886)', async () => { + const options = input({ profileHasSshTargets: () => true }) + expect(await selectServeRuntime(options)).toEqual({ + kind: 'electron', + reason: 'this profile has SSH targets, which orcad cannot serve yet' + }) + expect(options.materializeSlot).not.toHaveBeenCalled() + }) + + it('keeps orcad for SSH profiles when orcad is asked for by name', async () => { + const named = input({ env: { [SERVE_RUNTIME_ENV]: 'orcad' }, profileHasSshTargets: () => true }) + expect(await selectServeRuntime(named)).toMatchObject({ kind: 'orcad' }) + }) + + it('stays on Electron when it cannot tell whether the profile has SSH targets', async () => { + const unreadable = input({ + profileHasSshTargets: () => { + throw new Error('locked') + } + }) + expect(await selectServeRuntime(unreadable)).toEqual({ + kind: 'electron', + reason: + 'could not tell whether this profile has SSH targets, which orcad cannot serve yet (locked)' + }) + expect(unreadable.materializeSlot).not.toHaveBeenCalled() + }) + + it.each([ + ['with', [SSH_TARGET], 'electron'], + ['without', [], 'orcad'] + ] as const)( + 'stays on Electron, without migrating, for a schema-1 profile %s SSH targets', + async (_label, targets, afterMigration) => { + const dbPath = join(root, PROFILE_STATE_DATABASE_FILE_NAME) + seedSchemaV1Profile(dbPath, { sshTargets: targets }) + expect(await selectServeRuntime(input())).toMatchObject({ + kind: 'electron', + reason: expect.stringContaining('could not tell whether this profile has SSH targets') + }) + expect(schemaVersion(dbPath)).toBe(1) + // The serve host's own open migrates it; selection then reads the same targets. + openProfileStateDatabase(dbPath, DEFAULT_LOCAL_ORCA_PROFILE_ID).db.close() + expect(await selectServeRuntime(input())).toMatchObject({ kind: afterMigration }) + } + ) + + it('serves on orcad for a current-schema profile with no SSH targets', async () => { + const opened = openProfileStateDatabase( + join(root, PROFILE_STATE_DATABASE_FILE_NAME), + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + importProfileStateJson(opened.db, JSON.stringify({ sshTargets: [], settings: {} })) + opened.db.close() + expect(await selectServeRuntime(input())).toMatchObject({ kind: 'orcad' }) + }) + + it('reads SSH targets from the profile on disk', async () => { + writeFileSync(join(root, 'orca-data.json'), JSON.stringify({ sshTargets: [{ id: 'ssh-1' }] })) + expect(await selectServeRuntime(input())).toMatchObject({ kind: 'electron' }) + }) + it('runs the local slot on its pinned Node, linked into userData beside it', async () => { const options = input() const selection = await selectServeRuntime(options) diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts index 2d8f51769fe..49e5f2d2bc8 100644 --- a/src/main/orcad/orcad-local-serve-selection.ts +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -31,6 +31,7 @@ import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime' import { detectNativeHostAbi, nativeSlotName } from './native-host-abi' import { materializeOrcadArtifact } from '../ssh/orcad-artifact-materializer' import { materializeCachedNodeRuntime } from '../ssh/pinned-runtime-materializer' +import { serveProfileHasSshTargets } from './serve-profile-ssh-targets' const NATIVE_PREFLIGHT_TIMEOUT_MS = 30_000 @@ -43,6 +44,7 @@ export type ServeRuntimeSelectionInput = { materializeSlot?: typeof materializeOrcadArtifact materializeRuntime?: typeof materializeCachedNodeRuntime nativePreflight?: (runtime: string, entry: string) => Promise + profileHasSshTargets?: (userDataPath: string) => boolean } function isServerTarget(value: string): value is ServerTarget { @@ -63,6 +65,12 @@ export async function selectServeRuntime( if (requested && requested !== 'orcad') { return electron(`${SERVE_RUNTIME_ENV}=${requested} is neither orcad nor electron`) } + // Why: orcad has no SSH connection stack yet, so a default serve on it would list no targets + // and fail every connect (#25886, #8489). An explicit ORCA_SERVE_RUNTIME=orcad keeps orcad. + const sshReason = requested ? null : profileSshReason(input) + if (sshReason) { + return electron(sshReason) + } const target = (input.hostTarget ?? (() => nativeSlotName(detectNativeHostAbi())))() if (!isServerTarget(target)) { return electron(`no orcad build exists for this host (${target})`) @@ -111,6 +119,19 @@ export async function selectServeRuntime( } } +/** Why this profile must serve on Electron for SSH, or null when it provably has no targets. */ +function profileSshReason(input: ServeRuntimeSelectionInput): string | null { + try { + return (input.profileHasSshTargets ?? serveProfileHasSshTargets)(input.userDataPath) + ? 'this profile has SSH targets, which orcad cannot serve yet' + : null + } catch (error) { + // Why: fail closed. The read-only probe rejects profiles the serve host would still migrate + // (an older schema), and guessing "no SSH" there leaves saved targets unreachable. + return `could not tell whether this profile has SSH targets, which orcad cannot serve yet (${errorText(error)})` + } +} + /** The slot references its runtime beside it; the cached pinned Node is linked into place. */ async function placeSlotRuntime( slotDir: string, diff --git a/src/main/orcad/orcad-runtime-entry-artifacts.test.ts b/src/main/orcad/orcad-runtime-entry-artifacts.test.ts index 17c85a8e9ec..b9421483294 100644 --- a/src/main/orcad/orcad-runtime-entry-artifacts.test.ts +++ b/src/main/orcad/orcad-runtime-entry-artifacts.test.ts @@ -16,10 +16,7 @@ const RUNTIME_ENTRY_FILENAME = /[A-Za-z0-9_.-]+-(?:entry|worker)\.c?js/g * Named in orcad's bundle but not shipped yet. Each is a known gap, not an exemption: shipping * one removes it here, and this list may only shrink. */ -const KNOWN_UNSHIPPED_ENTRIES = new Set([ - 'session-scanner-service-entry.js', - 'wsl-transcript-fs-process-entry.js' -]) +const KNOWN_UNSHIPPED_ENTRIES = new Set(['wsl-transcript-fs-process-entry.js']) const directory = mkdtempSync(join(tmpdir(), 'orcad-runtime-entries-')) afterAll(() => { @@ -31,13 +28,13 @@ function buildOrcadBundles(): string[] { const builder = pathToFileURL(join(REPO_ROOT, 'config/scripts/orcad-entry-build.mjs')).href const script = ` import { build } from 'esbuild' - import { basename, join } from 'node:path' + import { join } from 'node:path' import * as entries from ${JSON.stringify(builder)} const out = ${JSON.stringify(directory)} await entries.buildOrcadEntry(join(out, 'orcad.js')) await Promise.all(Object.values(entries.ORCAD_CHILD_ENTRY_POINTS).map((entry) => build({ entryPoints: [entry], bundle: true, platform: 'node', target: 'node18', format: 'cjs', - outfile: join(out, basename(entry).replace(/\\.ts$/, '.js')), + outfile: join(out, entries.orcadChildOutputFilename(entry)), external: entries.ORCAD_EXTERNAL_MODULES, plugins: [entries.externalNativeAddons], logLevel: 'error' })))` @@ -66,3 +63,14 @@ it('ships every worker and child entry orcad loads at runtime', () => { expect(named.has(filename) && !shipped.has(filename), filename).toBe(true) } }) + +it('ships every child entry build-orcad.mjs builds', async () => { + const builder = pathToFileURL(join(REPO_ROOT, 'config/scripts/orcad-entry-build.mjs')).href + const entries = await import(builder) + const shipped = new Set(orcadArtifactFilenames('linux-x64-glibc')) + const built = Object.values(entries.ORCAD_CHILD_ENTRY_POINTS).map((entry) => + entries.orcadChildOutputFilename(entry) + ) + expect(built).toContain('session-scanner-service-entry.js') + expect(built.filter((filename) => !shipped.has(filename))).toEqual([]) +}) diff --git a/src/main/orcad/orcad-serve-readiness.ts b/src/main/orcad/orcad-serve-readiness.ts index 2148f02291a..92b36fa0191 100644 --- a/src/main/orcad/orcad-serve-readiness.ts +++ b/src/main/orcad/orcad-serve-readiness.ts @@ -26,7 +26,8 @@ export async function buildOrcadServeReadiness(input: { : rpc.createPairingOffer({ address: options.pairingAddress, name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, - scope: options.mobilePairing ? 'mobile' : 'runtime' + scope: options.mobilePairing ? 'mobile' : 'runtime', + grants: options.grantDesktopControl ? ['desktop-control'] : [] }) return { diff --git a/src/main/orcad/orcad-session-search.ts b/src/main/orcad/orcad-session-search.ts index 32d32272066..66468f38df0 100644 --- a/src/main/orcad/orcad-session-search.ts +++ b/src/main/orcad/orcad-session-search.ts @@ -8,9 +8,8 @@ import { installInProcessSessionSearchService } from '../ai-vault-search/session /** * orcad's session search registration. * - * In this process and not a scanner child: orcad ships only the watcher and the - * daemon entries beside `orcad.js`, so there is no scanner-service child here to - * own the index — and this process is the sole writer, so nothing can race it. + * In this process and not the scanner child: orcad never installs the search data + * root, so its scanner child gets no index and this process is the sole writer. * Null on a host whose Node has no `node:sqlite`, which is orcad's stated floor. */ export async function installOrcadSessionSearchService(args: { diff --git a/src/main/orcad/serve-profile-ssh-targets.test.ts b/src/main/orcad/serve-profile-ssh-targets.test.ts new file mode 100644 index 00000000000..fb018ac9ac1 --- /dev/null +++ b/src/main/orcad/serve-profile-ssh-targets.test.ts @@ -0,0 +1,72 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile +} from '../../shared/profile-state-storage-paths' +import { serveProfileHasSshTargets } from './serve-profile-ssh-targets' + +const TARGET = { id: 'ssh-1', label: 'box', host: 'box.example', port: 22, username: 'me' } +let userData = '' + +beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-serve-ssh-targets-')) +}) + +afterEach(() => { + rmSync(userData, { recursive: true, force: true }) +}) + +function writeIndex(profileId: string): void { + writeFileSync( + join(userData, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) +} + +function writeSqliteProfile(profileId: string, state: Record): void { + mkdirSync(join(userData, 'profiles', profileId), { recursive: true }) + const opened = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile(profileId, userData), + profileId + ) + importProfileStateJson(opened.db, JSON.stringify(state)) + opened.db.close() +} + +describe('serveProfileHasSshTargets', () => { + it('is false for a fresh install', () => { + expect(serveProfileHasSshTargets(userData)).toBe(false) + }) + + it('reads the active SQLite profile', () => { + writeIndex('work') + writeSqliteProfile('work', { sshTargets: [TARGET] }) + expect(serveProfileHasSshTargets(userData)).toBe(true) + }) + + it('is false for an active SQLite profile with no targets', () => { + writeIndex('work') + writeSqliteProfile('work', { sshTargets: [], settings: {} }) + expect(serveProfileHasSshTargets(userData)).toBe(false) + }) + + it('reads an active JSON-only profile', () => { + writeIndex('work') + mkdirSync(join(userData, 'profiles', 'work'), { recursive: true }) + writeFileSync( + getOrcaProfileDataFile('work', userData), + JSON.stringify({ sshTargets: [TARGET] }) + ) + expect(serveProfileHasSshTargets(userData)).toBe(true) + }) + + it('reads the legacy root profile when there is no profile index', () => { + writeFileSync(join(userData, 'orca-data.json'), JSON.stringify({ sshTargets: [TARGET] })) + expect(serveProfileHasSshTargets(userData)).toBe(true) + }) +}) diff --git a/src/main/orcad/serve-profile-ssh-targets.ts b/src/main/orcad/serve-profile-ssh-targets.ts new file mode 100644 index 00000000000..0f75c3a2d2a --- /dev/null +++ b/src/main/orcad/serve-profile-ssh-targets.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../shared/orca-profiles' +import { PROFILE_STATE_DATABASE_FILE_NAME } from '../../shared/profile-state-storage-paths' +import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location' +import { readProfileStateDomain } from '../persistence/profile-state/profile-state-domain-reader' +import { classifyProfileStateStorage } from '../persistence/profile-state/profile-state-storage-classification' + +/** + * Whether the profile `orca serve` would load has saved SSH targets. Read-only: never creates, + * migrates or locks the profile, because the serve host that starts next owns it. Throws when it + * cannot tell, including for an older schema the serve host would still migrate. + */ +export function serveProfileHasSshTargets(userDataPath: string): boolean { + // A profile index predates any profile-state import, so no index means the legacy root files. + const location = getActiveProfileStateLocation(userDataPath) ?? { + dataFile: join(userDataPath, 'orca-data.json'), + databaseFile: join(userDataPath, PROFILE_STATE_DATABASE_FILE_NAME), + profileId: DEFAULT_LOCAL_ORCA_PROFILE_ID + } + const storage = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (storage === 'neither') { + return false + } + if (storage === 'json-only') { + const parsed: unknown = JSON.parse(readFileSync(location.dataFile, 'utf8')) + return hasEntries(isRecord(parsed) ? parsed.sshTargets : undefined) + } + const result = readProfileStateDomain(location.databaseFile, location.profileId, 'sshTargets') + if (result.kind === 'unreadable') { + throw result.error + } + return result.kind === 'value' && hasEntries(result.value) +} + +function hasEntries(value: unknown): boolean { + return Array.isArray(value) && value.length > 0 +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index efc5abc1476..69a00dc96fa 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -6,9 +6,9 @@ import { } from './persistence-test-harness' /** * The same repo id may be registered on two execution hosts (see `removeProjectForHost`). - * Every deletion that resolves a *row* must therefore delete only that row: `removeProject` - * is id-only and would take the sibling host's registration with it. Since #11994 those - * deletions fan out to every paired device, so a cross-host over-delete is no longer local. + * Every deletion that resolves a *row* must therefore delete only that row, never the sibling + * host's registration. Since #11994 those deletions fan out to every paired device, so a + * cross-host over-delete is no longer local. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -123,6 +123,23 @@ describe('deleting one host copy of a repo id shared by two hosts', () => { expect(store.getRepos().map((repo) => repo.path)).toEqual(['/laptop/dup', '/remote/dup']) }) + it.each(['git', 'folder'] as const)( + 'removing the local %s row keeps the other host row and its metadata', + async (kind) => { + // A stale renderer catalog once sent id-only removal when it could not see the SSH twin (#13071). + const store = await createStoreFromState({ + repos: duplicateIdRepos().map((repo) => ({ ...repo, kind })) + }) + store.setWorktreeMetaForHost('dup::/remote/dup', 'ssh:ssh-1', { displayName: 'SSH wt' }) + + store.removeProjectForHost('dup', 'local') + expect(store.getRepos().map((repo) => repo.path)).toEqual(['/remote/dup']) + expect(Object.values(store.getAllWorktreeMeta()).map((meta) => meta.displayName)).toContain( + 'SSH wt' + ) + } + ) + it('removeProjectForHost drops only the addressed host row', async () => { const store = await createStoreWithDuplicateRepoId() diff --git a/src/main/persistence-orcad-migration-catalog.test.ts b/src/main/persistence-orcad-migration-catalog.test.ts index 30f089d6b74..cf8596ec69a 100644 --- a/src/main/persistence-orcad-migration-catalog.test.ts +++ b/src/main/persistence-orcad-migration-catalog.test.ts @@ -487,7 +487,7 @@ describe('orcad migration catalog persistence', () => { const store = createStore() const input = manifest() commitCatalog(store, input) - store.removeProject(REPOSITORY.id) + store.removeProjectForHost(REPOSITORY.id, 'local') expect(store.getOrcadMigrationCatalogState(input).state).toBe('committed') expect(store.getRepos()).toEqual([]) diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index a84e449b242..b76c827c198 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -107,7 +107,7 @@ describe('Store', () => { expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeAdd)).toBe(false) const beforeRemove = getLocalWorktreeScanGeneration(repoId) - store.removeProject(repoId) + store.removeProjectForHost(repoId, 'local') expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeRemove)).toBe(false) const beforeReAdd = getLocalWorktreeScanGeneration(repoId) @@ -121,7 +121,7 @@ describe('Store', () => { for (let index = 0; index < 200; index += 1) { const repoId = `scan-churn-${index}` store.addRepo(makeRepo({ id: repoId })) - store.removeProject(repoId) + store.removeProjectForHost(repoId, 'local') } expect(_getLocalWorktreeScanGenerationCacheSize()).toBe(initialCacheSize) @@ -278,9 +278,9 @@ describe('Store', () => { expect(store.getRepo('nonexistent')).toBeUndefined() }) - // ── 6. removeProject cleans up worktree meta ────────────────────────── + // ── 6. removeProjectForHost cleans up worktree meta ────────────────────────── - it('removeProject deletes the repo and its worktree meta', async () => { + it('removeProjectForHost deletes the repo and its worktree meta', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) @@ -289,7 +289,7 @@ describe('Store', () => { store.setWorktreeMeta('r1::/path/wt2', { displayName: 'wt2' }) store.setWorktreeMeta('r2::/other', { displayName: 'other' }) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') expect(store.getRepo('r1')).toBeUndefined() expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined() @@ -308,13 +308,13 @@ describe('Store', () => { ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [repoId]: 1 } }) - store.removeProject(repoId) + store.removeProjectForHost(repoId, 'local') } expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId).toEqual({}) }) - it('removeProject prunes the repo worktrees from workspace session state', async () => { + it('removeProjectForHost prunes the repo worktrees from workspace session state', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) @@ -327,14 +327,14 @@ describe('Store', () => { lastVisitedAtByWorktreeId: { 'r1::/path/wt1': 111, 'r2::/other': 222 } }) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') const session = store.getWorkspaceSession() expect(session.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined() expect(session.lastVisitedAtByWorktreeId?.['r2::/other']).toBe(222) }) - it('removeProject prunes the repo worktrees from per-host workspace session partitions', async () => { + it('removeProjectForHost prunes the repo worktrees from per-host workspace session partitions', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) @@ -349,7 +349,7 @@ describe('Store', () => { hostId ) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') const hostSession = store.getWorkspaceSession(hostId) expect(hostSession.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined() @@ -370,18 +370,18 @@ describe('Store', () => { ]) }) - it('removeProject removes the derived project host setup compatibility record', async () => { + it('removeProjectForHost removes the derived project host setup compatibility record', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r2']) expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual(['r2']) }) - it('removeProject deletes child and parent lineage for the repo', async () => { + it('removeProjectForHost deletes child and parent lineage for the repo', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) @@ -408,7 +408,7 @@ describe('Store', () => { }) ) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') expect(store.getWorktreeLineage('r1::/path/child')).toBeUndefined() expect(store.getWorktreeLineage('r2::/other-child')).toBeUndefined() diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index ef5ba9c2f89..a9890f66164 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -213,7 +213,7 @@ describe('Store', () => { } }) - store.removeProject('r1') + store.removeProjectForHost('r1', 'local') store.flush() expect(store.getMobileClientTabSelections()['device-a']).toEqual({ diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index 3bb572e9a34..6e976d1e5b5 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getDefaultPersistedState } from '../shared/constants' +import { getRepoExecutionHostId } from '../shared/execution-host' import { MARINE_CREATURES } from '../shared/marine-creatures' import { createRetiredNameLookup } from '../shared/worktree/retired-name-registry' import type { SshTarget } from '../shared/ssh-types' @@ -176,7 +177,7 @@ describe('worktree name retirement registry', () => { store.addRepo({ id: REPO, path: '/repos/a', displayName: 'a', badgeColor: '', addedAt: 0 }) store.addRetiredWorktreeName(REPO, 'nautilus') - store.removeProject(REPO) + store.removeProjectForHost(REPO, 'local') expect(store.getRetiredWorktreeNameRegistry(REPO).names).toEqual([]) }) @@ -195,7 +196,7 @@ describe('worktree name retirement registry', () => { const { getRetiredNameRegistryForRepo, retireGeneratedWorktreeName } = await import('./worktree-name-retirement') await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(oldRepo)) store.flush() store = await reloadStore() @@ -224,7 +225,7 @@ describe('worktree name retirement registry', () => { await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') // The deleted workspace has no Claude bucket; Codex rollout files are not backfilled. - store.removeProject(REPO) + store.removeProjectForHost(REPO, 'local') const newRepo = { ...oldRepo, id: OTHER_REPO } store.addRepo(newRepo) @@ -243,7 +244,7 @@ describe('worktree name retirement registry', () => { await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') // Removing and re-adding the host mints a fresh row id for the same machine and account. - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(oldRepo)) store.removeSshTarget('ssh-old') store.addSshTarget(sshTarget('ssh-new')) const newRepo = { ...oldRepo, id: OTHER_REPO, connectionId: 'ssh-new' } @@ -264,7 +265,7 @@ describe('worktree name retirement registry', () => { await import('./worktree-name-retirement') await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(oldRepo)) const otherRepo = { ...oldRepo, id: OTHER_REPO, connectionId: 'ssh-other' } store.addRepo(otherRepo) @@ -307,7 +308,7 @@ describe('worktree name retirement registry', () => { await retireGeneratedWorktreeName(store, repo, store.getSettings(), 'nautilus') // Drop the repo row, so the namespace copy is the only thing left holding the tombstone. - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(repo)) // The ssh-config alias is unchanged, so re-adoption matches even though the host moved. store.removeSshTarget('ssh-old') store.addRemovedSshTargetTombstone({ @@ -344,7 +345,7 @@ describe('worktree name retirement registry', () => { store.updateSshTarget('ssh-1', { host: 'new.example.com' }) // Drop the repo row the way a project remove/re-add does, leaving the mirror as the only source. - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(repo)) const readded = { ...REMOTE_REPO, id: OTHER_REPO, connectionId: 'ssh-1' } store.addRepo(readded) await expect( @@ -392,7 +393,7 @@ describe('worktree name retirement registry', () => { store.updateSshTarget(runtimeId, { host: 'vm-new.example.com' }) - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(repo)) const readded = { ...REMOTE_REPO, id: OTHER_REPO, connectionId: runtimeId } store.addRepo(readded) await expect( @@ -429,7 +430,7 @@ describe('worktree name retirement registry', () => { // `ssh-y` never moved. Drop its repo row the way a project remove/re-add does, so the namespace // copy is the only thing left holding the tombstone. - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(sibling)) const readded = { ...REMOTE_REPO, id: OTHER_REPO, connectionId: 'ssh-y' } store.addRepo(readded) await expect( @@ -447,7 +448,7 @@ describe('worktree name retirement registry', () => { await import('./worktree-name-retirement') await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus') - store.removeProject(REPO) + store.removeProjectForHost(REPO, getRepoExecutionHostId(oldRepo)) const newRepo = { ...oldRepo, id: OTHER_REPO, connectionId: 'ssh-new' } store.addRepo(newRepo) diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts index d81fa4bd5cf..5531cb96a4e 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -150,6 +150,7 @@ const SshRemoteRuntimeResolutionSchema = z .object({ rung: z.enum(SSH_REMOTE_RUNTIME_RUNGS), pinnedRefusal: z.string().max(64).optional(), + refusedAt: z.number().int().nonnegative().optional(), glibc: z .string() .regex(/^\d+\.\d+$/) diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 1dde3076116..7aa43607282 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -62,33 +62,6 @@ export class RepoLifecycleOperations { return getRepoOrderOperations(this).reorderReposForHost(orderedIds, hostId) } - removeProject(id: string): void { - const repoRemoved = this[repoLifecycleOperationsContext].runtime.state.repos.some( - (repo) => repo.id === id - ) - this[repoLifecycleOperationsContext].runtime.state.repos = this[ - repoLifecycleOperationsContext - ].runtime.state.repos.filter((r) => r.id !== id) - if (repoRemoved) { - retireLocalWorktreeScanGeneration(id) - } - syncProjectHostSetupCompatibilityState(this) - delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] - delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] - pruneWorktreeStateForRepo(this, id, null) - this[repoLifecycleOperationsContext].runtime.state.workspaceSession = - removeRepoFromWorkspaceSession( - this[repoLifecycleOperationsContext].runtime.state.workspaceSession, - id - ) - this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = - removeRepoFromHostWorkspaceSessions( - this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, - id - ) - scheduleSave(this[repoLifecycleOperationsContext].scheduling) - } - removeProjectForHost(id: string, hostId: ExecutionHostId): void { const repoRemoved = this[repoLifecycleOperationsContext].runtime.state.repos.some( (repo) => repo.id === id && getRepoExecutionHostId(repo) === hostId diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 2d4694ba5ea..462c7c781a9 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -5,6 +5,7 @@ import { ORCA_IMAGE_PROTOCOL_VALUE } from '../../shared/terminal-image-protocol' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' +import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripPiProcessOwnerEnv } from '../pty/pi-process-owner-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' @@ -39,6 +40,7 @@ export function buildLocalPtySpawnEnvironment(args: { removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env) stripPiProcessOwnerEnv(spawnEnv) removeAppImageRuntimeEnv(spawnEnv) + removeChromiumDisabledSessionBus(spawnEnv) removeInheritedNoColor(spawnEnv) for (const key of spawn.envToDelete ?? []) { delete spawnEnv[key] diff --git a/src/main/providers/ssh-plain-shell-pty-provider.test.ts b/src/main/providers/ssh-plain-shell-pty-provider.test.ts index 064f37a5cef..4b223eb5578 100644 --- a/src/main/providers/ssh-plain-shell-pty-provider.test.ts +++ b/src/main/providers/ssh-plain-shell-pty-provider.test.ts @@ -133,6 +133,11 @@ describe('SshPlainShellPtyProvider', () => { await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('not active') }) + it('leaves a PTY it never minted unverifiable, since an earlier relay may still run it', async () => { + const { provider } = createProvider() + await expect(provider.probePtyLiveness('ssh:target-1@@prior-relay-pty')).resolves.toBeNull() + }) + it('starts a fresh shell for a reattach request and says the session expired', async () => { const { provider } = createProvider() const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:target-1@@pty-3' }) diff --git a/src/main/providers/ssh-plain-shell-pty-provider.ts b/src/main/providers/ssh-plain-shell-pty-provider.ts index c0fbe34372a..2272bfda36b 100644 --- a/src/main/providers/ssh-plain-shell-pty-provider.ts +++ b/src/main/providers/ssh-plain-shell-pty-provider.ts @@ -55,7 +55,8 @@ const LOST_NOTICE = export class SshPlainShellPtyProvider implements IPtyProvider { private readonly shells = new Map() - private readonly lost = new Set() + // Why only confirmed exits answer absent: an id this provider never minted may still run on an earlier relay. + private readonly exited = new Set() private readonly dataListeners = new Set() private readonly exitListeners = new Set() private disposed = false @@ -137,10 +138,10 @@ export class SshPlainShellPtyProvider implements IPtyProvider { this.shells.delete(id) if (shell.exitCode === null && !shell.closeRequested) { // Why: no exit-status means nothing proved the shell ended; report it lost, never exited. - this.lost.add(id) this.emitData(id, shell, LOST_NOTICE) return } + this.exited.add(id) const payload = { id, code: shell.exitCode ?? 0, @@ -180,7 +181,7 @@ export class SshPlainShellPtyProvider implements IPtyProvider { if (this.shells.has(id)) { return true } - return this.lost.has(id) ? null : false + return this.exited.has(id) ? false : null } write(id: string, data: string): boolean { @@ -304,10 +305,9 @@ export class SshPlainShellPtyProvider implements IPtyProvider { return } this.disposed = true - const open = [...this.shells] + const open = [...this.shells.values()] this.shells.clear() - for (const [id, shell] of open) { - this.lost.add(id) + for (const shell of open) { try { shell.channel.close() } catch { diff --git a/src/main/pty/chromium-session-bus-env.test.ts b/src/main/pty/chromium-session-bus-env.test.ts new file mode 100644 index 00000000000..ac2d54ad988 --- /dev/null +++ b/src/main/pty/chromium-session-bus-env.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' +import { removeChromiumDisabledSessionBus } from './chromium-session-bus-env' + +describe('removeChromiumDisabledSessionBus', () => { + it("drops Chromium's no-bus marker", () => { + const env: Record = { DBUS_SESSION_BUS_ADDRESS: 'disabled:', PATH: '/bin' } + removeChromiumDisabledSessionBus(env) + expect(env).toEqual({ PATH: '/bin' }) + }) + + it('keeps a real address', () => { + const env = { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' } + removeChromiumDisabledSessionBus(env) + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe('unix:path=/run/user/1000/bus') + }) +}) + +describe('daemon PTY environment', () => { + afterEach(() => vi.unstubAllEnvs()) + + // #21119: the daemon's own env used to hand the marker to every shell and agent. + it.each([ + ['inherited from the daemon', {}, true], + ['sent by the client', { DBUS_SESSION_BUS_ADDRESS: 'disabled:' }, false] + ])('strips the marker %s', (_label, explicitEnv, inherited) => { + if (inherited) { + vi.stubEnv('DBUS_SESSION_BUS_ADDRESS', 'disabled:') + } + const env = createDaemonPtyEnvironment({ sessionId: 's', cols: 80, rows: 24, env: explicitEnv }) + expect(env).not.toHaveProperty('DBUS_SESSION_BUS_ADDRESS') + }) + + it('keeps a real inherited address', () => { + vi.stubEnv('DBUS_SESSION_BUS_ADDRESS', 'unix:path=/run/user/1000/bus') + const env = createDaemonPtyEnvironment({ sessionId: 's', cols: 80, rows: 24 }) + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe('unix:path=/run/user/1000/bus') + }) +}) diff --git a/src/main/pty/chromium-session-bus-env.ts b/src/main/pty/chromium-session-bus-env.ts new file mode 100644 index 00000000000..d1057362a4c --- /dev/null +++ b/src/main/pty/chromium-session-bus-env.ts @@ -0,0 +1,9 @@ +/** What Chromium writes into its own env when it starts without a session bus. */ +const CHROMIUM_DISABLED_SESSION_BUS = 'disabled:' + +/** Drops Chromium's no-bus marker so children fall back to `$XDG_RUNTIME_DIR/bus`; a real address is kept. */ +export function removeChromiumDisabledSessionBus(env: Record): void { + if (env.DBUS_SESSION_BUS_ADDRESS === CHROMIUM_DISABLED_SESSION_BUS) { + delete env.DBUS_SESSION_BUS_ADDRESS + } +} diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 612b5fcada1..5e310ff19ce 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -16,6 +16,7 @@ import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import { RUNTIME_DEVICE_GRANTS, type RuntimeDeviceGrant } from './rpc/rpc-method-permission' import { parseMobilePushRegistration, type MobilePushRegistration @@ -38,6 +39,27 @@ export type DeviceEntry = { // Why: survives a desktop restart so the host can keep pushing without the phone // re-registering. Absent on every registry written before background push existed. pushRegistration?: MobilePushRegistration + // Why: administrative permissions are granted only when pairing; absent on older rows means none. + grants?: RuntimeDeviceGrant[] +} + +const GRANTABLE: ReadonlySet = new Set(RUNTIME_DEVICE_GRANTS) + +function isRuntimeDeviceGrant(value: unknown): value is RuntimeDeviceGrant { + return typeof value === 'string' && GRANTABLE.has(value) +} + +function validGrants(value: unknown, scope: DeviceScope): RuntimeDeviceGrant[] | undefined { + if (scope !== 'runtime' || !Array.isArray(value)) { + return undefined + } + const grants = [...new Set(value.filter(isRuntimeDeviceGrant))].sort() + return grants.length > 0 ? grants : undefined +} + +function sameGrants(entry: DeviceEntry, grants: readonly RuntimeDeviceGrant[]): boolean { + const current = entry.grants ?? [] + return current.length === grants.length && current.every((grant) => grants.includes(grant)) } function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { @@ -83,17 +105,20 @@ export class DeviceRegistry { addDevice( name: string, scope: DeviceScope = 'mobile', - pairingReach: RuntimePairingReach = 'network' + pairingReach: RuntimePairingReach = 'network', + grants: readonly RuntimeDeviceGrant[] = [] ): DeviceEntry { - return this.createAndPersistDevice(this.devices, name, scope, pairingReach) + return this.createAndPersistDevice(this.devices, name, scope, pairingReach, grants) } private createAndPersistDevice( existingDevices: DeviceEntry[], name: string, scope: DeviceScope, - pairingReach: RuntimePairingReach + pairingReach: RuntimePairingReach, + grants: readonly RuntimeDeviceGrant[] ): DeviceEntry { + const validatedGrants = validGrants(grants, scope) const entry: DeviceEntry = { deviceId: randomUUID(), name, @@ -101,7 +126,8 @@ export class DeviceRegistry { scope, pairedAt: Date.now(), lastSeenAt: 0, - pairingReach + pairingReach, + ...(validatedGrants ? { grants: validatedGrants } : {}) } const nextDevices = [...existingDevices, entry] // Why: a credential is not valid until its durable registry write succeeds. @@ -119,9 +145,14 @@ export class DeviceRegistry { getOrCreatePendingDevice( name: string, scope: DeviceScope = 'mobile', - pairingReach: RuntimePairingReach = 'network' + pairingReach: RuntimePairingReach = 'network', + grants: readonly RuntimeDeviceGrant[] = [] ): DeviceEntry { - const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope) + // Why: a pending token is reused only for the same grants, so re-advertising never widens one. + const existing = this.devices.find( + (d) => + d.lastSeenAt === 0 && d.scope === scope && sameGrants(d, validGrants(grants, scope) ?? []) + ) if (existing) { // Why: the same pending token can be re-advertised at a broader reach; widen it but never narrow it, // or a link already handed out for off-host use would stop being served after the next launch. @@ -129,7 +160,7 @@ export class DeviceRegistry { ? this.setPairingReach(existing, 'network') : existing } - return this.addDevice(name, scope, pairingReach) + return this.addDevice(name, scope, pairingReach, grants) } private setPairingReach(existing: DeviceEntry, pairingReach: RuntimePairingReach): DeviceEntry { @@ -153,10 +184,11 @@ export class DeviceRegistry { rotatePendingDevice( name: string, scope: DeviceScope = 'mobile', - pairingReach: RuntimePairingReach = 'network' + pairingReach: RuntimePairingReach = 'network', + grants: readonly RuntimeDeviceGrant[] = [] ): DeviceEntry { const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) - return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach) + return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach, grants) } removeDevice(deviceId: string): boolean { @@ -325,6 +357,7 @@ export class DeviceRegistry { // Why: older registries only existed for phone pairing. Treat missing // scope as mobile so legacy device tokens do not gain new CLI powers. scope: device.scope === 'runtime' ? 'runtime' : 'mobile', + grants: validGrants(device.grants, device.scope === 'runtime' ? 'runtime' : 'mobile'), relayBinding: validRelayBinding(device.relayBinding, device.deviceId), mobilePairingConnectionMode: device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic', diff --git a/src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts b/src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts new file mode 100644 index 00000000000..37078ee435b --- /dev/null +++ b/src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts @@ -0,0 +1,57 @@ +// #11315: SSH TUI repaints reach Electron main through onPtyData; tail work must stay linear in bytes. +import { describe, expect, it, vi } from 'vitest' +import * as ansiNormalization from './terminal-ansi-normalization' +import { readPtyTail, runtimeWithLeaf } from './orca-runtime-pty-leaf.test-fixture' + +const ESC = '\x1b' +// An ask_user-style panel: climb to the top, then clear and repaint each full-width row. +function panelFrames(width: number, frames: number): string { + const frame = `${ESC}[4A${['title', 'option a', 'option b', 'hint'] + .map((label) => `\r${ESC}[2K${label} ${'─'.repeat(width - label.length - 1)}\n`) + .join('')}` + return frame.repeat(frames) +} + +describe('onPtyData redraw cost', () => { + it('replays a full-history TUI redraw without splicing the tail once per line', async () => { + const ptyId = 'pty-replay' + const { runtime } = runtimeWithLeaf(ptyId) + try { + runtime.onPtyData(ptyId, 'history\n'.repeat(2_000), 0) + // The panel leaves the cursor parked inside it, so later chunks take the redraw model. + runtime.onPtyData(ptyId, `${panelFrames(120, 1)}${ESC}[2A`, 1) + const replay = Array.from({ length: 20_000 }, (_, index) => `line ${index}\r\n`).join('') + const splice = vi.spyOn(Array.prototype, 'splice') + let spliceCalls: number + try { + runtime.onPtyData(ptyId, `${ESC}[2J${ESC}[H${ESC}[3J${replay}`, 2) + spliceCalls = splice.mock.calls.length + } finally { + splice.mockRestore() + } + // One splice per capped row made a replay cost O(lines x tail cap) on Electron main. + expect(spliceCalls).toBeLessThan(100) + expect(readPtyTail(runtime, ptyId).at(-1)).toBe('line 19999') + } finally { + await runtime.onPtyExit(ptyId, 0) + } + }) + + it('normalizes a chunk once when a diverged leaf carries the same escape prefix', async () => { + const ptyId = 'pty-diverged' + const { runtime, leaf } = runtimeWithLeaf(ptyId) + // Different retained history sends the leaf down its own tail update. + leaf.tailBuffer = ['leaf-only history'] + leaf.tailLinesTotal = 1 + const normalize = vi.spyOn(ansiNormalization, 'normalizeTerminalChunk') + try { + const chunk = panelFrames(80, 3) + runtime.onPtyData(ptyId, chunk, 1_000) + expect(normalize.mock.calls.filter(([data]) => data === chunk)).toHaveLength(1) + expect(leaf.tailBuffer).toContain(`hint ${'─'.repeat(75)}`) + } finally { + normalize.mockRestore() + await runtime.onPtyExit(ptyId, 0) + } + }) +}) diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index c3dfd7b2cac..e665d55ce10 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -86,11 +86,13 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution } : null let ptyTailAfter: ReturnType | null = null + let ptyNormalized: ReturnType | null = null if (pty) { pty.connected = true pty.disconnectedAt = null pty.lastOutputAt = at const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) + ptyNormalized = normalized pty.tailPendingAnsi = normalized.pendingAnsi observeTerminalCommandPaint(pty, data, normalized.text) const nextTail = appendNormalizedToTailBuffer( @@ -162,7 +164,11 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution // mismatch branch below recomputes an exact state on its next chunk. leaf.tailWaitState = pty.tailWaitState } else { - const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) + // Why: the same carried escape prefix normalizes the same chunk identically. + const normalized = + ptyNormalized && ptyTailBefore?.pendingAnsi === leaf.tailPendingAnsi + ? ptyNormalized + : normalizeTerminalChunk(data, leaf.tailPendingAnsi) leaf.tailPendingAnsi = normalized.pendingAnsi const previousWaitState = leaf.tailWaitState?.fromTail === true diff --git a/src/main/runtime/orca-runtime-pty-leaf.test-fixture.ts b/src/main/runtime/orca-runtime-pty-leaf.test-fixture.ts new file mode 100644 index 00000000000..09181e450a6 --- /dev/null +++ b/src/main/runtime/orca-runtime-pty-leaf.test-fixture.ts @@ -0,0 +1,40 @@ +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimeLeafRecord } from './runtime-terminal-state-records' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +/** A runtime whose single tab leaf is bound to `ptyId`, so onPtyData updates PTY and leaf tails. */ +export function runtimeWithLeaf(ptyId: string): { + runtime: OrcaRuntimeService + leaf: RuntimeLeafRecord +} { + const runtime = new OrcaRuntimeService() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [{ tabId: 'tab-1', worktreeId: 'wt-1', activeLeafId: LEAF_ID, layout: null, title: '' }], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId, + paneTitle: null + } + ] + }) + const leaves = runtime['leaves'] + const leaf = leaves.values().next().value + if (leaves.size !== 1 || !leaf) { + throw new Error('Expected exactly one runtime leaf') + } + return { runtime, leaf } +} + +export function readPtyTail(runtime: OrcaRuntimeService, ptyId: string): string[] { + const lines = runtime['ptysById'].get(ptyId)?.tailBuffer + if (!lines) { + throw new Error('PTY record has no tail') + } + return lines.map(String) +} diff --git a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts index c9879bf3d7c..31294118b60 100644 --- a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts +++ b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts @@ -267,7 +267,7 @@ describe('OrcaRuntimeService', () => { Object.assign(repo, updates) return { ...repo } as never }, - removeProject: (id: string) => { + removeProjectForHost: (id: string) => { const index = repos.findIndex((repo) => repo.id === id) if (index !== -1) { repos.splice(index, 1) @@ -309,7 +309,7 @@ describe('OrcaRuntimeService', () => { const runtimeStore = { ...store, getRepos: () => [...repos] as never, - removeProject: (id: string) => { + removeProjectForHost: (id: string) => { const index = repos.findIndex((repo) => repo.id === id) if (index !== -1) { repos.splice(index, 1) diff --git a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts index b11ce57dcfb..649d7de4116 100644 --- a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts +++ b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' +import { mulberry32 } from '../../shared/agent-tui-ansi-fuzz-stream' import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' -import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer' +import { MAX_TAIL_PARTIAL_CHARS } from './terminal-tail-limits' +import { + appendNormalizedToMultilineTailBufferUnwindowed, + type RetainedTailRedrawCursor +} from './terminal-tail-redraw-buffer' // Differential guard for the windowed redraw tail path: the public // appendNormalizedToTailBuffer routes vertical-control chunks through a @@ -9,16 +14,6 @@ import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail // This fuzz asserts the windowed result is byte-identical to the reference // implementation across randomized tails and redraw chunks. -function mulberry32(seed: number): () => number { - let a = seed >>> 0 - return () => { - a = (a + 0x6d2b79f5) | 0 - let t = Math.imul(a ^ (a >>> 15), 1 | a) - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } -} - function randomTail(rng: () => number, maxLines: number): string[] { const count = Math.floor(rng() * maxLines) return Array.from({ length: count }, (_, i) => { @@ -55,6 +50,25 @@ function randomRedrawChunk(rng: () => number): string { return parts.join('') } +function expectMatchesUnwindowed( + tail: string[], + partial: string, + chunk: string, + redrawCursor: RetainedTailRedrawCursor | null, + label: string +): void { + const actual = appendNormalizedToTailBuffer(tail, partial, chunk, redrawCursor) + // Reference path over the full tail. + const expected = appendNormalizedToMultilineTailBufferUnwindowed( + tail, + partial.slice(-MAX_TAIL_PARTIAL_CHARS), + chunk, + partial.length > MAX_TAIL_PARTIAL_CHARS, + redrawCursor + ) + expect(actual, label).toEqual(expected) +} + describe('windowed redraw tail equivalence', () => { it('matches the unwindowed reference across 500 randomized cases', () => { const rng = mulberry32(42) @@ -68,24 +82,28 @@ describe('windowed redraw tail equivalence', () => { // without one take the single-line fast path, which is out of scope. const chunk = `\x1b[${1 + Math.floor(rng() * 4)}A${randomRedrawChunk(rng)}` - const actual = appendNormalizedToTailBuffer(tail, partial, chunk, redrawCursor) - // Reference path over the full tail. - const expected = appendNormalizedToMultilineTailBufferUnwindowed( - tail, - partial.slice(-4000), - chunk, - partial.length > 4000, - redrawCursor - ) + expectMatchesUnwindowed(tail, partial, chunk, redrawCursor, `round ${round}`) + } + }) - expect(actual.lines, `round ${round} lines`).toEqual(expected.lines) - expect(actual.partialLine, `round ${round} partial`).toBe(expected.partialLine) - expect(actual.redrawCursor, `round ${round} cursor`).toEqual(expected.redrawCursor) - expect(actual.truncated, `round ${round} truncated`).toBe(expected.truncated) - expect(actual.newCompleteLines, `round ${round} newLines`).toBe(expected.newCompleteLines) - expect(actual.newlyCompletedLines, `round ${round} completedLines`).toEqual( - expected.newlyCompletedLines - ) + it('matches the reference when a chunk repeats multi-row repaint frames', () => { + const rng = mulberry32(7) + for (let round = 0; round < 80; round++) { + // Why below the cap: both paths trim a full tail differently mid-chunk, which is out of scope. + const tail = randomTail(rng, 1900) + const partial = rng() < 0.5 ? `partial ${'z'.repeat(Math.floor(rng() * 30))}` : '' + const redrawCursor = + rng() < 0.3 ? { rowFromEnd: Math.floor(rng() * 20), column: Math.floor(rng() * 40) } : null + // Why repeated frames: their summed cursor-ups exceed the tail while the net reach does not, + // so only the net-reach window keeps these chunks windowed. + const panelRows = 1 + Math.floor(rng() * 30) + const frame = `\x1b[${panelRows}A${Array.from( + { length: panelRows + Math.floor(rng() * 3) - 1 }, + () => `\r${randomRedrawChunk(rng).replace(/\n/g, '')}\n` + ).join('')}` + const chunk = frame.repeat(1 + Math.floor(rng() * 40)) + + expectMatchesUnwindowed(tail, partial, chunk, redrawCursor, `round ${round}`) } }) }) diff --git a/src/main/runtime/rpc/core-typed-method-contract.test.ts b/src/main/runtime/rpc/core-typed-method-contract.test.ts index dc2b72bea57..b93c5efe2d6 100644 --- a/src/main/runtime/rpc/core-typed-method-contract.test.ts +++ b/src/main/runtime/rpc/core-typed-method-contract.test.ts @@ -20,18 +20,21 @@ const ProbeParams = z.object({ id: z.string(), count: z.number().optional() }) const probe = defineMethod({ name: 'test.typedProbe', + permission: 'workspace', params: ProbeParams, handler: (params) => ({ id: params.id, count: params.count ?? 0 }) }) const schemalessProbe = defineMethod({ name: 'test.schemalessProbe', + permission: 'workspace', params: null, handler: () => ['a', 'b'] }) const streamingProbe = defineStreamingMethod({ name: 'test.streamingProbe', + permission: 'workspace', params: ProbeParams, handler: async (params, _ctx, emit) => { emit(params.id) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 006bb7a982d..82df98666bc 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -12,6 +12,7 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import type { RpcCallerIdentity } from './rpc-caller-identity' +import type { RpcMethodPermission } from './rpc-method-permission' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -134,6 +135,8 @@ type RpcParsedParams = TSchema extends ZodType // Why: the authored shape — literal name, params schema, and producer result all survive for compile-time contracts. export type RpcTypedMethod = { readonly name: TName + // Why: required so a new method cannot ship without deciding which callers may reach it. + readonly permission: RpcMethodPermission readonly params: TSchema readonly handler: RpcHandler, TResult> } @@ -143,6 +146,7 @@ export function defineMethod { return { name: spec.name, + permission: spec.permission, params: spec.params, handler: spec.handler } @@ -157,6 +161,7 @@ export type RpcStreamingHandler = ( // Why: emitted values stay `unknown` — the emit callback is an input, so there is no return position to infer them from. export type RpcTypedStreamingMethod = { readonly name: TName + readonly permission: RpcMethodPermission readonly params: TSchema readonly stream: true readonly handler: RpcStreamingHandler> @@ -167,6 +172,7 @@ export function defineStreamingMethod { return { name: spec.name, + permission: spec.permission, params: spec.params, stream: true, handler: spec.handler @@ -177,12 +183,14 @@ export function defineStreamingMethod unknown } export type RpcStreamingMethodDeclaration = { readonly name: string + readonly permission: RpcMethodPermission readonly params: ZodType | null readonly stream: true readonly handler: ( @@ -197,6 +205,7 @@ export type RpcAnyMethodDeclaration = RpcMethodDeclaration | RpcStreamingMethodD // Why: RpcMethod is the registry's erased view; the dispatcher parses params itself and hands handlers `unknown`. export type RpcMethod = { readonly name: string + readonly permission: RpcMethodPermission readonly params: ZodType | null readonly handler: (params: unknown, ctx: RpcContext) => unknown } @@ -204,6 +213,7 @@ export type RpcMethod = { // Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path. export type RpcStreamingMethod = { readonly name: string + readonly permission: RpcMethodPermission readonly params: ZodType | null readonly stream: true readonly handler: ( diff --git a/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts b/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts index 35d8f5bb097..f9b48c49108 100644 --- a/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts +++ b/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts @@ -9,6 +9,7 @@ const handler = vi.fn(() => ({ source: 'server' })) const methods = [ defineMethod({ name: 'browser.click', + permission: 'workspace', params: z.object({ page: z.string(), x: z.number() }), handler }) diff --git a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts index 9640352faf7..247f24cb980 100644 --- a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts +++ b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts @@ -18,22 +18,26 @@ function makeRuntime(): OrcaRuntimeService { const METHODS = [ defineMethod({ name: 'computer.click', + permission: 'desktop-control', params: z.object({ app: z.string().min(1, 'Missing app') }), handler: () => ({ ok: true }) }), defineMethod({ name: 'browser.click', + permission: 'workspace', params: z.object({ page: z.string().min(1, 'Missing page') }), handler: () => ({ ok: true }) }), defineMethod({ name: 'orchestration.throwZod', + permission: 'workspace', params: z.object({}), handler: () => z.object({ title: z.string().min(1, 'Handler title missing') }).parse({ title: '' }) }), defineMethod({ name: 'orchestration.invalidArgument', + permission: 'workspace', params: z.object({}), handler: () => { throw new InvalidArgumentError('Async validation rejected payload') @@ -41,6 +45,7 @@ const METHODS = [ }), defineMethod({ name: 'orchestration.inspectCaller', + permission: 'workspace', params: z.object({}), handler: (_params, { authenticatedCallerFingerprint }) => ({ authenticatedCallerFingerprint @@ -48,6 +53,7 @@ const METHODS = [ }), defineMethod({ name: 'orchestration.federationInspectCaller', + permission: 'workspace', params: z.object({}), handler: (_params, { authenticatedCallerFingerprint }) => ({ authenticatedCallerFingerprint diff --git a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts index c4c9433b939..1f2cc987da8 100644 --- a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -58,31 +58,37 @@ function makeRuntime(ui: PersistedUIState = getDefaultUIState()): OrcaRuntimeSer const METHODS = [ defineMethod({ name: 'browser.click', + permission: 'workspace', params: z.object({}), handler: () => ({ clicked: true }) }), defineMethod({ name: 'browser.tabCreate', + permission: 'workspace', params: z.object({}), handler: () => ({ browserPageId: 'page-1' }) }), defineMethod({ name: 'browser.tabShow', + permission: 'workspace', params: z.object({}), handler: () => ({ tab: { id: 'page-1' } }) }), defineMethod({ name: 'browser.viewport', + permission: 'workspace', params: z.object({}), handler: () => ({ ok: true }) }), defineMethod({ name: 'browser.eval', + permission: 'workspace', params: z.object({}), handler: () => ({ value: 'ok' }) }), defineStreamingMethod({ name: 'browser.screencast', + permission: 'workspace', params: z.object({}), handler: async (_params, _options, emit) => { emit({ type: 'frame' }) @@ -91,51 +97,61 @@ const METHODS = [ }), defineStreamingMethod({ name: 'browser.screencast.binaryOnly', + permission: 'workspace', params: z.object({}), handler: async () => {} }), defineMethod({ name: 'browser.screencast.unsubscribe', + permission: 'workspace', params: z.object({}), handler: () => ({ ok: true }) }), defineMethod({ name: 'browser.profileImportFromBrowser', + permission: 'workspace', params: z.object({}), handler: () => ({ ok: true }) }), defineMethod({ name: 'browser.profileList', + permission: 'workspace', params: z.object({}), handler: () => ({ profiles: [] }) }), defineMethod({ name: 'browser.profileClearDefaultCookies', + permission: 'workspace', params: z.object({}), handler: () => ({ cleared: false }) }), defineMethod({ name: 'computer.permissions', + permission: 'desktop-control', params: z.object({}), handler: () => ({ opened: true }) }), defineMethod({ name: 'computer.permissionsStatus', + permission: 'workspace', params: z.object({}), handler: () => ({ permissions: [] }) }), defineMethod({ name: 'computer.click', + permission: 'desktop-control', params: z.object({}), handler: () => ({ clicked: true }) }), defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: z.object({}), handler: () => ({ id: 'msg-1' }) }), defineMethod({ name: 'browser.fail', + permission: 'workspace', params: z.object({}), handler: () => { throw new Error('nope') diff --git a/src/main/runtime/rpc/dispatcher-request-parsing.test.ts b/src/main/runtime/rpc/dispatcher-request-parsing.test.ts index e3df920a39a..0185420099b 100644 --- a/src/main/runtime/rpc/dispatcher-request-parsing.test.ts +++ b/src/main/runtime/rpc/dispatcher-request-parsing.test.ts @@ -13,6 +13,7 @@ describe('parseRpcRequestParams', () => { it('parses repeated valid requests through the compiled schema', () => { const method = defineMethod({ name: 'test.parse', + permission: 'workspace', params: z.object({ requestId: z.string().min(1), count: z.number().int().nonnegative() }), handler: () => null }) @@ -28,6 +29,7 @@ describe('parseRpcRequestParams', () => { it('preserves validation errors from the runtime fallback', () => { const method = defineMethod({ name: 'test.parse', + permission: 'workspace', params: z.object({ count: z.number().int('Count must be an integer') }), handler: () => null }) @@ -43,6 +45,7 @@ describe('parseRpcRequestParams', () => { it('keeps transform output when compilation falls back', () => { const method = defineMethod({ name: 'test.parse', + permission: 'workspace', params: z.object({ value: z.string().transform((value) => value.toUpperCase()) }), handler: () => null }) diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index cca01d83588..af8a87a8edb 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -2,6 +2,7 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { PairingRpcContext } from './core' import type { RpcCallerIdentity } from './rpc-caller-identity' +import type { RpcCallerScope } from './rpc-caller-scope' export type RpcDispatchStreamingOptions = { authenticatedCallerFingerprint?: string @@ -11,6 +12,8 @@ export type RpcDispatchStreamingOptions = { pairedDeviceId?: string /** Set by a transport that knows its caller but carries no paired device (the desktop's IPC). */ caller?: RpcCallerIdentity + /** What the transport proved the caller may do; absent means this host's owner. */ + callerScope?: RpcCallerScope clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 4c87ceaaf50..7920166d119 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -25,6 +25,12 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' import { resolveRpcCallerIdentity } from './rpc-caller-identity' +import { + bindRpcCallToCallerScope, + denyRpcMethodForCaller, + OWNER_RPC_CALLER_SCOPE, + type RpcCallerScope +} from './rpc-caller-scope' import { needsOrchestrationCallerResolution, resolveOrchestrationSessionCaller, @@ -34,6 +40,8 @@ import { export type DispatcherOptions = { runtime: OrcaRuntimeService methods?: readonly RpcAnyMethodDeclaration[] + /** Pins every call to this scope, for in-process bridges that relay a non-owner caller. */ + callerScope?: RpcCallerScope } type DispatchCallOptions = RpcDispatchStreamingOptions @@ -44,9 +52,11 @@ export class RpcDispatcher { private readonly orchestrationMutations: OrchestrationMutationExecutor private readonly legacyOrchestration: OrchestrationLegacyCompatibility private readonly streamingDispatcher: RpcStreamingDispatcher + private readonly pinnedCallerScope: RpcCallerScope | undefined - constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) { + constructor({ runtime, methods = ALL_RPC_METHODS, callerScope }: DispatcherOptions) { this.runtime = runtime + this.pinnedCallerScope = callerScope this.registry = buildRegistry(methods) this.orchestrationMutations = getOrchestrationMutationExecutor(runtime) this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime) @@ -55,13 +65,19 @@ export class RpcDispatcher { registry: this.registry, orchestrationMutations: this.orchestrationMutations, legacyOrchestration: this.legacyOrchestration, - meta: () => this.meta() + meta: () => this.meta(), + pinnedCallerScope: callerScope }) } async dispatch(request: RpcRequest, options?: DispatchCallOptions): Promise { const meta = this.meta() const method = this.registry.get(request.method) + const callerScope = this.pinnedCallerScope ?? options?.callerScope ?? OWNER_RPC_CALLER_SCOPE + const denial = denyRpcMethodForCaller(callerScope, request.method, method?.permission) + if (denial) { + return errorResponse(request.id, meta, 'forbidden', denial) + } if (!method) { return errorResponse( request.id, @@ -98,6 +114,17 @@ export class RpcDispatcher { ) } + const pendingBinding = bindRpcCallToCallerScope( + callerScope, + this.runtime, + request.method, + parsedParams.value + ) + const binding = pendingBinding ? await pendingBinding : null + if (binding?.kind === 'denied') { + return errorResponse(request.id, meta, 'forbidden', binding.message) + } + if (request.method.startsWith('emulator.')) { emulatorProbe(`rpc ${request.method}`, request.params) } @@ -127,7 +154,11 @@ export class RpcDispatcher { orchestrationMutations: this.orchestrationMutations, legacyOrchestration: this.legacyOrchestration }) - return successResponse(request.id, meta, result) + const filtered = binding?.filterResult?.(result) ?? { kind: 'allowed', result } + if (filtered.kind === 'denied') { + return errorResponse(request.id, meta, 'forbidden', filtered.message) + } + return successResponse(request.id, meta, filtered.result) } catch (error) { if (request.method.startsWith('emulator.')) { emulatorProbeError(`rpc ${request.method}`, error, { params: request.params }) diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 786bc73985d..7c97c36ac40 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -25,6 +25,7 @@ export const ACCOUNT_METHODS = [ // Why local only: sign-in runs `claude auth login` in a terminal on the execution host. defineMethod({ name: 'accounts.beginClaudeSignIn', + permission: 'accounts-admin', params: BeginClaudeSignInParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -35,6 +36,7 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.finishClaudeSignIn', + permission: 'accounts-admin', params: FinishClaudeSignInParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -45,6 +47,7 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.cancelClaudeSignIn', + permission: 'accounts-admin', params: FinishClaudeSignInParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -56,11 +59,13 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.listData', + permission: 'workspace', params: null, handler: async (_, { runtime }) => runtime.getDataAccountsSnapshot() }), defineMethod({ name: 'accounts.addDataFromHome', + permission: 'accounts-admin', params: AddDataAccountParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -71,18 +76,21 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.selectData', + permission: 'accounts-admin', params: SelectDataAccountParams, handler: async (params, { runtime }) => runtime.selectDataAccount(params.provider, params.accountId) }), defineMethod({ name: 'accounts.removeData', + permission: 'accounts-admin', params: RemoveDataAccountParams, handler: async (params, { runtime }) => runtime.removeDataAccount(params.provider, params.accountId) }), defineMethod({ name: 'accounts.list', + permission: 'workspace', params: ListAccountsParams, handler: async (params, { runtime }) => { // Why: ensure the snapshot reflects the latest provider state before @@ -97,11 +105,13 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.selectClaude', + permission: 'accounts-admin', params: SelectAccountParams, handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId) }), defineMethod({ name: 'accounts.selectCodex', + permission: 'accounts-admin', params: SelectAccountParams, handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId) }), @@ -109,28 +119,33 @@ export const ACCOUNT_METHODS = [ // Why: old hosts silently strip unknown target fields from selectCodex. // A distinct RPC makes version skew fail before it can clear the host slot. name: 'accounts.selectCodexForTarget', + permission: 'accounts-admin', params: SelectCodexAccountForTargetParams, handler: async (params, { runtime }) => runtime.selectCodexAccountForTarget(params.accountId, params.target) }), defineMethod({ name: 'accounts.consumeCodexResetCredit', + permission: 'accounts-admin', params: ConsumeCodexResetCreditParams, handler: async (params, { runtime }) => runtime.consumeCodexRateLimitResetCredit(params.idempotencyKey, params.expectedScope) }), defineMethod({ name: 'accounts.removeClaude', + permission: 'accounts-admin', params: RemoveAccountParams, handler: async (params, { runtime }) => runtime.removeClaudeAccount(params.accountId) }), defineMethod({ name: 'accounts.removeCodex', + permission: 'accounts-admin', params: RemoveAccountParams, handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId) }), defineMethod({ name: 'accounts.addClaudeFromConfigDir', + permission: 'accounts-admin', params: AddClaudeFromConfigDirParams, handler: async () => { throw new Error( @@ -140,6 +155,7 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.addCodexFromHome', + permission: 'accounts-admin', params: AddCodexFromHomeParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { @@ -156,6 +172,7 @@ export const ACCOUNT_METHODS = [ // accounts on either side. Mirrors the notifications.subscribe pattern. defineStreamingMethod({ name: 'accounts.subscribe', + permission: 'workspace', params: null, handler: async (_params, { runtime, connectionId }, emit) => { await new Promise((resolve) => { @@ -191,6 +208,7 @@ export const ACCOUNT_METHODS = [ }), defineMethod({ name: 'accounts.unsubscribe', + permission: 'workspace', params: AccountsUnsubscribeParams, handler: async (params, { runtime }) => { runtime.cleanupSubscription(params.subscriptionId) diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts index 59273cf8715..388ba3736b7 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -6,6 +6,7 @@ import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/ag export const AGENT_HOOK_METHODS = [ defineMethod({ name: 'agentHooks.prepareCodexForWslPane', + permission: 'workspace', params: PrepareCodexForWslPaneParams, handler: async (params, { runtime, clientKind }) => { if (clientKind !== undefined) { diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 15aeb28bd12..0de59791663 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -305,6 +305,7 @@ function runReplaySafeAgentLaunch( export const AGENT_LAUNCH_METHODS = [ defineMethod({ name: 'agent.launchReplay', + permission: 'workspace', params: AgentLaunchReplay, handler: async (params, context): Promise => { if (!supportsAgentLaunch(context)) { @@ -334,6 +335,7 @@ export const AGENT_LAUNCH_METHODS = [ }), defineMethod({ name: 'agent.launch', + permission: 'workspace', params: AgentLaunch, handler: async (params, context): Promise => { if (!supportsAgentLaunch(context)) { diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts index 84e008a7f55..3c9854afb6c 100644 --- a/src/main/runtime/rpc/methods/agent-session.ts +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -61,6 +61,7 @@ function assertOperationTimestampWithinFutureSkew(clientOperationId: string): vo export const AGENT_SESSION_METHODS = [ defineMethod({ name: 'terminal.ensureAgentSession', + permission: 'workspace', params: EnsureAgentSessionParams, handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => (runtime as AgentSessionRuntime).ensureAgentSession( @@ -70,6 +71,7 @@ export const AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'terminal.createAgentSession', + permission: 'workspace', params: CreateAgentSessionParams, handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => { assertOperationTimestampWithinFutureSkew(params.clientOperationId) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index af6ff78b23a..01e0cbe0e92 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -29,6 +29,7 @@ export { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, AiVaultSe export const AI_VAULT_METHODS = [ defineMethod({ name: 'aiVault.searchSessions', + permission: 'workspace', params: AiVaultSearchRequestSchema, handler: (params, { runtime, clientKind, clientCapabilities }) => { const response = searchSessionService(params, clientKind ? 'relay' : 'runtime') @@ -41,12 +42,14 @@ export const AI_VAULT_METHODS = [ }), defineMethod({ name: 'aiVault.searchStatus', + permission: 'workspace', params: AiVaultSearchStatusRequestSchema, handler: (params, { clientKind }) => sessionSearchServiceStatus(params, clientKind ? 'relay' : 'runtime') }), defineMethod({ name: 'aiVault.setSearchEnabled', + permission: 'settings-write', params: AiVaultSetSearchEnabledParamsSchema, handler: async (params, { runtime, clientKind, pairedDeviceId }) => { // Paired clients only: an in-process caller writes this host's own settings directly, @@ -66,12 +69,14 @@ export const AI_VAULT_METHODS = [ }), defineMethod({ name: 'aiVault.resolveSessionTitles', + permission: 'workspace', params: AiVaultSessionTitlesParams, handler: (params, { runtime, signal }) => runtime.resolveAiVaultSessionTitles(params.requests, signal) }), defineMethod({ name: 'aiVault.listSessions', + permission: 'workspace', params: AiVaultListSessionsParams, handler: async (params, { runtime, clientKind, clientCapabilities }) => { await ensureStructuredAgentSessionHostUnlessRefused(() => @@ -107,6 +112,7 @@ export const AI_VAULT_METHODS = [ }), defineMethod({ name: 'aiVault.prepareSessionResume', + permission: 'workspace', params: AiVaultPrepareSessionResumeParams, handler: async (params, { runtime }) => { const args: AiVaultPrepareSessionResumeArgs = { diff --git a/src/main/runtime/rpc/methods/antigravity-accounts.ts b/src/main/runtime/rpc/methods/antigravity-accounts.ts index 18d6205c652..fc0e8c66adb 100644 --- a/src/main/runtime/rpc/methods/antigravity-accounts.ts +++ b/src/main/runtime/rpc/methods/antigravity-accounts.ts @@ -8,22 +8,26 @@ import { getAntigravityAccountService } from '../../../antigravity/native-accoun export const ANTIGRAVITY_ACCOUNT_METHODS = [ defineMethod({ name: 'accounts.antigravityList', + permission: 'workspace', params: AntigravityAccountTargetParams, handler: async (target) => getAntigravityAccountService(target).listAccounts() }), defineMethod({ name: 'accounts.antigravityAddCurrent', + permission: 'accounts-admin', params: AntigravityAccountTargetParams, handler: async (target) => getAntigravityAccountService(target).addCurrentAccount() }), defineMethod({ name: 'accounts.antigravitySelect', + permission: 'accounts-admin', params: AntigravityAccountMutationParams, handler: async ({ target, accountId }) => getAntigravityAccountService(target).selectAccount(accountId) }), defineMethod({ name: 'accounts.antigravityRemove', + permission: 'accounts-admin', params: AntigravityAccountMutationParams, handler: async ({ target, accountId }) => getAntigravityAccountService(target).removeAccount(accountId) diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts index b6b91af8b0b..7eda8a6ecd4 100644 --- a/src/main/runtime/rpc/methods/artifacts.ts +++ b/src/main/runtime/rpc/methods/artifacts.ts @@ -9,36 +9,43 @@ import { export const ARTIFACT_METHODS = [ defineMethod({ name: 'artifacts.list', + permission: 'workspace', params: ListOptions, handler: (params, { runtime }) => runtime.listArtifacts(params) }), defineMethod({ name: 'artifacts.getPublishedLink', + permission: 'workspace', params: SourceRequest, handler: (params, { runtime }) => runtime.getPublishedArtifactLink(params) }), defineMethod({ name: 'artifacts.share', + permission: 'workspace', params: WriteRequest, handler: (params, { runtime }) => runtime.shareArtifact(params) }), defineMethod({ name: 'artifacts.publish', + permission: 'workspace', params: WriteRequest, handler: (params, { runtime }) => runtime.publishArtifact(params) }), defineMethod({ name: 'artifacts.update', + permission: 'workspace', params: WriteRequest, handler: (params, { runtime }) => runtime.updateArtifact(params) }), defineMethod({ name: 'artifacts.unshare', + permission: 'workspace', params: SourceRequest, handler: (params, { runtime }) => runtime.unshareArtifact(params) }), defineMethod({ name: 'artifacts.delete', + permission: 'workspace', params: ArtifactsDeleteParams, handler: (params, { runtime }) => runtime.deleteArtifact(params.id, params) }) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index b1e3eebe6eb..861eb6e6462 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -28,12 +28,14 @@ function mutationOwner( export const AUTOMATION_METHODS = [ defineMethod({ name: 'automation.list', + permission: 'workspace', params: AutomationList, // The projection retains `automations`, so old clients ignore the added owner metadata. handler: (params, { runtime }) => runtime.listAutomationsForScope(params) }), defineMethod({ name: 'automation.show', + permission: 'workspace', params: AutomationId, // Why: the owner rides along so a client that cannot project one itself — the // CLI — can echo it back on the mutation that follows. Optional: an older @@ -46,6 +48,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.create', + permission: 'workspace', params: AutomationCreate, handler: async (params, { runtime }) => ({ automation: await runtime.createAutomation(params) @@ -53,6 +56,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.update', + permission: 'workspace', params: AutomationUpdate, handler: async (params, context) => ({ automation: await context.runtime.updateAutomation(params.id, params.updates, { @@ -63,6 +67,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.delete', + permission: 'workspace', params: AutomationId, handler: (params, context) => context.runtime.deleteAutomation( @@ -72,6 +77,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.runNow', + permission: 'workspace', params: AutomationId, handler: async (params, context) => ({ run: await context.runtime.runAutomationNow( @@ -82,6 +88,7 @@ export const AUTOMATION_METHODS = [ }), defineMethod({ name: 'automation.runs', + permission: 'workspace', params: AutomationRuns, handler: (params, { runtime }) => { if (params.limit !== undefined || params.cursor !== undefined) { diff --git a/src/main/runtime/rpc/methods/browser-client-file-channel.ts b/src/main/runtime/rpc/methods/browser-client-file-channel.ts index 362a1990531..6ee9761da24 100644 --- a/src/main/runtime/rpc/methods/browser-client-file-channel.ts +++ b/src/main/runtime/rpc/methods/browser-client-file-channel.ts @@ -67,6 +67,7 @@ function requireFileChannelPage( export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [ defineMethod({ name: 'browser.clientHost.fileChannel.read', + permission: 'workspace', params: BrowserClientFileChannelReadParams, handler: async (params, context) => { const page = requireFileChannelPage(params, context) @@ -87,6 +88,7 @@ export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [ }), defineMethod({ name: 'browser.clientHost.fileChannel.write', + permission: 'workspace', params: BrowserClientFileChannelWriteParams, handler: async (params, context) => { const page = requireFileChannelPage(params, context) @@ -108,6 +110,7 @@ export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [ }), defineMethod({ name: 'browser.clientHost.fileChannel.abort', + permission: 'workspace', params: BrowserClientFileChannelAbortParams, handler: async (params, context) => { requireFileChannelPage(params, context) diff --git a/src/main/runtime/rpc/methods/browser-client-host.ts b/src/main/runtime/rpc/methods/browser-client-host.ts index e06632f7959..03dc0446c6f 100644 --- a/src/main/runtime/rpc/methods/browser-client-host.ts +++ b/src/main/runtime/rpc/methods/browser-client-host.ts @@ -19,6 +19,7 @@ import { defineMethod, defineStreamingMethod } from '../core' export const BROWSER_CLIENT_HOST_METHODS = [ defineStreamingMethod({ name: 'browser.clientHost.attach', + permission: 'workspace', params: BrowserClientHostAttachParams, handler: async ( params, @@ -164,6 +165,7 @@ export const BROWSER_CLIENT_HOST_METHODS = [ }), defineMethod({ name: 'browser.clientHost.commandResult', + permission: 'workspace', params: BrowserClientHostCommandResultParams, handler: ( params, @@ -196,6 +198,7 @@ export const BROWSER_CLIENT_HOST_METHODS = [ }), defineMethod({ name: 'browser.clientHost.pageMetadata', + permission: 'workspace', params: BrowserClientPageMetadataParams, handler: ( params, diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 780f40fb373..dfe1dbf8fd5 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -14,18 +14,13 @@ import { Is, Keypress, LimitParam, - ProfileCreate, - ProfileDelete, - ProfileImportFromBrowser, Screenshot, Scroll, Select, SelectorPath, TabCurrent, - TabSetProfile, TabClose, TabList, - TabProfileClone, TabShow, TabSwitch, Upload, @@ -33,82 +28,98 @@ import { } from './browser-schemas' import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-schema' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' +import { BROWSER_PROFILE_METHODS } from './browser-profile-rpc-methods' import { CertificateProceed } from '../../../../shared/rpc-contract/browser-core-params' export const BROWSER_CORE_METHODS = [ defineMethod({ name: 'browser.snapshot', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserSnapshot(params) }), defineMethod({ name: 'browser.click', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserClick(params) }), defineMethod({ name: 'browser.goto', + permission: 'workspace', params: Goto, handler: async (params, { runtime }) => runtime.browserGoto(params) }), defineMethod({ name: 'browser.certificate.proceed', + permission: 'workspace', params: CertificateProceed, handler: async (params, { runtime }) => runtime.browserProceedCertificate(params) }), ...BROWSER_TEXT_METHODS, defineMethod({ name: 'browser.select', + permission: 'workspace', params: Select, handler: async (params, { runtime }) => runtime.browserSelect(params) }), defineMethod({ name: 'browser.scroll', + permission: 'workspace', params: Scroll, handler: async (params, { runtime }) => runtime.browserScroll(params) }), defineMethod({ name: 'browser.back', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserBack(params) }), defineMethod({ name: 'browser.reload', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserReload(params) }), defineMethod({ name: 'browser.screenshot', + permission: 'workspace', params: Screenshot, handler: async (params, { runtime }) => runtime.browserScreenshot(params) }), defineMethod({ name: 'browser.eval', + permission: 'workspace', params: Eval, handler: async (params, { runtime }) => runtime.browserEval(params) }), defineMethod({ name: 'browser.tabList', + permission: 'workspace', params: TabList, handler: async (params, { runtime }) => runtime.browserTabList(params) }), defineMethod({ name: 'browser.tabShow', + permission: 'workspace', params: TabShow, handler: async (params, { runtime }) => runtime.browserTabShow(params) }), defineMethod({ name: 'browser.tabCurrent', + permission: 'workspace', params: TabCurrent, handler: async (params, { runtime }) => runtime.browserTabCurrent(params) }), defineMethod({ name: 'browser.tabSwitch', + permission: 'workspace', params: TabSwitch, handler: async (params, { runtime }) => runtime.browserTabSwitch(params) }), defineMethod({ name: 'browser.tabCreate', + permission: 'workspace', params: BrowserTabCreateParams, handler: async (params, { runtime, pairedDeviceId, clientKind }) => pairedDeviceId @@ -117,176 +128,158 @@ export const BROWSER_CORE_METHODS = [ }), defineMethod({ name: 'browser.openUrl', + permission: 'workspace', params: BrowserOpenUrlParams, handler: async (params, { runtime }) => runtime.browserOpenUrlOnClient(params) }), - defineMethod({ - name: 'browser.tabSetProfile', - params: TabSetProfile, - handler: async (params, { runtime }) => runtime.browserTabSetProfile(params) - }), - defineMethod({ - name: 'browser.tabProfileShow', - params: TabShow, - handler: async (params, { runtime }) => runtime.browserTabProfileShow(params) - }), - defineMethod({ - name: 'browser.tabProfileClone', - params: TabProfileClone, - handler: async (params, { runtime }) => runtime.browserTabProfileClone(params) - }), defineMethod({ name: 'browser.tabClose', + permission: 'workspace', params: TabClose, handler: async (params, { runtime }) => runtime.browserTabClose(params) }), - defineMethod({ - name: 'browser.profileList', - params: null, - handler: async (_params, { runtime }) => runtime.browserProfileList() - }), - defineMethod({ - name: 'browser.profileCreate', - params: ProfileCreate, - handler: async (params, { runtime }) => runtime.browserProfileCreate(params) - }), - defineMethod({ - name: 'browser.profileDelete', - params: ProfileDelete, - handler: async (params, { runtime }) => runtime.browserProfileDelete(params) - }), - defineMethod({ - name: 'browser.profileDetectBrowsers', - params: null, - handler: async (_params, { runtime }) => runtime.browserProfileDetectBrowsers() - }), - defineMethod({ - name: 'browser.profileImportFromBrowser', - params: ProfileImportFromBrowser, - handler: async (params, { runtime }) => runtime.browserProfileImportFromBrowser(params) - }), - defineMethod({ - name: 'browser.profileClearDefaultCookies', - params: null, - handler: async (_params, { runtime }) => runtime.browserProfileClearDefaultCookies() - }), + ...BROWSER_PROFILE_METHODS, defineMethod({ name: 'browser.hover', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserHover(params) }), defineMethod({ name: 'browser.drag', + permission: 'workspace', params: Drag, handler: async (params, { runtime }) => runtime.browserDrag(params) }), defineMethod({ name: 'browser.upload', + permission: 'workspace', params: Upload, handler: async (params, { runtime }) => runtime.browserUpload(params) }), defineMethod({ name: 'browser.wait', + permission: 'workspace', params: Wait, handler: async (params, { runtime }) => runtime.browserWait(params) }), defineMethod({ name: 'browser.check', + permission: 'workspace', params: Check, handler: async (params, { runtime }) => runtime.browserCheck(params) }), defineMethod({ name: 'browser.focus', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserFocus(params) }), defineMethod({ name: 'browser.clear', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserClear(params) }), defineMethod({ name: 'browser.selectAll', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserSelectAll(params) }), defineMethod({ name: 'browser.keypress', + permission: 'workspace', params: Keypress, handler: async (params, { runtime }) => runtime.browserKeypress(params) }), defineMethod({ name: 'browser.pdf', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserPdf(params) }), defineMethod({ name: 'browser.fullScreenshot', + permission: 'workspace', params: FullScreenshot, handler: async (params, { runtime }) => runtime.browserFullScreenshot(params) }), defineMethod({ name: 'browser.dblclick', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserDblclick(params) }), defineMethod({ name: 'browser.forward', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserForward(params) }), defineMethod({ name: 'browser.scrollIntoView', + permission: 'workspace', params: Element, handler: async (params, { runtime }) => runtime.browserScrollIntoView(params) }), defineMethod({ name: 'browser.get', + permission: 'workspace', params: Get, handler: async (params, { runtime }) => runtime.browserGet(params) }), defineMethod({ name: 'browser.is', + permission: 'workspace', params: Is, handler: async (params, { runtime }) => runtime.browserIs(params) }), defineMethod({ name: 'browser.find', + permission: 'workspace', params: Find, handler: async (params, { runtime }) => runtime.browserFind(params) }), defineMethod({ name: 'browser.console', + permission: 'workspace', params: LimitParam, handler: async (params, { runtime }) => runtime.browserConsoleLog(params) }), defineMethod({ name: 'browser.network', + permission: 'workspace', params: LimitParam, handler: async (params, { runtime }) => runtime.browserNetworkLog(params) }), defineMethod({ name: 'browser.exec', + permission: 'workspace', params: Exec, handler: async (params, { runtime }) => runtime.browserExec(params) }), defineMethod({ name: 'browser.capture.start', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserCaptureStart(params) }), defineMethod({ name: 'browser.capture.stop', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserCaptureStop(params) }), defineMethod({ name: 'browser.download', + permission: 'workspace', params: SelectorPath, handler: async (params, { runtime }) => runtime.browserDownload(params) }), defineMethod({ name: 'browser.highlight', + permission: 'workspace', params: Highlight, handler: async (params, { runtime }) => runtime.browserHighlight(params) }) diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts index 000838c4938..41954070929 100644 --- a/src/main/runtime/rpc/methods/browser-extras.ts +++ b/src/main/runtime/rpc/methods/browser-extras.ts @@ -26,101 +26,121 @@ import { MouseClick } from '../../../../shared/rpc-contract/browser-extras-param export const BROWSER_EXTRA_METHODS = [ defineMethod({ name: 'browser.cookie.get', + permission: 'workspace', params: CookieGet, handler: async (params, { runtime }) => runtime.browserCookieGet(params) }), defineMethod({ name: 'browser.cookie.set', + permission: 'workspace', params: CookieSet, handler: async (params, { runtime }) => runtime.browserCookieSet(params) }), defineMethod({ name: 'browser.cookie.delete', + permission: 'workspace', params: CookieDelete, handler: async (params, { runtime }) => runtime.browserCookieDelete(params) }), defineMethod({ name: 'browser.viewport', + permission: 'workspace', params: Viewport, handler: async (params, { runtime }) => runtime.browserSetViewport(params) }), defineMethod({ name: 'browser.geolocation', + permission: 'workspace', params: Geolocation, handler: async (params, { runtime }) => runtime.browserSetGeolocation(params) }), defineMethod({ name: 'browser.intercept.enable', + permission: 'workspace', params: InterceptEnable, handler: async (params, { runtime }) => runtime.browserInterceptEnable(params) }), defineMethod({ name: 'browser.intercept.disable', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserInterceptDisable(params) }), defineMethod({ name: 'browser.intercept.list', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserInterceptList(params) }), defineMethod({ name: 'browser.mouseMove', + permission: 'workspace', params: MouseXY, handler: async (params, { runtime }) => runtime.browserMouseMove(params) }), defineMethod({ name: 'browser.mouseDown', + permission: 'workspace', params: MouseButton, handler: async (params, { runtime }) => runtime.browserMouseDown(params) }), defineMethod({ name: 'browser.mouseClick', + permission: 'workspace', params: MouseClick, handler: async (params, { runtime }) => runtime.browserMouseClick(params) }), defineMethod({ name: 'browser.mouseUp', + permission: 'workspace', params: MouseButton, handler: async (params, { runtime }) => runtime.browserMouseUp(params) }), defineMethod({ name: 'browser.mouseWheel', + permission: 'workspace', params: MouseWheel, handler: async (params, { runtime }) => runtime.browserMouseWheel(params) }), defineMethod({ name: 'browser.setDevice', + permission: 'workspace', params: SetDevice, handler: async (params, { runtime }) => runtime.browserSetDevice(params) }), defineMethod({ name: 'browser.setOffline', + permission: 'workspace', params: SetOffline, handler: async (params, { runtime }) => runtime.browserSetOffline(params) }), defineMethod({ name: 'browser.setHeaders', + permission: 'workspace', params: SetHeaders, handler: async (params, { runtime }) => runtime.browserSetHeaders(params) }), defineMethod({ name: 'browser.setCredentials', + permission: 'workspace', params: SetCredentials, handler: async (params, { runtime }) => runtime.browserSetCredentials(params) }), defineMethod({ name: 'browser.setMedia', + permission: 'workspace', params: SetMedia, handler: async (params, { runtime }) => runtime.browserSetMedia(params) }), defineMethod({ name: 'browser.clipboardRead', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserClipboardRead(params) }), defineMethod({ name: 'browser.clipboardWrite', + permission: 'workspace', params: ClipboardWrite, handler: async (params, { runtime }) => { await assertRpcClipboardTextWriteWithinLimit(params.text) @@ -129,41 +149,49 @@ export const BROWSER_EXTRA_METHODS = [ }), defineMethod({ name: 'browser.dialogAccept', + permission: 'workspace', params: DialogAccept, handler: async (params, { runtime }) => runtime.browserDialogAccept(params) }), defineMethod({ name: 'browser.dialogDismiss', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserDialogDismiss(params) }), defineMethod({ name: 'browser.storage.local.get', + permission: 'workspace', params: StorageKey, handler: async (params, { runtime }) => runtime.browserStorageLocalGet(params) }), defineMethod({ name: 'browser.storage.local.set', + permission: 'workspace', params: StorageKeyValue, handler: async (params, { runtime }) => runtime.browserStorageLocalSet(params) }), defineMethod({ name: 'browser.storage.local.clear', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserStorageLocalClear(params) }), defineMethod({ name: 'browser.storage.session.get', + permission: 'workspace', params: StorageKey, handler: async (params, { runtime }) => runtime.browserStorageSessionGet(params) }), defineMethod({ name: 'browser.storage.session.set', + permission: 'workspace', params: StorageKeyValue, handler: async (params, { runtime }) => runtime.browserStorageSessionSet(params) }), defineMethod({ name: 'browser.storage.session.clear', + permission: 'workspace', params: BrowserTarget, handler: async (params, { runtime }) => runtime.browserStorageSessionClear(params) }) diff --git a/src/main/runtime/rpc/methods/browser-identity-rpc.ts b/src/main/runtime/rpc/methods/browser-identity-rpc.ts index 55fbd8d1e6b..60d80d9fc02 100644 --- a/src/main/runtime/rpc/methods/browser-identity-rpc.ts +++ b/src/main/runtime/rpc/methods/browser-identity-rpc.ts @@ -10,11 +10,13 @@ import { export const BROWSER_IDENTITY_METHODS = [ defineMethod({ name: 'browser.identity.get', + permission: 'workspace', params: null, handler: () => getBrowserIdentityModeStatus() }), defineMethod({ name: 'browser.identity.set', + permission: 'workspace', params: BrowserIdentitySet, handler: async ({ mode, reset }) => setBrowserIdentityMode(mode, { reset }) }) diff --git a/src/main/runtime/rpc/methods/browser-network-tunnel.ts b/src/main/runtime/rpc/methods/browser-network-tunnel.ts index d610824b1f2..3e0c312be46 100644 --- a/src/main/runtime/rpc/methods/browser-network-tunnel.ts +++ b/src/main/runtime/rpc/methods/browser-network-tunnel.ts @@ -23,6 +23,7 @@ export function createBrowserNetworkTunnelMethods( return [ defineStreamingMethod({ name: 'network.browserTunnel', + permission: 'host-admin', params: BrowserNetworkTunnelAttachParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/browser-profile-rpc-methods.ts b/src/main/runtime/rpc/methods/browser-profile-rpc-methods.ts new file mode 100644 index 00000000000..685857dc94f --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-profile-rpc-methods.ts @@ -0,0 +1,66 @@ +import { defineMethod } from '../core' +import { + ProfileCreate, + ProfileDelete, + ProfileImportFromBrowser, + TabProfileClone, + TabSetProfile, + TabShow +} from './browser-schemas' + +export const BROWSER_PROFILE_METHODS = [ + defineMethod({ + name: 'browser.tabSetProfile', + permission: 'workspace', + params: TabSetProfile, + handler: async (params, { runtime }) => runtime.browserTabSetProfile(params) + }), + defineMethod({ + name: 'browser.tabProfileShow', + permission: 'workspace', + params: TabShow, + handler: async (params, { runtime }) => runtime.browserTabProfileShow(params) + }), + defineMethod({ + name: 'browser.tabProfileClone', + permission: 'workspace', + params: TabProfileClone, + handler: async (params, { runtime }) => runtime.browserTabProfileClone(params) + }), + defineMethod({ + name: 'browser.profileList', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileList() + }), + defineMethod({ + name: 'browser.profileCreate', + permission: 'workspace', + params: ProfileCreate, + handler: async (params, { runtime }) => runtime.browserProfileCreate(params) + }), + defineMethod({ + name: 'browser.profileDelete', + permission: 'workspace', + params: ProfileDelete, + handler: async (params, { runtime }) => runtime.browserProfileDelete(params) + }), + defineMethod({ + name: 'browser.profileDetectBrowsers', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileDetectBrowsers() + }), + defineMethod({ + name: 'browser.profileImportFromBrowser', + permission: 'workspace', + params: ProfileImportFromBrowser, + handler: async (params, { runtime }) => runtime.browserProfileImportFromBrowser(params) + }), + defineMethod({ + name: 'browser.profileClearDefaultCookies', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileClearDefaultCookies() + }) +] diff --git a/src/main/runtime/rpc/methods/browser-screencast.ts b/src/main/runtime/rpc/methods/browser-screencast.ts index 798e1ed84d2..5b59ebd629e 100644 --- a/src/main/runtime/rpc/methods/browser-screencast.ts +++ b/src/main/runtime/rpc/methods/browser-screencast.ts @@ -8,6 +8,7 @@ import { ScreencastUnsubscribe } from '../../../../shared/rpc-contract/browser-s export const BROWSER_SCREENCAST_METHODS = [ defineStreamingMethod({ name: 'browser.screencast', + permission: 'workspace', params: Screencast, handler: async ( params, @@ -26,6 +27,7 @@ export const BROWSER_SCREENCAST_METHODS = [ }), defineMethod({ name: 'browser.screencast.unsubscribe', + permission: 'workspace', params: ScreencastUnsubscribe, handler: async (params, { runtime }) => { if (!runtimeBrowserCommandsFactoryIsAvailable()) { diff --git a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts index 18fd19a0e6a..91f16ec1869 100644 --- a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts @@ -5,6 +5,7 @@ import { Fill, KeyboardInsert, Type } from './browser-schemas' export const BROWSER_TEXT_METHODS = [ defineMethod({ name: 'browser.fill', + permission: 'workspace', params: Fill, handler: async (params, { runtime }) => { await assertRpcClipboardTextWriteWithinLimit(params.value) @@ -13,6 +14,7 @@ export const BROWSER_TEXT_METHODS = [ }), defineMethod({ name: 'browser.type', + permission: 'workspace', params: Type, handler: async (params, { runtime }) => { await assertRpcClipboardTextWriteWithinLimit(params.input) @@ -21,6 +23,7 @@ export const BROWSER_TEXT_METHODS = [ }), defineMethod({ name: 'browser.keyboardInsertText', + permission: 'workspace', params: KeyboardInsert, handler: async (params, { runtime }) => { await assertRpcClipboardTextWriteWithinLimit(params.text) diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index e7506ad2f59..68c80f20ee0 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -8,6 +8,7 @@ let clientEventSubscriptionSeq = 0 export const CLIENT_EVENT_METHODS = [ defineStreamingMethod({ name: 'runtime.clientEvents.subscribe', + permission: 'workspace', params: null, handler: async (_params, { runtime, connectionId, clientKind }, emit) => { await new Promise((resolve) => { @@ -51,6 +52,7 @@ export const CLIENT_EVENT_METHODS = [ }), defineMethod({ name: 'runtime.clientEvents.unsubscribe', + permission: 'workspace', params: ClientEventsUnsubscribeParams, handler: async (params, { runtime, connectionId }) => { const expectedPrefix = `runtime-client-events-${connectionId ?? 'inproc'}-` diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 6ed36a6fe83..a2c1ad20d95 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -15,11 +15,13 @@ import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema export const CLIENT_UI_METHODS = [ defineMethod({ name: 'settings.get', + permission: 'workspace', params: null, handler: (_params, { runtime }) => ({ settings: runtime.getClientSettings() }) }), defineMethod({ name: 'settings.update', + permission: 'settings-write', params: SettingsUpdate, handler: async (params, { runtime }) => ({ settings: await runtime.updateClientSettings(params) @@ -27,6 +29,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'settings.getTerminalQuickCommands', + permission: 'workspace', params: null, // Why: command bodies can total ~240 KB, so keep unrelated settings reads // from carrying them over every paired/relay connection. @@ -36,6 +39,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'settings.updateTerminalQuickCommands', + permission: 'settings-write', params: TerminalQuickCommandsUpdate, handler: (params, { runtime }) => ({ terminalQuickCommands: runtime.updateClientTerminalQuickCommands(params.mutation) @@ -43,6 +47,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'settings.updatePRBotAuthorOverride', + permission: 'settings-write', params: PRBotAuthorOverrideUpdate, handler: (params, { runtime }) => ({ settings: runtime.updateClientPRBotAuthorOverride(params) @@ -50,6 +55,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'settings.mutateNativeChatSessionOptions', + permission: 'settings-write', params: NativeChatSessionOptionsMutation, handler: (params, { runtime }) => { runtime.updateClientNativeChatSessionOptions(params) @@ -58,11 +64,13 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'ui.get', + permission: 'workspace', params: null, handler: (_params, { runtime }) => ({ ui: omitPairingLocalUiFields(runtime.getUIState()) }) }), defineMethod({ name: 'ui.set', + permission: 'workspace', params: UiUpdate, // Why the fields are dropped here rather than removed from the schema: UiUpdate is strict, so // an unlisted key would make the dispatcher reject an old client's ENTIRE payload. @@ -74,6 +82,7 @@ export const CLIENT_UI_METHODS = [ }), defineMethod({ name: 'ui.recordFeatureInteraction', + permission: 'workspace', params: FeatureInteractionIdParam, handler: (params, { runtime }) => ({ ui: omitPairingLocalUiFields(runtime.recordFeatureInteraction(params)) diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index 60ed142455f..55ba5761c67 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -92,6 +92,7 @@ function assertMobileUploadOwner( export const CLIPBOARD_METHODS = [ defineMethod({ name: 'clipboard.saveImageAsTempFile', + permission: 'workspace', params: SaveImageAsTempFile, handler: async (params, ctx) => { const clientId = mobileClientId(ctx) @@ -109,6 +110,7 @@ export const CLIPBOARD_METHODS = [ }), defineMethod({ name: 'clipboard.startImageUpload', + permission: 'workspace', params: StartImageUpload, handler: (params, ctx) => { pruneExpiredUploads() @@ -130,6 +132,7 @@ export const CLIPBOARD_METHODS = [ }), defineMethod({ name: 'clipboard.appendImageUploadChunk', + permission: 'workspace', params: AppendImageUploadChunk, handler: (params, ctx) => { const upload = getUpload(params.uploadId) @@ -149,6 +152,7 @@ export const CLIPBOARD_METHODS = [ }), defineMethod({ name: 'clipboard.commitImageUpload', + permission: 'workspace', params: CommitImageUpload, handler: async (params, ctx) => { const upload = getUpload(params.uploadId) @@ -176,6 +180,7 @@ export const CLIPBOARD_METHODS = [ }), defineMethod({ name: 'clipboard.abortImageUpload', + permission: 'workspace', params: AbortImageUpload, handler: (params, ctx) => { pruneExpiredUploads() diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts index e2708667633..c580a0d9af8 100644 --- a/src/main/runtime/rpc/methods/computer.ts +++ b/src/main/runtime/rpc/methods/computer.ts @@ -34,6 +34,7 @@ export function resetComputerSessionsForTest(): void { export const COMPUTER_METHODS = [ defineMethod({ name: 'computer.capabilities', + permission: 'workspace', params: ComputerCapabilitiesParams, handler: async () => { return await callComputerSidecarCapabilities() @@ -41,6 +42,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.listApps', + permission: 'desktop-control', params: ListApps, handler: async () => { return await callComputerSidecarListApps() @@ -48,6 +50,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.permissions', + permission: 'desktop-control', params: ComputerPermissions, handler: async (params) => { const { openComputerUsePermissions } = @@ -57,6 +60,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.permissionsStatus', + permission: 'workspace', params: ComputerPermissionsStatusParams, handler: async () => { const { getComputerUsePermissionStatus } = @@ -66,6 +70,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.listWindows', + permission: 'desktop-control', params: ListWindows, handler: async (params) => { return await callComputerSidecarListWindows(params) @@ -73,6 +78,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.getAppState', + permission: 'desktop-control', params: ComputerObserveTarget, handler: async (params) => { return await callComputerSidecarSnapshot(params) @@ -80,6 +86,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.click', + permission: 'desktop-control', params: Click, handler: async (params) => { return await callComputerSidecarAction('click', params) @@ -87,6 +94,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.performSecondaryAction', + permission: 'desktop-control', params: PerformSecondaryAction, handler: async (params) => { return await callComputerSidecarAction('performSecondaryAction', params) @@ -94,6 +102,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.scroll', + permission: 'desktop-control', params: Scroll, handler: async (params) => { return await callComputerSidecarAction('scroll', params) @@ -101,6 +110,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.drag', + permission: 'desktop-control', params: Drag, handler: async (params) => { return await callComputerSidecarAction('drag', params) @@ -108,6 +118,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.typeText', + permission: 'desktop-control', params: TypeText, handler: async (params) => { return await callComputerSidecarAction('typeText', params) @@ -115,6 +126,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.pressKey', + permission: 'desktop-control', params: PressKey, handler: async (params) => { return await callComputerSidecarAction('pressKey', params) @@ -122,6 +134,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.hotkey', + permission: 'desktop-control', params: Hotkey, handler: async (params) => { return await callComputerSidecarAction('hotkey', params) @@ -129,6 +142,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.pasteText', + permission: 'desktop-control', params: PasteText, handler: async (params) => { return await callComputerSidecarAction('pasteText', params) @@ -136,6 +150,7 @@ export const COMPUTER_METHODS = [ }), defineMethod({ name: 'computer.setValue', + permission: 'desktop-control', params: SetValue, handler: async (params) => { return await callComputerSidecarAction('setValue', params) diff --git a/src/main/runtime/rpc/methods/diagnostics.ts b/src/main/runtime/rpc/methods/diagnostics.ts index 953eccd5d51..789d33a6f41 100644 --- a/src/main/runtime/rpc/methods/diagnostics.ts +++ b/src/main/runtime/rpc/methods/diagnostics.ts @@ -3,6 +3,7 @@ import { defineMethod } from '../core' export const DIAGNOSTICS_METHODS = [ defineMethod({ name: 'diagnostics.memory', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => { return await runtime.getMemorySnapshot() diff --git a/src/main/runtime/rpc/methods/emulator.ts b/src/main/runtime/rpc/methods/emulator.ts index b472e539603..6b1b12f07c3 100644 --- a/src/main/runtime/rpc/methods/emulator.ts +++ b/src/main/runtime/rpc/methods/emulator.ts @@ -35,96 +35,115 @@ const InstallParams = z.object({ export const EMULATOR_METHODS = [ defineMethod({ name: 'emulator.list', + permission: 'workspace', params: ListParams, handler: async (params, { runtime }) => runtime.emulatorList(params) }), defineMethod({ name: 'emulator.attach', + permission: 'workspace', params: AttachParams, handler: async (params, { runtime }) => runtime.emulatorAttach(params) }), defineMethod({ name: 'emulator.tap', + permission: 'workspace', params: TapParams, handler: async (params, { runtime }) => runtime.emulatorTap(params) }), defineMethod({ name: 'emulator.gesture', + permission: 'workspace', params: GestureParams, handler: async (params, { runtime }) => runtime.emulatorGesture(params) }), defineMethod({ name: 'emulator.type', + permission: 'workspace', params: TypeParams, handler: async (params, { runtime }) => runtime.emulatorType(params) }), defineMethod({ name: 'emulator.button', + permission: 'workspace', params: ButtonParams, handler: async (params, { runtime }) => runtime.emulatorButton(params) }), defineMethod({ name: 'emulator.rotate', + permission: 'workspace', params: RotateParams, handler: async (params, { runtime }) => runtime.emulatorRotate(params) }), defineMethod({ name: 'emulator.exec', + permission: 'workspace', params: ExecParams, handler: async (params, { runtime }) => runtime.emulatorExec(params) }), defineMethod({ name: 'emulator.kill', + permission: 'workspace', params: KillParams, handler: async (params, { runtime }) => runtime.emulatorKill(params) }), defineMethod({ name: 'emulator.shutdown', + permission: 'workspace', params: ShutdownParams, handler: async (params, { runtime }) => runtime.emulatorShutdown(params) }), defineMethod({ name: 'emulator.listSimulators', + permission: 'workspace', params: EmulatorListSimulatorsParams, handler: async (params, { runtime }) => runtime.emulatorListSimulators(params) }), defineMethod({ name: 'emulator.availability', + permission: 'workspace', params: EmulatorAvailabilityParams, handler: async (params, { runtime }) => runtime.emulatorAvailability(params) }), defineMethod({ name: 'emulator.listDevices', + permission: 'workspace', params: EmulatorListDevicesParams, handler: async (params, { runtime }) => runtime.emulatorListDevices(params) }), defineMethod({ name: 'emulator.install', + permission: 'workspace', params: InstallParams, handler: async (params, { runtime }) => runtime.emulatorInstall(params) }), defineMethod({ name: 'emulator.launch', + permission: 'workspace', params: LaunchParams, handler: async (params, { runtime }) => runtime.emulatorLaunch(params) }), defineMethod({ name: 'emulator.permissions', + permission: 'workspace', params: PermissionsParams, handler: async (params, { runtime }) => runtime.emulatorPermissions(params) }), defineMethod({ name: 'emulator.ax', + permission: 'workspace', params: AxParams, handler: async (params, { runtime }) => runtime.emulatorAx(params) }), defineMethod({ name: 'emulator.logcat', + permission: 'workspace', params: LogcatParams, handler: async (params, { runtime }) => runtime.emulatorLogcat(params) }), defineMethod({ name: 'emulator.unregisterActive', + permission: 'workspace', params: EmulatorUnregisterActiveParams, handler: async (params, { runtime }) => runtime.emulatorUnregisterActive(params) }) diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts index eb734d5c8d6..89cb2eca069 100644 --- a/src/main/runtime/rpc/methods/files-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts @@ -36,6 +36,7 @@ function sshMutationArguments( export const FILE_MUTATION_METHODS = [ defineMethod({ name: 'files.write', + permission: 'workspace', params: FileWrite, handler: async (params, { runtime }) => runtime.writeFileExplorerFile( @@ -47,6 +48,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.writeBase64', + permission: 'workspace', params: FileWriteBase64, handler: async (params, { runtime }) => runtime.writeFileExplorerFileBase64( @@ -58,6 +60,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.writeBase64Chunk', + permission: 'workspace', params: FileWriteBase64Chunk, handler: async (params, { runtime }) => runtime.writeFileExplorerFileBase64Chunk( @@ -70,6 +73,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.createFile', + permission: 'workspace', params: FileMutationOpen, handler: async (params, { runtime }) => runtime.createFileExplorerFile( @@ -80,6 +84,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.createDir', + permission: 'workspace', params: FileMutationOpen, handler: async (params, { runtime }) => runtime.createFileExplorerDir( @@ -90,6 +95,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.createDirNoClobber', + permission: 'workspace', params: FileMutationOpen, handler: async (params, { runtime }) => runtime.createFileExplorerDirNoClobber( @@ -100,6 +106,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.commitUpload', + permission: 'workspace', params: FileCommitUpload, handler: async (params, { runtime }) => runtime.commitFileExplorerUpload( @@ -111,6 +118,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.rename', + permission: 'workspace', params: FileRename, handler: async (params, { runtime }) => runtime.renameFileExplorerPath( @@ -122,6 +130,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.copy', + permission: 'workspace', params: FileCopy, handler: async (params, { runtime }) => runtime.copyFileExplorerPath( @@ -133,6 +142,7 @@ export const FILE_MUTATION_METHODS = [ }), defineMethod({ name: 'files.delete', + permission: 'workspace', params: FileDelete, handler: async (params, { runtime }) => runtime.deleteFileExplorerPath( diff --git a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts index 20d52eb424d..6b619a9b13f 100644 --- a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts +++ b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts @@ -8,6 +8,7 @@ import { export const FILE_TERMINAL_ARTIFACT_METHODS = [ defineMethod({ name: 'files.readTerminalArtifact', + permission: 'workspace', params: TerminalArtifactFile, handler: async (params, { runtime, clientId }) => runtime.readTerminalArtifactFile( @@ -19,6 +20,7 @@ export const FILE_TERMINAL_ARTIFACT_METHODS = [ }), defineMethod({ name: 'files.readTerminalArtifactPreview', + permission: 'workspace', params: TerminalArtifactFile, handler: async (params, { runtime, clientId, clientKind, requestId }) => { const budget = remoteFileContentBudget(clientKind, requestId) @@ -40,6 +42,7 @@ export const FILE_TERMINAL_ARTIFACT_METHODS = [ }), defineMethod({ name: 'files.writeTerminalArtifact', + permission: 'workspace', params: TerminalArtifactFileWrite, handler: async (params, { runtime, clientId }) => runtime.writeTerminalArtifactFile( diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 99eaa3f2c4d..2a64b9ceb49 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -25,6 +25,7 @@ let filesWatchSubscriptionSeq = 0 export const FILE_METHODS = [ defineMethod({ name: 'files.list', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime, signal }) => signal === undefined @@ -33,6 +34,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.searchPaths', + permission: 'workspace', params: FilePathSearch, handler: async (params, { runtime, signal, clientKind, requestId }) => { if (params.mode !== 'quick-open') { @@ -58,12 +60,14 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.open', + permission: 'workspace', params: FileOpenTab, handler: async (params, { runtime }) => runtime.openMobileFile(params.worktree, params.relativePath, params.navigation) }), defineMethod({ name: 'files.openDiff', + permission: 'workspace', params: FileOpenDiff, handler: async (params, { runtime }) => runtime.openMobileDiff( @@ -75,12 +79,14 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.read', + permission: 'workspace', params: FileOpen, handler: async (params, { runtime }) => runtime.readMobileFile(params.worktree, params.relativePath) }), defineMethod({ name: 'files.readDocPreview', + permission: 'workspace', params: DocPreviewFileRead, handler: async (params, { runtime, clientKind, requestId }) => runtime.readDocPreviewFile( @@ -94,6 +100,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.resolveTerminalPath', + permission: 'workspace', params: ResolveTerminalPath, handler: async (params, { runtime, clientId }) => runtime.resolveTerminalPath( @@ -109,6 +116,7 @@ export const FILE_METHODS = [ ...FILE_TERMINAL_ARTIFACT_METHODS, defineMethod({ name: 'files.readPreview', + permission: 'workspace', params: FileOpen, handler: async (params, { runtime, clientKind, requestId }) => { const budget = remoteFileContentBudget(clientKind, requestId) @@ -119,6 +127,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.readChunk', + permission: 'workspace', params: FileReadChunk, handler: async (params, { runtime }) => runtime.readFileExplorerChunk( @@ -130,6 +139,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.readDir', + permission: 'workspace', params: FileTreePath, handler: async (params, { runtime }) => params.followSymlinks === undefined @@ -140,12 +150,14 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.browseServerDir', + permission: 'workspace', params: ServerDirectoryBrowse, handler: async (params, { runtime }) => runtime.browseServerDir(params.path) }), ...FILE_MUTATION_METHODS, defineMethod({ name: 'files.search', + permission: 'workspace', params: FileSearch, handler: async (params, { runtime, signal }) => runtime.searchRuntimeFiles( @@ -164,6 +176,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.listAll', + permission: 'workspace', params: FileListAll, handler: async (params, { runtime, clientKind, requestId, signal }) => { const maxContentBytes = remoteFileContentBudget(clientKind, requestId) @@ -180,23 +193,27 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.listMarkdownDocuments', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listRuntimeMarkdownDocuments(params.worktree) }), defineMethod({ name: 'files.pathsExist', + permission: 'workspace', params: FilePathsExist, handler: async (params, { runtime }) => runtime.pathsExistRuntimeFiles(params.worktree, params.relativePaths) }), defineMethod({ name: 'files.stat', + permission: 'workspace', params: FileTreePath, handler: async (params, { runtime }) => runtime.statRuntimeFile(params.worktree, params.relativePath) }), defineStreamingMethod({ name: 'files.watch', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime, connectionId, signal }, emit) => { const seq = ++filesWatchSubscriptionSeq @@ -213,6 +230,7 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.unwatch', + permission: 'workspace', params: FileUnwatch, handler: async (params, { runtime }) => { await runtime.cleanupSubscriptionAndWait(params.subscriptionId) diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts index a2e178b8ed9..9163b1d3533 100644 --- a/src/main/runtime/rpc/methods/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -10,6 +10,7 @@ import { export const FOLDER_WORKSPACE_METHODS = [ defineMethod({ name: 'folderWorkspace.list', + permission: 'workspace', params: null, handler: (_params, { runtime }) => ({ folderWorkspaces: runtime.listFolderWorkspaces() @@ -17,6 +18,7 @@ export const FOLDER_WORKSPACE_METHODS = [ }), defineMethod({ name: 'folderWorkspace.create', + permission: 'workspace', params: FolderWorkspaceCreate, handler: async (params, context) => ({ folderWorkspace: await context.runtime.createFolderWorkspace({ @@ -27,6 +29,7 @@ export const FOLDER_WORKSPACE_METHODS = [ }), defineMethod({ name: 'folderWorkspace.update', + permission: 'workspace', params: FolderWorkspaceUpdate, handler: async (params, { runtime }) => ({ folderWorkspace: await runtime.updateFolderWorkspace(params.folderWorkspaceId, params.updates) @@ -34,11 +37,13 @@ export const FOLDER_WORKSPACE_METHODS = [ }), defineMethod({ name: 'folderWorkspace.delete', + permission: 'workspace', params: FolderWorkspaceSelector, handler: async (params, { runtime }) => runtime.deleteFolderWorkspace(params.folderWorkspaceId) }), defineMethod({ name: 'folderWorkspace.getPathStatus', + permission: 'workspace', params: FolderWorkspacePathStatus, handler: async (params, { runtime }) => ({ status: await runtime.getFolderWorkspacePathStatus(params) diff --git a/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts b/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts index dcc376decf4..24c28f47f70 100644 --- a/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts +++ b/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts @@ -65,6 +65,7 @@ function buildCommitMessageGenerationOverride(params: { export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ defineMethod({ name: 'git.generateCommitMessage', + permission: 'workspace', params: GitGenerateCommitMessage, handler: async (params, { runtime }) => { const override = buildCommitMessageGenerationOverride(params) @@ -76,6 +77,7 @@ export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ }), defineMethod({ name: 'git.discoverCommitMessageModels', + permission: 'workspace', params: GitDiscoverCommitMessageModels, handler: async (params, { runtime }) => runtime.discoverRuntimeCommitMessageModels( @@ -90,12 +92,14 @@ export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ }), defineMethod({ name: 'git.cancelGenerateCommitMessage', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.cancelRuntimeGenerateCommitMessage(params.worktree) }), defineMethod({ name: 'git.generatePullRequestFields', + permission: 'workspace', params: GitGeneratePullRequestFields, handler: async (params, { runtime }) => { const input = { @@ -115,6 +119,7 @@ export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ }), defineMethod({ name: 'git.cancelGeneratePullRequestFields', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.cancelRuntimeGeneratePullRequestFields(params.worktree) diff --git a/src/main/runtime/rpc/methods/git-diff-methods.ts b/src/main/runtime/rpc/methods/git-diff-methods.ts index edcaebbdf42..5f83c239772 100644 --- a/src/main/runtime/rpc/methods/git-diff-methods.ts +++ b/src/main/runtime/rpc/methods/git-diff-methods.ts @@ -14,6 +14,7 @@ function remoteDiffContentBudget( export const GIT_DIFF_METHODS = [ defineMethod({ name: 'git.diff', + permission: 'workspace', params: GitDiff, handler: async (params, { runtime, clientKind, requestId }) => runtime.getRuntimeGitDiff( @@ -26,6 +27,7 @@ export const GIT_DIFF_METHODS = [ }), defineMethod({ name: 'git.branchDiff', + permission: 'workspace', params: GitBranchDiff, handler: async (params, { runtime, clientKind, requestId }) => runtime.getRuntimeGitBranchDiff( @@ -38,6 +40,7 @@ export const GIT_DIFF_METHODS = [ }), defineMethod({ name: 'git.commitDiff', + permission: 'workspace', params: GitCommitDiff, handler: async (params, { runtime, clientKind, requestId }) => runtime.getRuntimeGitCommitDiff( diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 20102d71e28..7af9ecf0eee 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -24,6 +24,7 @@ import { export const GIT_METHODS = [ defineMethod({ name: 'git.status', + permission: 'workspace', params: GitStatusParams, handler: async (params, { runtime, signal }) => { const options = @@ -59,18 +60,21 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.checkIgnored', + permission: 'workspace', params: GitCheckIgnored, handler: async (params, { runtime }) => runtime.checkRuntimeGitIgnoredPaths(params.worktree, params.paths) }), defineMethod({ name: 'git.submoduleStatus', + permission: 'workspace', params: GitSubmoduleStatus, handler: async (params, { runtime }) => runtime.getRuntimeGitSubmoduleStatus(params.worktree, params.submodulePath, params.area) }), defineMethod({ name: 'git.history', + permission: 'workspace', params: GitHistory, handler: async (params, { runtime }) => runtime.getRuntimeGitHistory(params.worktree, { @@ -80,45 +84,53 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.conflictOperation', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree) }), defineMethod({ name: 'git.abortMerge', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.abortRuntimeGitMerge(params.worktree) }), defineMethod({ name: 'git.abortRebase', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.abortRuntimeGitRebase(params.worktree) }), defineMethod({ name: 'git.checkout', + permission: 'workspace', params: GitCheckout, handler: async (params, { runtime }) => runtime.checkoutRuntimeGitBranch(params.worktree, params.branch) }), defineMethod({ name: 'git.localBranches', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listRuntimeGitLocalBranches(params.worktree) }), ...GIT_DIFF_METHODS, defineMethod({ name: 'git.branchCompare', + permission: 'workspace', params: GitBranchCompare, handler: async (params, { runtime }) => runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef, params.admissionTier) }), defineMethod({ name: 'git.commitCompare', + permission: 'workspace', params: GitCommitCompare, handler: async (params, { runtime }) => runtime.getRuntimeGitCommitCompare(params.worktree, params.commitId) }), defineMethod({ name: 'git.upstreamStatus', + permission: 'workspace', params: GitTargetedRemote, handler: async (params, { runtime }) => params.pushTarget === undefined @@ -127,6 +139,7 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.fetch', + permission: 'workspace', params: GitTargetedRemote, handler: async (params, { runtime }) => params.pushTarget === undefined @@ -135,12 +148,14 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.forkSync', + permission: 'workspace', params: GitForkSync, handler: async (params, { runtime }) => runtime.syncRuntimeGitForkDefaultBranch(params.worktree, params.expectedUpstream) }), defineMethod({ name: 'git.pull', + permission: 'workspace', params: GitTargetedRemote, handler: async (params, { runtime }) => params.pushTarget === undefined @@ -149,6 +164,7 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.fastForward', + permission: 'workspace', params: GitTargetedRemote, handler: async (params, { runtime }) => params.pushTarget === undefined @@ -157,12 +173,14 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.rebaseFromBase', + permission: 'workspace', params: GitRebaseFromBase, handler: async (params, { runtime }) => runtime.rebaseRuntimeGitFromBase(params.worktree, params.baseRef) }), defineMethod({ name: 'git.push', + permission: 'workspace', params: GitPush, handler: async (params, { runtime }) => runtime.pushRuntimeGit( @@ -174,6 +192,7 @@ export const GIT_METHODS = [ }), defineMethod({ name: 'git.commit', + permission: 'workspace', params: GitCommit, handler: async (params, { runtime }) => runtime.commitRuntimeGit(params.worktree, params.message) @@ -181,48 +200,56 @@ export const GIT_METHODS = [ ...GIT_COMMIT_MESSAGE_GENERATION_METHODS, defineMethod({ name: 'git.stage', + permission: 'workspace', params: GitFilePath, handler: async (params, { runtime }) => runtime.stageRuntimeGitPath(params.worktree, params.filePath) }), defineMethod({ name: 'git.bulkStage', + permission: 'workspace', params: GitBulkPaths, handler: async (params, { runtime }) => runtime.bulkStageRuntimeGitPaths(params.worktree, params.filePaths) }), defineMethod({ name: 'git.unstage', + permission: 'workspace', params: GitFilePath, handler: async (params, { runtime }) => runtime.unstageRuntimeGitPath(params.worktree, params.filePath) }), defineMethod({ name: 'git.bulkUnstage', + permission: 'workspace', params: GitBulkPaths, handler: async (params, { runtime }) => runtime.bulkUnstageRuntimeGitPaths(params.worktree, params.filePaths) }), defineMethod({ name: 'git.discard', + permission: 'workspace', params: GitFilePath, handler: async (params, { runtime }) => runtime.discardRuntimeGitPath(params.worktree, params.filePath) }), defineMethod({ name: 'git.bulkDiscard', + permission: 'workspace', params: GitBulkPaths, handler: async (params, { runtime }) => runtime.bulkDiscardRuntimeGitPaths(params.worktree, params.filePaths) }), defineMethod({ name: 'git.remoteFileUrl', + permission: 'workspace', params: GitRemoteFileUrl, handler: async (params, { runtime }) => runtime.getRuntimeGitRemoteFileUrl(params.worktree, params.relativePath, params.line) }), defineMethod({ name: 'git.remoteCommitUrl', + permission: 'workspace', params: GitRemoteCommitUrl, handler: async (params, { runtime }) => runtime.getRuntimeGitRemoteCommitUrl(params.worktree, params.sha) diff --git a/src/main/runtime/rpc/methods/github-account-binding-methods.ts b/src/main/runtime/rpc/methods/github-account-binding-methods.ts index 30f893141c1..26fdab4d6e6 100644 --- a/src/main/runtime/rpc/methods/github-account-binding-methods.ts +++ b/src/main/runtime/rpc/methods/github-account-binding-methods.ts @@ -7,6 +7,7 @@ import { export const GITHUB_ACCOUNT_BINDING_METHODS = [ defineMethod({ name: 'github.listBindableAccounts', + permission: 'workspace', params: BindableAccounts, handler: async (params, { runtime }) => runtime.listGitHubBindableAccounts(params.repo, { @@ -15,6 +16,7 @@ export const GITHUB_ACCOUNT_BINDING_METHODS = [ }), defineMethod({ name: 'github.validateAccountBinding', + permission: 'workspace', params: ValidateAccountBinding, handler: async (params, { runtime }) => runtime.validateGitHubAccountBinding(params.repo, { diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts index 825fe573d2f..42714c18f39 100644 --- a/src/main/runtime/rpc/methods/github-issue-methods.ts +++ b/src/main/runtime/rpc/methods/github-issue-methods.ts @@ -9,11 +9,13 @@ import { export const GITHUB_ISSUE_METHODS = [ defineMethod({ name: 'github.issue', + permission: 'workspace', params: Issue, handler: async (params, { runtime }) => runtime.getRepoIssue(params.repo, params.number) }), defineMethod({ name: 'github.createIssue', + permission: 'workspace', params: CreateIssue, handler: async (params, { runtime }) => { const fields = @@ -27,12 +29,14 @@ export const GITHUB_ISSUE_METHODS = [ }), defineMethod({ name: 'github.updateIssue', + permission: 'workspace', params: UpdateIssue, handler: async (params, { runtime }) => runtime.updateRepoIssue(params.repo, params.number, params.updates) }), defineMethod({ name: 'github.addIssueComment', + permission: 'workspace', params: IssueComment, handler: async (params, { runtime }) => params.type diff --git a/src/main/runtime/rpc/methods/github-project-methods.ts b/src/main/runtime/rpc/methods/github-project-methods.ts index c05086decbf..226bcba015d 100644 --- a/src/main/runtime/rpc/methods/github-project-methods.ts +++ b/src/main/runtime/rpc/methods/github-project-methods.ts @@ -20,81 +20,97 @@ import { export const GITHUB_PROJECT_METHODS = [ defineMethod({ name: 'github.project.listAccessible', + permission: 'workspace', params: GithubProjectListAccessibleParams, handler: async (params, { runtime }) => runtime.listGitHubProjects(params) }), defineMethod({ name: 'github.project.listLabelsBySlug', + permission: 'workspace', params: SlugRepo, handler: async (params, { runtime }) => runtime.listGitHubLabelsBySlug(params) }), defineMethod({ name: 'github.project.listAssignableUsersBySlug', + permission: 'workspace', params: SlugAssignableUsers, handler: async (params, { runtime }) => runtime.listGitHubAssignableUsersBySlug(params) }), defineMethod({ name: 'github.project.listIssueTypesBySlug', + permission: 'workspace', params: SlugRepo, handler: async (params, { runtime }) => runtime.listGitHubIssueTypesBySlug(params) }), defineMethod({ name: 'github.project.resolveRef', + permission: 'workspace', params: ProjectRef, handler: async (params, { runtime }) => runtime.resolveGitHubProjectRef(params) }), defineMethod({ name: 'github.project.listViews', + permission: 'workspace', params: ProjectViews, handler: async (params, { runtime }) => runtime.listGitHubProjectViews(params) }), defineMethod({ name: 'github.project.viewTable', + permission: 'workspace', params: ProjectViewTable, handler: async (params, { runtime }) => runtime.getGitHubProjectViewTable(params) }), defineMethod({ name: 'github.project.workItemDetailsBySlug', + permission: 'workspace', params: ProjectWorkItemDetailsBySlug, handler: async (params, { runtime }) => runtime.getGitHubProjectWorkItemDetailsBySlug(params) }), defineMethod({ name: 'github.project.updateItemField', + permission: 'workspace', params: ProjectItemField, handler: async (params, { runtime }) => runtime.updateGitHubProjectItemField(params) }), defineMethod({ name: 'github.project.clearItemField', + permission: 'workspace', params: ClearProjectItemField, handler: async (params, { runtime }) => runtime.clearGitHubProjectItemField(params) }), defineMethod({ name: 'github.project.updateIssueBySlug', + permission: 'workspace', params: SlugIssueUpdate, handler: async (params, { runtime }) => runtime.updateGitHubIssueBySlug(params) }), defineMethod({ name: 'github.project.updatePullRequestBySlug', + permission: 'workspace', params: SlugPullRequestUpdate, handler: async (params, { runtime }) => runtime.updateGitHubPullRequestBySlug(params) }), defineMethod({ name: 'github.project.updateIssueTypeBySlug', + permission: 'workspace', params: SlugIssueTypeUpdate, handler: async (params, { runtime }) => runtime.updateGitHubIssueTypeBySlug(params) }), defineMethod({ name: 'github.project.addIssueCommentBySlug', + permission: 'workspace', params: SlugIssueComment, handler: async (params, { runtime }) => runtime.addGitHubIssueCommentBySlug(params) }), defineMethod({ name: 'github.project.updateIssueCommentBySlug', + permission: 'workspace', params: SlugIssueCommentEdit, handler: async (params, { runtime }) => runtime.updateGitHubIssueCommentBySlug(params) }), defineMethod({ name: 'github.project.deleteIssueCommentBySlug', + permission: 'workspace', params: SlugIssueCommentDelete, handler: async (params, { runtime }) => runtime.deleteGitHubIssueCommentBySlug(params) }) diff --git a/src/main/runtime/rpc/methods/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-methods.ts index 0a7f2efd522..2b2f66098f6 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-methods.ts @@ -14,6 +14,7 @@ import { export const GITHUB_PULL_REQUEST_METHODS = [ defineMethod({ name: 'github.prForBranch', + permission: 'workspace', params: PrForBranch, handler: async (params, { runtime }) => runtime.getRepoPRForBranch( @@ -28,6 +29,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.prChecks', + permission: 'workspace', params: PullRequestChecks, handler: async (params, { runtime }) => runtime.getRepoPRChecks(params.repo, params.prNumber, params.headSha, params.prRepo ?? null, { @@ -36,6 +38,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.prCheckDetails', + permission: 'workspace', params: PullRequestCheckDetails, handler: async (params, { runtime, signal }) => runtime.getRepoPRCheckDetails( @@ -52,6 +55,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.rerunPRChecks', + permission: 'workspace', params: RerunPullRequestChecks, handler: async (params, { runtime }) => runtime.rerunRepoPRChecks(params.repo, params.prNumber, { @@ -62,6 +66,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.prComments', + permission: 'workspace', params: PullRequest, handler: async (params, { runtime }) => runtime.getRepoPRComments(params.repo, params.prNumber, params.prRepo ?? null, { @@ -70,6 +75,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.setPRCommentReaction', + permission: 'workspace', params: PRCommentReaction, handler: async (params, { runtime }) => runtime.setRepoPRCommentReaction( @@ -82,6 +88,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.prFileContents', + permission: 'workspace', params: PullRequestFileContents, handler: async (params, { runtime }) => runtime.getRepoPRFileContents(params.repo, { @@ -96,6 +103,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.resolveReviewThread', + permission: 'workspace', params: ReviewThread, handler: async (params, { runtime }) => runtime.resolveRepoReviewThread( @@ -107,6 +115,7 @@ export const GITHUB_PULL_REQUEST_METHODS = [ }), defineMethod({ name: 'github.setPRFileViewed', + permission: 'workspace', params: PullRequestFileViewed, handler: async (params, { runtime }) => runtime.setRepoPRFileViewed(params.repo, { diff --git a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts index 59089b82015..e2fbfa5937e 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts @@ -15,12 +15,14 @@ import { export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ defineMethod({ name: 'github.updatePRTitle', + permission: 'workspace', params: UpdatePrTitle, handler: async (params, { runtime }) => runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title, params.prRepo ?? null) }), defineMethod({ name: 'github.updatePR', + permission: 'workspace', params: UpdatePr, handler: async (params, { runtime }) => runtime.updateRepoPRDetails( @@ -32,12 +34,14 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.mergePR', + permission: 'workspace', params: MergePr, handler: async (params, { runtime }) => runtime.mergeRepoPR(params.repo, params.prNumber, params.method, params.prRepo ?? null) }), defineMethod({ name: 'github.setPRAutoMerge', + permission: 'workspace', params: SetPrAutoMerge, handler: async (params, { runtime }) => runtime.setRepoPRAutoMerge( @@ -50,18 +54,21 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.updatePRState', + permission: 'workspace', params: UpdatePrState, handler: async (params, { runtime }) => runtime.updateRepoPRState(params.repo, params.prNumber, params.updates, params.prRepo ?? null) }), defineMethod({ name: 'github.markPRReadyForReview', + permission: 'workspace', params: MarkPrReadyForReview, handler: async (params, { runtime }) => runtime.markRepoPRReadyForReview(params.repo, params.prNumber, params.prRepo ?? null) }), defineMethod({ name: 'github.requestPRReviewers', + permission: 'workspace', params: RequestPrReviewers, handler: async (params, { runtime }) => runtime.requestRepoPRReviewers( @@ -73,6 +80,7 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.removePRReviewers', + permission: 'workspace', params: RemovePrReviewers, handler: async (params, { runtime }) => runtime.removeRepoPRReviewers( @@ -84,6 +92,7 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.addPRReviewComment', + permission: 'workspace', params: PRReviewComment, handler: async (params, { runtime }) => runtime.addRepoPRReviewComment(params.repo, { @@ -98,6 +107,7 @@ export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ }), defineMethod({ name: 'github.addPRReviewCommentReply', + permission: 'workspace', params: PRReviewCommentReply, handler: async (params, { runtime }) => runtime.addRepoPRReviewCommentReply(params.repo, { diff --git a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts index 6b2d83988d7..fabce2b8a0b 100644 --- a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts +++ b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts @@ -13,21 +13,25 @@ import { export const GITHUB_REPO_WORK_ITEM_METHODS = [ defineMethod({ name: 'github.repoSlug', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoSlug(params.repo) }), defineMethod({ name: 'github.repoUpstream', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoUpstream(params.repo) }), defineMethod({ name: 'github.rateLimit', + permission: 'workspace', params: RateLimit, handler: async (params, { runtime }) => runtime.getGitHubRateLimit(params) }), defineMethod({ name: 'github.listWorkItems', + permission: 'workspace', params: WorkItemsList, handler: async (params, { runtime }) => runtime.listRepoWorkItems( @@ -40,32 +44,38 @@ export const GITHUB_REPO_WORK_ITEM_METHODS = [ }), defineMethod({ name: 'github.listIssues', + permission: 'workspace', params: IssuesList, handler: async (params, { runtime }) => runtime.listRepoIssues(params.repo, params.limit) }), defineMethod({ name: 'github.countWorkItems', + permission: 'workspace', params: WorkItemsCount, handler: async (params, { runtime }) => runtime.countRepoWorkItems(params.repo, params.query) }), defineMethod({ name: 'github.listLabels', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.listRepoLabels(params.repo) }), defineMethod({ name: 'github.listAssignableUsers', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.listRepoAssignableUsers(params.repo) }), defineMethod({ name: 'github.workItem', + permission: 'workspace', params: WorkItem, handler: async (params, { runtime }) => runtime.getRepoWorkItem(params.repo, params.number, params.type) }), defineMethod({ name: 'github.workItemByOwnerRepo', + permission: 'workspace', params: WorkItemByOwnerRepo, handler: async (params, { runtime }) => runtime.getRepoWorkItemByOwnerRepo( @@ -81,6 +91,7 @@ export const GITHUB_REPO_WORK_ITEM_METHODS = [ }), defineMethod({ name: 'github.workItemDetails', + permission: 'workspace', params: WorkItemDetails, handler: async (params, { runtime }) => runtime.getRepoWorkItemDetails(params.repo, params.number, params.type) diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index 93f73d5f05c..fdc5cd75397 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -26,6 +26,7 @@ import { export const GITLAB_METHODS = [ defineMethod({ name: 'gitlab.listMRs', + permission: 'workspace', params: WorkItemsList, handler: async (params, { runtime }) => runtime.listGitLabRepoMRs( @@ -38,6 +39,7 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.listWorkItems', + permission: 'workspace', params: WorkItemsList, handler: async (params, { runtime }) => runtime.listGitLabRepoWorkItems( @@ -50,6 +52,7 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.listIssues', + permission: 'workspace', params: IssuesList, handler: async (params, { runtime }) => { const normalized = normalizeGitLabIssueListArgs(params) @@ -64,56 +67,66 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.todos', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.listGitLabRepoTodos(params.repo) }), defineMethod({ name: 'gitlab.diagnoseAuth', + permission: 'workspace', params: EmptyParams, handler: async (_params, { runtime }) => runtime.diagnoseGitLabAuth() }), defineMethod({ name: 'gitlab.rateLimit', + permission: 'workspace', params: GitLabRateLimit, handler: async (params, { runtime }) => runtime.getGitLabRateLimit(params) }), defineMethod({ name: 'gitlab.listLabels', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.listGitLabRepoLabels(params.repo) }), defineMethod({ name: 'gitlab.createIssue', + permission: 'workspace', params: CreateIssue, handler: async (params, { runtime }) => runtime.createGitLabRepoIssue(params.repo, params.title, params.body) }), defineMethod({ name: 'gitlab.updateIssue', + permission: 'workspace', params: UpdateIssue, handler: async (params, { runtime }) => runtime.updateGitLabRepoIssue(params.repo, params.number, params.updates, params.projectRef) }), defineMethod({ name: 'gitlab.addIssueComment', + permission: 'workspace', params: AddIssueComment, handler: async (params, { runtime }) => runtime.addGitLabRepoIssueComment(params.repo, params.number, params.body, params.projectRef) }), defineMethod({ name: 'gitlab.addMRComment', + permission: 'workspace', params: AddMRComment, handler: async (params, { runtime }) => runtime.addGitLabRepoMRComment(params.repo, params.iid, params.body, params.projectRef) }), defineMethod({ name: 'gitlab.addMRInlineComment', + permission: 'workspace', params: AddMRInlineComment, handler: async (params, { runtime }) => runtime.addGitLabRepoMRInlineComment(params.repo, params.iid, params.input, params.projectRef) }), defineMethod({ name: 'gitlab.resolveMRDiscussion', + permission: 'workspace', params: ResolveMRDiscussion, handler: async (params, { runtime }) => runtime.resolveGitLabRepoMRDiscussion( @@ -126,6 +139,7 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.jobTrace', + permission: 'workspace', params: JobTrace, handler: async (params, { runtime }) => { const result = await runtime.getGitLabRepoJobTrace( @@ -138,30 +152,35 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.retryJob', + permission: 'workspace', params: RetryJob, handler: async (params, { runtime }) => runtime.retryGitLabRepoJob(params.repo, params.jobId, params.projectRef) }), defineMethod({ name: 'gitlab.mergeMR', + permission: 'workspace', params: MergeMr, handler: async (params, { runtime }) => runtime.mergeGitLabRepoMR(params.repo, params.iid, params.method, params.projectRef) }), defineMethod({ name: 'gitlab.updateMRState', + permission: 'workspace', params: UpdateMrState, handler: async (params, { runtime }) => runtime.updateGitLabRepoMRState(params.repo, params.iid, params.state, params.projectRef) }), defineMethod({ name: 'gitlab.updateMR', + permission: 'workspace', params: UpdateMr, handler: async (params, { runtime }) => runtime.updateGitLabRepoMR(params.repo, params.iid, params.updates, params.projectRef) }), defineMethod({ name: 'gitlab.updateMRReviewers', + permission: 'workspace', params: UpdateMrReviewers, handler: async (params, { runtime }) => runtime.updateGitLabRepoMRReviewers( @@ -173,12 +192,14 @@ export const GITLAB_METHODS = [ }), defineMethod({ name: 'gitlab.workItemDetails', + permission: 'workspace', params: WorkItemDetails, handler: async (params, { runtime }) => runtime.getGitLabRepoWorkItemDetails(params.repo, params.iid, params.type, params.projectRef) }), defineMethod({ name: 'gitlab.workItemByPath', + permission: 'workspace', params: WorkItemByPath, handler: async (params, { runtime }) => runtime.getGitLabRepoWorkItemByPath( diff --git a/src/main/runtime/rpc/methods/host-capabilities.ts b/src/main/runtime/rpc/methods/host-capabilities.ts index 85a32fd1e5c..e1fa849a662 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.ts @@ -6,6 +6,7 @@ import { isGitBashAvailable } from '../../../git-bash' export const HOST_CAPABILITY_METHODS = [ defineMethod({ name: 'host.platform', + permission: 'workspace', params: null, handler: async () => ({ platform: process.platform }) }), @@ -13,21 +14,25 @@ export const HOST_CAPABILITY_METHODS = [ // execFileSync wsl.exe/pwsh.exe on this host's main event loop for up to 5s per call. defineMethod({ name: 'host.wsl.isAvailable', + permission: 'workspace', params: null, handler: async () => isWslAvailableAsync() }), defineMethod({ name: 'host.wsl.listDistros', + permission: 'workspace', params: null, handler: async () => listWslDistrosAsync() }), defineMethod({ name: 'host.pwsh.isAvailable', + permission: 'workspace', params: null, handler: async () => isPwshAvailableAsync() }), defineMethod({ name: 'host.gitBash.isAvailable', + permission: 'workspace', params: null, handler: async () => isGitBashAvailable() }) diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index c2f071d2cbb..ed547c5912a 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -10,6 +10,7 @@ import { export const HOSTED_REVIEW_METHODS = [ defineMethod({ name: 'hostedReview.forBranch', + permission: 'workspace', params: HostedReviewForBranch, handler: async (params, { runtime }) => { const fallbackGitHubPR = @@ -32,6 +33,7 @@ export const HOSTED_REVIEW_METHODS = [ }), defineMethod({ name: 'hostedReview.getCreationEligibility', + permission: 'workspace', params: HostedReviewCreationEligibility, handler: async (params, { runtime }) => { const fallbackGitHubPR = @@ -56,6 +58,7 @@ export const HOSTED_REVIEW_METHODS = [ }), defineMethod({ name: 'hostedReview.create', + permission: 'workspace', params: HostedReviewCreate, handler: async (params, { runtime }) => { // The wire carries the host's own provider token, so this is where an arm this build does @@ -78,6 +81,7 @@ export const HOSTED_REVIEW_METHODS = [ }), defineMethod({ name: 'hostedReview.createStacked', + permission: 'workspace', params: HostedReviewCreate, handler: async (params, { runtime }) => { if (!supportsHostedReviewCreation(params.provider)) { diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 0e959cf0f5e..dc13c8ead0b 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -37,6 +37,7 @@ function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void export const JIRA_METHODS = [ defineMethod({ name: 'jira.connect', + permission: 'accounts-admin', params: Connect, handler: async (params, { runtime }) => runtime.jiraConnect({ @@ -48,48 +49,57 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.disconnect', + permission: 'accounts-admin', params: SiteSelection, handler: async (params, { runtime }) => runtime.jiraDisconnect(params?.siteId) }), defineMethod({ name: 'jira.selectSite', + permission: 'accounts-admin', params: SelectSite, handler: async (params, { runtime }) => runtime.jiraSelectSite(params.siteId.trim()) }), defineMethod({ name: 'jira.status', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.jiraStatus() }), defineMethod({ name: 'jira.readStatus', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.jiraReadStatus() }), defineMethod({ name: 'jira.testConnection', + permission: 'workspace', params: SiteSelection, handler: async (params, { runtime }) => runtime.jiraTestConnection(params?.siteId) }), defineMethod({ name: 'jira.searchIssues', + permission: 'workspace', params: SearchIssues, handler: async (params, { runtime, signal }) => runtime.jiraSearchIssues(params.jql, params.limit, params.siteId, signal) }), defineMethod({ name: 'jira.listIssues', + permission: 'workspace', params: ListIssues, handler: async (params, { runtime }) => runtime.jiraListIssues(params?.filter, params?.limit, params?.siteId) }), defineMethod({ name: 'jira.getIssue', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }) => runtime.jiraGetIssue(params.key.trim(), params.siteId) }), defineMethod({ name: 'jira.lookupIssueSummary', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime, signal }) => { if (!params.siteId) { @@ -100,6 +110,7 @@ export const JIRA_METHODS = [ }), defineStreamingMethod({ name: 'jira.getIssueStream', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }, emit) => { emitJiraPayload(await runtime.jiraGetIssue(params.key.trim(), params.siteId), emit) @@ -107,6 +118,7 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.createIssue', + permission: 'workspace', params: CreateIssue, handler: async (params, { runtime }) => runtime.jiraCreateIssue({ @@ -121,24 +133,28 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.updateIssue', + permission: 'workspace', params: IssueUpdate, handler: async (params, { runtime }) => runtime.jiraUpdateIssue(params.key.trim(), params.updates, params.siteId) }), defineMethod({ name: 'jira.addIssueComment', + permission: 'workspace', params: IssueComment, handler: async (params, { runtime }) => runtime.jiraAddIssueComment(params.key.trim(), params.body.trim(), params.siteId) }), defineMethod({ name: 'jira.issueComments', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }) => runtime.jiraIssueComments(params.key.trim(), params.siteId) }), defineStreamingMethod({ name: 'jira.issueCommentsStream', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }, emit) => { emitJiraPayload(await runtime.jiraIssueComments(params.key.trim(), params.siteId), emit) @@ -146,17 +162,20 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.listProjects', + permission: 'workspace', params: SiteSelection, handler: async (params, { runtime }) => runtime.jiraListProjects(params?.siteId) }), defineMethod({ name: 'jira.listIssueTypes', + permission: 'workspace', params: ProjectIssueTypes, handler: async (params, { runtime }) => runtime.jiraListIssueTypes(params.projectIdOrKey.trim(), params.siteId) }), defineMethod({ name: 'jira.listCreateFields', + permission: 'workspace', params: ProjectIssueTypeFields, handler: async (params, { runtime }) => runtime.jiraListCreateFields( @@ -167,28 +186,33 @@ export const JIRA_METHODS = [ }), defineMethod({ name: 'jira.listPriorities', + permission: 'workspace', params: SiteSelection, handler: async (params, { runtime }) => runtime.jiraListPriorities(params?.siteId) }), defineMethod({ name: 'jira.listAssignableUsers', + permission: 'workspace', params: AssignableUsers, handler: async (params, { runtime }) => runtime.jiraListAssignableUsers(params.key.trim(), params.query, params.siteId) }), defineMethod({ name: 'jira.searchUsers', + permission: 'workspace', params: UserSearch, handler: async (params, { runtime }) => runtime.jiraSearchUsers(params.query, params.siteId) }), defineMethod({ name: 'jira.listTransitions', + permission: 'workspace', params: IssueKey, handler: async (params, { runtime }) => runtime.jiraListTransitions(params.key.trim(), params.siteId) }), defineMethod({ name: 'jira.getProjectStatusOrder', + permission: 'workspace', params: ProjectStatusOrder, handler: async (params, { runtime }) => runtime.jiraGetProjectStatusOrder(params.projectKey.trim(), params.siteId) diff --git a/src/main/runtime/rpc/methods/linear-agent-access.ts b/src/main/runtime/rpc/methods/linear-agent-access.ts index 2c46face39d..1c8d6c177fe 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.ts @@ -31,12 +31,14 @@ function parseLinearWriteId(writeId: string | undefined): string | undefined { export const LINEAR_AGENT_ACCESS_METHODS = [ defineMethod({ name: 'linear.saveIssue', + permission: 'workspace', params: LinearSaveIssue, handler: async (params, { runtime }) => runtime.linearSaveIssue({ ...params, writeId: parseLinearWriteId(params.writeId) }) }), defineMethod({ name: 'linear.agentSearchIssues', + permission: 'workspace', params: AgentSearchIssues, handler: async (params, { runtime }) => runtime.linearSearchForAgents({ @@ -47,73 +49,87 @@ export const LINEAR_AGENT_ACCESS_METHODS = [ }), defineMethod({ name: 'linear.issueContext', + permission: 'workspace', params: AgentIssueContext, handler: async (params, { runtime }) => runtime.linearIssueContext(params) }), defineMethod({ name: 'linear.agentTeamList', + permission: 'workspace', params: LinearWorkspaceRead, handler: async (params, { runtime }) => runtime.linearTeamListForAgents(params) }), defineMethod({ name: 'linear.agentTeamMembers', + permission: 'workspace', params: LinearTeamLookup, handler: async (params, { runtime }) => runtime.linearTeamMembersForAgents(params) }), defineMethod({ name: 'linear.agentTeamStates', + permission: 'workspace', params: LinearTeamLookup, handler: async (params, { runtime }) => runtime.linearTeamStatesForAgents(params) }), defineMethod({ name: 'linear.agentTeamLabels', + permission: 'workspace', params: LinearTeamLookup, handler: async (params, { runtime }) => runtime.linearTeamLabelsForAgents(params) }), defineMethod({ name: 'linear.agentIssueList', + permission: 'workspace', params: LinearIssueList, handler: async (params, { runtime }) => runtime.linearIssueListForAgents(params) }), defineMethod({ name: 'linear.agentProjectList', + permission: 'workspace', params: LinearProjectList, handler: async (params, { runtime }) => runtime.linearProjectListForAgents(params) }), defineMethod({ name: 'linear.resolveCurrentIssue', + permission: 'workspace', params: LinearCurrentContext, handler: async (params, { runtime }) => runtime.linearResolveCurrentIssue(params) }), defineMethod({ name: 'linear.issueSetState', + permission: 'workspace', params: LinearIssueSetState, handler: async (params, { runtime }) => runtime.linearIssueSetState(params) }), defineMethod({ name: 'linear.issueUpdateTask', + permission: 'workspace', params: LinearIssueUpdateTask, handler: async (params, { runtime }) => runtime.linearIssueUpdateTask(params) }), defineMethod({ name: 'linear.issueRelationWrite', + permission: 'workspace', params: LinearIssueRelationWrite, handler: async (params, { runtime }) => runtime.linearIssueRelationWrite(params) }), defineMethod({ name: 'linear.issueAddComment', + permission: 'workspace', params: LinearIssueAddComment, handler: async (params, { runtime }) => runtime.linearIssueAddComment({ ...params, writeId: parseLinearWriteId(params.writeId) }) }), defineMethod({ name: 'linear.issueAttachLink', + permission: 'workspace', params: LinearIssueAttachLink, handler: async (params, { runtime }) => runtime.linearIssueAttachLink({ ...params, writeId: parseLinearWriteId(params.writeId) }) }), defineMethod({ name: 'linear.issueCreate', + permission: 'workspace', params: LinearIssueCreate, handler: async (params, { runtime }) => runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) }) diff --git a/src/main/runtime/rpc/methods/linear-issue-list-method.ts b/src/main/runtime/rpc/methods/linear-issue-list-method.ts index 9dd838c837a..e69719d2c6e 100644 --- a/src/main/runtime/rpc/methods/linear-issue-list-method.ts +++ b/src/main/runtime/rpc/methods/linear-issue-list-method.ts @@ -4,6 +4,7 @@ import { ListIssues, McpListIssues } from '../../../../shared/rpc-contract/linea export const LINEAR_ISSUE_LIST_METHOD = defineMethod({ name: 'linear.listIssues', + permission: 'workspace', params: ListIssues, handler: async (params, { runtime }) => { if (isMcpIssueListRequest(params)) { @@ -17,6 +18,7 @@ export const LINEAR_ISSUE_LIST_METHOD = defineMethod({ export const LINEAR_MCP_ISSUE_LIST_METHOD = defineMethod({ name: 'linear.mcpListIssues', + permission: 'workspace', params: McpListIssues, handler: async (params, { runtime }) => runtime.linearMcpIssueList(params) }) diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts index 8377b69acdf..481ed9fd0cc 100644 --- a/src/main/runtime/rpc/methods/linear-project-create.ts +++ b/src/main/runtime/rpc/methods/linear-project-create.ts @@ -3,6 +3,7 @@ import { CreateProject } from '../../../../shared/rpc-contract/linear-project-cr export const LINEAR_PROJECT_CREATE_METHOD = defineMethod({ name: 'linear.createProject', + permission: 'workspace', params: CreateProject, handler: async (params, { runtime }) => runtime.linearCreateProject( diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index 456b4c5b4e9..179d7610c98 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -23,31 +23,37 @@ import { export const LINEAR_METHODS = [ defineMethod({ name: 'linear.connect', + permission: 'accounts-admin', params: Connect, handler: async (params, { runtime }) => runtime.linearConnect(params.apiKey.trim()) }), defineMethod({ name: 'linear.disconnect', + permission: 'accounts-admin', params: WorkspaceSelection, handler: async (params, { runtime }) => runtime.linearDisconnect(params?.workspaceId) }), defineMethod({ name: 'linear.selectWorkspace', + permission: 'accounts-admin', params: SelectWorkspace, handler: async (params, { runtime }) => runtime.linearSelectWorkspace(params.workspaceId.trim()) }), defineMethod({ name: 'linear.status', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.linearStatus() }), defineMethod({ name: 'linear.testConnection', + permission: 'workspace', params: WorkspaceSelection, handler: async (params, { runtime }) => runtime.linearTestConnection(params?.workspaceId) }), defineMethod({ name: 'linear.searchIssues', + permission: 'workspace', params: SearchIssues, handler: async (params, { runtime }) => runtime.linearSearchIssues(params.query, params.limit, params.workspaceId) @@ -56,6 +62,7 @@ export const LINEAR_METHODS = [ LINEAR_MCP_ISSUE_LIST_METHOD, defineMethod({ name: 'linear.createIssue', + permission: 'workspace', params: CreateIssue, handler: async (params, { runtime }) => runtime.linearCreateIssue( @@ -75,35 +82,41 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.getIssue', + permission: 'workspace', params: IssueId, handler: async (params, { runtime }) => runtime.linearGetIssue(params.id.trim(), params.workspaceId) }), defineMethod({ name: 'linear.updateIssue', + permission: 'workspace', params: IssueUpdate, handler: async (params, { runtime }) => runtime.linearUpdateIssue(params.id.trim(), params.updates, params.workspaceId) }), defineMethod({ name: 'linear.addIssueComment', + permission: 'workspace', params: IssueComment, handler: async (params, { runtime }) => runtime.linearAddIssueComment(params.issueId.trim(), params.body.trim(), params.workspaceId) }), defineMethod({ name: 'linear.issueComments', + permission: 'workspace', params: LinearIssueCommentsParams, handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.listTeams', + permission: 'workspace', params: WorkspaceSelection, handler: async (params, { runtime }) => runtime.linearListTeams(params?.workspaceId) }), defineMethod({ name: 'linear.listProjects', + permission: 'workspace', params: ListProjects, handler: async (params, { runtime }) => runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId, params?.force) @@ -111,12 +124,14 @@ export const LINEAR_METHODS = [ LINEAR_PROJECT_CREATE_METHOD, defineMethod({ name: 'linear.getProject', + permission: 'workspace', params: ProjectId, handler: async (params, { runtime }) => runtime.linearGetProject(params.id.trim(), params.workspaceId.trim(), params.force) }), defineMethod({ name: 'linear.listProjectIssues', + permission: 'workspace', params: ProjectIssues, handler: async (params, { runtime }) => runtime.linearListProjectIssues( @@ -128,12 +143,14 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.listCustomViews', + permission: 'workspace', params: ListCustomViews, handler: async (params, { runtime }) => runtime.linearListCustomViews(params.model, params.limit, params.workspaceId, params.force) }), defineMethod({ name: 'linear.getCustomView', + permission: 'workspace', params: CustomViewId, handler: async (params, { runtime }) => runtime.linearGetCustomView( @@ -145,6 +162,7 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.listCustomViewIssues', + permission: 'workspace', params: CustomViewContents, handler: async (params, { runtime }) => runtime.linearListCustomViewIssues( @@ -156,6 +174,7 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.listCustomViewProjects', + permission: 'workspace', params: CustomViewContents, handler: async (params, { runtime }) => runtime.linearListCustomViewProjects( @@ -167,18 +186,21 @@ export const LINEAR_METHODS = [ }), defineMethod({ name: 'linear.teamStates', + permission: 'workspace', params: TeamId, handler: async (params, { runtime }) => runtime.linearTeamStates(params.teamId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.teamLabels', + permission: 'workspace', params: TeamId, handler: async (params, { runtime }) => runtime.linearTeamLabels(params.teamId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.teamMembers', + permission: 'workspace', params: TeamId, handler: async (params, { runtime }) => runtime.linearTeamMembers(params.teamId.trim(), params.workspaceId) diff --git a/src/main/runtime/rpc/methods/managed-server.ts b/src/main/runtime/rpc/methods/managed-server.ts index 95902891bae..aace76a8551 100644 --- a/src/main/runtime/rpc/methods/managed-server.ts +++ b/src/main/runtime/rpc/methods/managed-server.ts @@ -22,32 +22,38 @@ function actions(): ManagedServerActions { export const MANAGED_SERVER_METHODS = [ defineMethod({ name: 'managedServer.status', + permission: 'workspace', params: ManagedServerSelector, handler: ({ selector }) => actions().status(selector) }), defineMethod({ name: 'managedServer.update', + permission: 'host-admin', params: ManagedServerUpdate, handler: ({ selector, force }) => actions().update(selector, force === true) }), defineMethod({ name: 'managedServer.rollback', + permission: 'host-admin', params: ManagedServerSelector, handler: ({ selector }) => actions().rollback(selector) }), defineMethod({ name: 'managedServer.recover', + permission: 'host-admin', params: ManagedServerRecover, handler: ({ selector, acceptChangedState }) => actions().recover(selector, acceptChangedState === true) }), defineMethod({ name: 'managedServer.stop', + permission: 'host-admin', params: ManagedServerSelector, handler: ({ selector }) => actions().stop(selector) }), defineMethod({ name: 'managedServer.cancelStop', + permission: 'host-admin', params: ManagedServerSelector, handler: ({ selector }) => actions().cancelStop(selector) }) diff --git a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts index 756d4719f22..c9eb997f6cd 100644 --- a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts +++ b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts @@ -4,12 +4,14 @@ import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' export const MOBILE_MARKDOWN_TAB_METHODS = [ defineMethod({ name: 'markdown.readTab', + permission: 'workspace', params: ActivateTab, handler: async (params, { runtime }) => runtime.readMobileMarkdownTab(params.worktree, params.tabId) }), defineMethod({ name: 'markdown.saveTab', + permission: 'workspace', params: SaveMarkdownTab, handler: async (params, { runtime }) => runtime.saveMobileMarkdownTab( diff --git a/src/main/runtime/rpc/methods/mobile-web-bundle.ts b/src/main/runtime/rpc/methods/mobile-web-bundle.ts index f88938373b3..5f879bf20ff 100644 --- a/src/main/runtime/rpc/methods/mobile-web-bundle.ts +++ b/src/main/runtime/rpc/methods/mobile-web-bundle.ts @@ -161,6 +161,7 @@ async function readVerifiedWindow( export const MOBILE_WEB_BUNDLE_METHODS = [ defineMethod({ name: MOBILE_WEB_BUNDLE_MANIFEST_METHOD, + permission: 'workspace', params: null, handler: async (): Promise => ({ manifest: requireBundle().manifest, @@ -170,6 +171,7 @@ export const MOBILE_WEB_BUNDLE_METHODS = [ }), defineMethod({ name: MOBILE_WEB_BUNDLE_CHUNK_METHOD, + permission: 'workspace', params: MobileWebBundleChunkParamsSchema, handler: (params, ctx): Promise => readVerifiedWindow(ctx, params, MOBILE_WEB_BUNDLE_CHUNK_BYTES, async (window) => ({ @@ -179,6 +181,7 @@ export const MOBILE_WEB_BUNDLE_METHODS = [ }), defineMethod({ name: MOBILE_WEB_BUNDLE_RANGE_METHOD, + permission: 'workspace', params: MobileWebBundleRangeParamsSchema, handler: (params, ctx): Promise => readVerifiedWindow(ctx, params, MOBILE_WEB_BUNDLE_RANGE_BYTES, async (window) => { diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 296d55e3b6e..1cc734b0197 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -77,6 +77,7 @@ function pageForClient( export const NATIVE_CHAT_METHODS = [ defineMethod({ name: 'nativeChat.readSession', + permission: 'workspace', params: NativeChatSession, handler: async (params, { runtime, clientKind, signal }) => { const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW @@ -111,6 +112,7 @@ export const NATIVE_CHAT_METHODS = [ }), defineStreamingMethod({ name: 'nativeChat.subscribe', + permission: 'workspace', params: NativeChatSession, handler: async (params, { runtime, connectionId, clientKind, signal }, emit) => { if (signal?.aborted) { @@ -237,6 +239,7 @@ export const NATIVE_CHAT_METHODS = [ }), defineMethod({ name: 'nativeChat.unsubscribe', + permission: 'workspace', params: NativeChatUnsubscribe, handler: async (params, { runtime, connectionId }) => { const connection = connectionId ?? 'local' diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 7df66b4acf4..cca473aa975 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -16,6 +16,7 @@ let notificationsSubscriptionSeq = 0 export const NOTIFICATION_METHODS = [ defineStreamingMethod({ name: 'notifications.subscribe', + permission: 'workspace', params: NotificationsSubscribeParams, handler: async (params, { runtime, connectionId }, emit) => { const shouldEmit = createNotificationStreamFilter(params?.includeDesktopSuppressed) @@ -48,6 +49,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.unsubscribe', + permission: 'workspace', params: NotificationUnsubscribeParams, handler: async (params, { runtime }) => { runtime.cleanupSubscription(params.subscriptionId) @@ -56,6 +58,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.getMissedSince', + permission: 'workspace', params: NotificationGetMissedSinceParams, // Why: returns only notifications with seq > lastSeenSeq. The runtime owns // the monotonic seq, so this is the single source of truth for what the @@ -75,6 +78,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.registerPush', + permission: 'pairing-admin', params: NotificationRegisterPushParams, // Why: the registration is keyed by the revocable paired device identity, never // by anything the caller can assert, so an in-process or CLI caller has no device @@ -89,6 +93,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.testPush', + permission: 'pairing-admin', params: null, handler: async (_params, { runtime, clientKind, pairedDeviceId }) => { if (clientKind !== 'mobile' || !pairedDeviceId) { @@ -99,6 +104,7 @@ export const NOTIFICATION_METHODS = [ }), defineMethod({ name: 'notifications.unregisterPush', + permission: 'pairing-admin', params: null, // Deleting the gateway token is durable (outbox), so an offline gateway still // reports success to the phone that asked to stop being pushed to. diff --git a/src/main/runtime/rpc/methods/orcad-migration.ts b/src/main/runtime/rpc/methods/orcad-migration.ts index 69c24d294e8..be09836eb4e 100644 --- a/src/main/runtime/rpc/methods/orcad-migration.ts +++ b/src/main/runtime/rpc/methods/orcad-migration.ts @@ -8,6 +8,7 @@ import { defineMethod, type RpcContext } from '../core' export const ORCAD_MIGRATION_METHODS = [ defineMethod({ name: 'orcad.migration.stageCatalog', + permission: 'host-admin', params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) @@ -18,6 +19,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.commitCatalog', + permission: 'host-admin', params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) @@ -28,6 +30,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.stageSnapshotChunk', + permission: 'host-admin', params: OrcadMigrationSnapshotChunkRequestSchema, handler: async (params, context) => { requireMigrationRuntimeClient(context) @@ -36,6 +39,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.abortCatalog', + permission: 'host-admin', params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) @@ -46,6 +50,7 @@ export const ORCAD_MIGRATION_METHODS = [ }), defineMethod({ name: 'orcad.migration.catalogState', + permission: 'host-admin', params: OrcadMigrationCatalogParams, handler: async (params, context) => { requireMigrationRuntimeClient(context) diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.ts index f48c553156d..f32c443577b 100644 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.ts +++ b/src/main/runtime/rpc/methods/orcad-terminal-census.ts @@ -8,6 +8,7 @@ import { export const ORCAD_TERMINAL_CENSUS_METHODS = [ defineMethod({ name: ORCAD_TERMINAL_CENSUS_METHOD, + permission: 'host-admin', params: OrcadTerminalCensusParamsSchema, handler: async (params, { runtime }) => { // Why lazy: the census reaches the daemon modules, whose xterm polyfill defines a global diff --git a/src/main/runtime/rpc/methods/orchestration/caller-show.ts b/src/main/runtime/rpc/methods/orchestration/caller-show.ts index e7d801f50be..0d2240770cb 100644 --- a/src/main/runtime/rpc/methods/orchestration/caller-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/caller-show.ts @@ -21,6 +21,7 @@ import { defineMethod } from '../../core' export const ORCHESTRATION_CALLER_METHODS = [ defineMethod({ name: 'orchestration.callerShow', + permission: 'workspace', params: null, // Why no params: the dispatch entry already resolved the session the caller's environment names, // and a session it cannot admit never reaches here: its refusal is the answer. @@ -32,6 +33,7 @@ export const ORCHESTRATION_CALLER_METHODS = [ }), defineMethod({ name: 'orchestration.sessionAddress', + permission: 'workspace', params: SessionAddressParams, // Why host-side: only the host's session records know a chat's `/clear` root. handler: (params, { runtime }): OrchestrationSessionAddressResult => { @@ -48,6 +50,7 @@ export const ORCHESTRATION_CALLER_METHODS = [ }), defineMethod({ name: 'orchestration.partyLocation', + permission: 'workspace', params: PartyLocationParams, // Why host-side: a chat sender's live session follows its `/clear` lineage on this host. handler: (params, { runtime }): OrchestrationPartyLocationResult => diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts index caec51d462e..2b9996b7f76 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts @@ -19,6 +19,7 @@ import { export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.federationFleetSnapshot', + permission: 'workspace', params: FederationFleetSnapshotParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { const items = await mapWithConcurrency(params.dispatchIds, 16, async (dispatchId) => { @@ -41,6 +42,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationRelease', + permission: 'workspace', params: FederationDispatchParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { const attachment = requireHomeAttachment( @@ -54,6 +56,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationShow', + permission: 'workspace', params: FederationDispatchParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { const attachment = requireHomeAttachment( @@ -78,6 +81,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationRead', + permission: 'workspace', params: FederationReadParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) @@ -103,6 +107,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationReadOutput', + permission: 'workspace', params: FederationOutputReadParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { const attachment = requireHomeAttachment( @@ -186,6 +191,7 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.federationStop', + permission: 'workspace', params: FederationDispatchParams, handler: async (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts index 721232f194a..16b4c11f5d6 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts @@ -16,6 +16,7 @@ import { export const ORCHESTRATION_FEDERATION_RELAY_METHODS = [ defineMethod({ name: 'orchestration.federationPull', + permission: 'workspace', params: FederationPullParams, handler: (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) @@ -37,6 +38,7 @@ export const ORCHESTRATION_FEDERATION_RELAY_METHODS = [ }), defineMethod({ name: 'orchestration.federationAck', + permission: 'workspace', params: FederationAckParams, handler: (params, { runtime, authenticatedCallerFingerprint }) => { requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) @@ -101,6 +103,7 @@ export const ORCHESTRATION_FEDERATION_RELAY_METHODS = [ }), defineMethod({ name: 'orchestration.federationImport', + permission: 'workspace', params: FederationImportParams, handler: (params, { runtime, authenticatedCallerFingerprint }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 0e5f3fedf11..2fb84779c6e 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -32,6 +32,7 @@ import { prepareFederatedAttachmentAuthority } from './federation-attachment-aut export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ defineMethod({ name: 'orchestration.federationAttachStart', + permission: 'workspace', params: FederationAttachStartParams, handler: async (params, { runtime, orchestrationMutation }) => { if (!orchestrationMutation) { diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts index f0736db916c..8efdf234f3e 100644 --- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts +++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts @@ -23,6 +23,7 @@ export const ORCHESTRATION_GATE_METHODS = [ // CLI (or any caller) for the entire duration of the pipeline. defineMethod({ name: 'orchestration.run', + permission: 'workspace', params: RunParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -64,6 +65,7 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.runStop', + permission: 'workspace', params: RunStopParams, handler: (_params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -83,6 +85,7 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateCreate', + permission: 'workspace', params: GateCreateParams, handler: ( params, @@ -130,6 +133,7 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateResolve', + permission: 'workspace', params: GateResolveParams, handler: ( params, @@ -162,6 +166,7 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateList', + permission: 'workspace', params: GateListParams, handler: ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts index 25b8058e5df..96919bae3c1 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts @@ -16,6 +16,7 @@ import { assertWorkerCanReport } from '../../../../orchestration/worker-report-a export const ORCHESTRATION_ASK_METHODS = [ defineMethod({ name: 'orchestration.ask', + permission: 'workspace', params: AskParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index b9aa437e179..5693320cbcc 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -17,6 +17,7 @@ import { export const ORCHESTRATION_CHECK_METHODS = [ defineMethod({ name: 'orchestration.check', + permission: 'workspace', params: CheckParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts index e079596f80d..507fef56b2f 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts @@ -24,6 +24,7 @@ import { export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.reply', + permission: 'workspace', params: ReplyParams, handler: async ( params, @@ -139,6 +140,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.inbox', + permission: 'workspace', params: InboxParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -155,6 +157,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskCreate', + permission: 'workspace', params: TaskCreateParams, handler: ( params, @@ -203,6 +206,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskList', + permission: 'workspace', params: TaskListParams, handler: ( params, @@ -245,6 +249,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskUpdate', + permission: 'workspace', params: TaskUpdateParams, handler: ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index a98e016da75..35ca1237227 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -32,6 +32,7 @@ import { assertLifecycleCallerIsNotAnotherParty } from './lifecycle-caller-fence export const ORCHESTRATION_SEND_METHODS = [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: SendParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/terminal-owned-mailboxes.ts b/src/main/runtime/rpc/methods/orchestration/messaging/terminal-owned-mailboxes.ts new file mode 100644 index 00000000000..27b5aa95282 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/terminal-owned-mailboxes.ts @@ -0,0 +1,45 @@ +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { DispatchContextRow } from '../../../../orchestration/types' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { hasRunBindingKey } from '../../../../orchestration/orchestration-caller-identity' +import { orchestrationCallerIdentity } from '../runs/run-scope' +import { callerHoldsDispatchPane } from './dispatch-mailbox-fence' + +export type TerminalOwnedMailboxes = { + /** The canonical addresses this terminal's live pane reads: itself, its bound Run, its Dispatch. */ + addresses: ReadonlySet + /** The Dispatch the live pane and process currently hold. */ + dispatch: DispatchContextRow | undefined + /** The Run the live pane currently coordinates. */ + runId: string | undefined +} + +/** + * The mailboxes `check` reads for this terminal, validated against its live pane and process. + * Why: routing rewrites a handle to `run:`/`dispatch:`, so owning mail means owning those exact rows. + */ +export function resolveTerminalOwnedMailboxes( + runtime: OrcaRuntimeService, + db: OrchestrationDb, + handle: string +): TerminalOwnedMailboxes { + const addresses = new Set([handle]) + const paneKey = runtime.getLiveTerminalPaneKey(handle) ?? undefined + const caller = orchestrationCallerIdentity(runtime, { handle, paneKey, session: undefined }) + const run = hasRunBindingKey(caller) ? db.getCurrentRunForCoordinator(caller) : undefined + if (run) { + addresses.add(`run:${run.id}`) + } + const candidate = paneKey ? db.getActiveDispatchForIdentity(handle, paneKey) : undefined + const dispatch = + candidate && + callerHoldsDispatchPane(candidate, paneKey) && + (candidate.process_incarnation === null || + runtime.getTerminalProcessIncarnation(handle) === candidate.process_incarnation) + ? candidate + : undefined + if (dispatch) { + addresses.add(`dispatch:${dispatch.id}`) + } + return { addresses, dispatch, runId: run?.id } +} diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index 6834aeb22b9..041ae7fff38 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -25,6 +25,7 @@ import { dispatchTaskSource } from '../../../../orchestration/dispatch-task-sour export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ name: 'orchestration.dispatch', + permission: 'workspace', params: DispatchParams, handler: async ( params, @@ -223,6 +224,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ name: 'orchestration.dispatchShow', + permission: 'workspace', params: DispatchShowParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts index a1cf43ab424..689b03498e3 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts @@ -8,6 +8,7 @@ import { RequestShowParams } from '../../../../../../shared/rpc-contract/orchest export const ORCHESTRATION_MUTATION_REQUEST_METHODS = [ defineMethod({ name: 'orchestration.requestShow', + permission: 'workspace', params: RequestShowParams, // Why: recovery needs a way to ask whether a mutation landed without mutating // again; this reads the durable receipt and never writes one, so it must stay diff --git a/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts index 6946606651f..36141403707 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts @@ -4,6 +4,7 @@ import { ResetParams } from '../schemas' export const ORCHESTRATION_RESET_METHODS = [ defineMethod({ name: 'orchestration.reset', + permission: 'workspace', params: ResetParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index 88653af8ecd..6af5ac6a2ed 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -28,6 +28,7 @@ function cancelBoundDispatchWaiters( export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCreate', + permission: 'workspace', params: RunCreateParams, handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { const caller = resolveOrchestrationCaller(runtime, { @@ -54,6 +55,7 @@ export const ORCHESTRATION_RUN_METHODS = [ }), defineMethod({ name: 'orchestration.runUse', + permission: 'workspace', params: RunUseParams, handler: ( params, @@ -112,6 +114,7 @@ export const ORCHESTRATION_RUN_METHODS = [ }), defineMethod({ name: 'orchestration.runCurrent', + permission: 'workspace', params: RunCurrentParams, handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { const caller = resolveOrchestrationCaller(runtime, { @@ -126,6 +129,7 @@ export const ORCHESTRATION_RUN_METHODS = [ }), defineMethod({ name: 'orchestration.runList', + permission: 'workspace', params: RunListParams, handler: (params, { runtime }) => { const listed = runtime.getOrchestrationDb().listRuns(params) @@ -134,6 +138,7 @@ export const ORCHESTRATION_RUN_METHODS = [ }), defineMethod({ name: 'orchestration.runShow', + permission: 'workspace', params: RunShowParams, handler: (params, { runtime }) => { const run = runtime.getOrchestrationDb().getRun(params.id) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index 1dff6d9a6b6..7482f573ccc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -26,6 +26,7 @@ import { export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.workerShow', + permission: 'workspace', params: WorkerDispatchParams, handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -79,6 +80,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.workerRead', + permission: 'workspace', params: WorkerReadParams, handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -199,6 +201,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ }), defineMethod({ name: 'orchestration.workerAbandon', + permission: 'workspace', params: WorkerDispatchParams, handler: (params, { runtime, orchestrationCaller }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts index 9c77fd8895f..315a8cffdf4 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts @@ -27,6 +27,7 @@ import { WORKER_TERMINAL_LIST_STATES, WorkerListParams } from './worker-release- export const ORCHESTRATION_WORKER_LIST_METHOD = defineMethod({ name: 'orchestration.workerList', + permission: 'workspace', params: WorkerListParams, handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts index f641485e757..976df7ba83d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts @@ -14,6 +14,7 @@ import { OrchestrationWorkerTerminalUserInputParams } from '../../../../../../sh export const ORCHESTRATION_WORKER_RELEASE_METHODS = [ defineMethod({ name: 'orchestration.workerRelease', + permission: 'workspace', params: WorkerDispatchParams, handler: async (params, { runtime, orchestrationMutation }): Promise => { const db = runtime.getOrchestrationDb() @@ -85,6 +86,7 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS = [ }), defineMethod({ name: 'orchestration.workerRetain', + permission: 'workspace', params: WorkerRetainParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() @@ -132,6 +134,7 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS = [ ORCHESTRATION_WORKER_LIST_METHOD, defineMethod({ name: 'orchestration.workerTerminalUserInput', + permission: 'workspace', // `sessionId` addresses a worker that IS a structured agent session. Its pane key is a random // identity credential that never leaves main, so the caller names the session and the owning // runtime resolves it — a renderer echoing the pane key back would make it learnable. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts index 57b12b6da6b..dbb94c2726f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts @@ -15,6 +15,7 @@ import { WorkerDispatchParams } from '../../../../../../shared/rpc-contract/orch export const ORCHESTRATION_WORKER_STOP_METHODS = [ defineMethod({ name: 'orchestration.workerStop', + permission: 'workspace', params: WorkerDispatchParams, handler: (params, { runtime, orchestrationMutation }) => dedupeWorkerStop(runtime, params.dispatch, async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index 5dd3f0c598b..3d6a2b24a5a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -19,6 +19,7 @@ import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as SESSION_CODES } from '../.. export const ORCHESTRATION_WORKER_START_METHODS = [ defineMethod({ name: 'orchestration.workerStart', + permission: 'workspace', params: WorkerStartParams, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index 5a32ddab62f..775bcea7580 100644 --- a/src/main/runtime/rpc/methods/pairing.ts +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -7,6 +7,7 @@ import { export const PAIRING_METHODS = [ defineMethod({ name: 'pairing.getEndpoints', + permission: 'pairing-admin', params: PairingGetEndpointsParamsSchema, handler: async (params, ctx) => { if (!ctx.pairing) { @@ -17,6 +18,7 @@ export const PAIRING_METHODS = [ }), defineMethod({ name: 'pairing.provisionRelay', + permission: 'pairing-admin', params: PairingProvisionRelayParamsSchema, handler: async (params, ctx) => { if (!ctx.pairing) { diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts index 667aff9179d..869783f5a39 100644 --- a/src/main/runtime/rpc/methods/plugins.ts +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -68,6 +68,7 @@ function bindRpcPanelOwner(service: PluginService, context: RpcContext): string export const PLUGIN_METHODS = [ defineMethod({ name: 'plugins.list', + permission: 'workspace', params: null, handler: async () => listForRpc() }), @@ -75,6 +76,7 @@ export const PLUGIN_METHODS = [ // Why: headless serve has no consent dialog — an explicit consent call is // the only way a pending plugin becomes active on a server. name: 'plugins.consent', + permission: 'host-admin', params: pluginConsentRequestSchema, handler: async (params) => { const service = requirePluginService() @@ -88,6 +90,7 @@ export const PLUGIN_METHODS = [ }), defineMethod({ name: 'plugins.setEnabled', + permission: 'host-admin', params: PluginSetEnabledParams, handler: async (params) => { const service = requirePluginService() @@ -104,6 +107,7 @@ export const PLUGIN_METHODS = [ // capability enforcement must live behind this method too, not only in // the desktop IPC handler. name: 'plugins.panelAction', + permission: 'host-admin', // Why: raw admission must run before strict schema parsing so malformed // and oversized traffic cannot bypass the panel budget. params: PluginsPanelActionParams, @@ -117,6 +121,7 @@ export const PLUGIN_METHODS = [ }), defineMethod({ name: 'plugins.readPanelEntry', + permission: 'workspace', params: PluginReadPanelEntryParams, handler: async (params, context): Promise => { const service = requirePluginService() @@ -132,6 +137,7 @@ export const PLUGIN_METHODS = [ }), defineMethod({ name: 'plugins.invokeCommand', + permission: 'host-admin', params: PluginInvokeCommandParams, handler: async (params) => { const service = requirePluginService() diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index cc1dd5705c3..c4ef0d6f10f 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -15,26 +15,31 @@ import { export const PREFLIGHT_METHODS = [ defineMethod({ name: 'preflight.check', + permission: 'workspace', params: PreflightCheck, handler: async (params) => runPreflightCheck(params.force) }), defineMethod({ name: 'preflight.detectAgents', + permission: 'workspace', params: null, handler: async () => detectInstalledAgentsWithShellPathHydration() }), defineMethod({ name: 'preflight.detectRemoteAgents', + permission: 'workspace', params: PreflightDetectRemoteAgents, handler: async (params) => detectRemoteAgents(params) }), defineMethod({ name: 'preflight.detectRemoteWindowsTerminalCapabilities', + permission: 'workspace', params: PreflightDetectRemoteWindowsTerminalCapabilities, handler: async (params) => detectRemoteWindowsTerminalCapabilities(params) }), defineMethod({ name: 'preflight.refreshAgents', + permission: 'workspace', params: null, handler: async () => refreshShellPathAndDetectAgents() }) diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index f67705f6cdd..b90dd229205 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -12,6 +12,7 @@ import { export const PROJECT_RUNTIME_METHODS = [ defineMethod({ name: 'project.list', + permission: 'workspace', params: null, handler: (_params, { runtime }) => { runtime.enrichMissingRepoGitRemoteIdentities?.() @@ -20,6 +21,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'project.update', + permission: 'workspace', params: ProjectUpdate, handler: (params, { runtime }) => ({ project: runtime.updateProject(params.projectId, params.updates) @@ -27,6 +29,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.list', + permission: 'workspace', params: null, handler: (_params, { runtime }) => { runtime.enrichMissingRepoGitRemoteIdentities?.() @@ -35,6 +38,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.create', + permission: 'workspace', params: ProjectHostSetupCreate, handler: (params, { runtime }) => ({ result: runtime.createProjectHostSetup(params) @@ -42,6 +46,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.setupExistingFolder', + permission: 'workspace', params: ProjectHostSetupExistingFolder, handler: async (params, context) => ({ result: projectRepoResultVisibilityForClient( @@ -52,6 +57,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.clone', + permission: 'workspace', params: ProjectHostSetupClone, handler: async (params, context) => ({ result: projectRepoResultVisibilityForClient( @@ -62,6 +68,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.update', + permission: 'workspace', params: ProjectHostSetupUpdate, handler: (params, context) => ({ result: projectRepoResultVisibilityForClient( @@ -72,6 +79,7 @@ export const PROJECT_RUNTIME_METHODS = [ }), defineMethod({ name: 'projectHostSetup.delete', + permission: 'workspace', params: ProjectHostSetupDelete, handler: (params, context) => ({ result: projectRepoResultVisibilityForClient( diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 51e362c1692..b3725e17e0e 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -31,6 +31,7 @@ import { export const REPO_METHODS = [ defineMethod({ name: 'repo.list', + permission: 'workspace', params: null, handler: (_params, context) => { context.runtime.enrichMissingRepoGitRemoteIdentities?.() @@ -44,11 +45,13 @@ export const REPO_METHODS = [ ...PROJECT_RUNTIME_METHODS, defineMethod({ name: 'projectGroup.list', + permission: 'workspace', params: null, handler: (_params, { runtime }) => ({ groups: runtime.listProjectGroups() }) }), defineMethod({ name: 'projectGroup.create', + permission: 'workspace', params: ProjectGroupCreate, handler: async (params, { runtime }) => ({ group: await runtime.createProjectGroup(params) @@ -56,6 +59,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'projectGroup.update', + permission: 'workspace', params: ProjectGroupUpdate, handler: async (params, { runtime }) => ({ group: await runtime.updateProjectGroup(params.groupId, params.updates) @@ -63,11 +67,13 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'projectGroup.delete', + permission: 'workspace', params: ProjectGroupSelector, handler: async (params, { runtime }) => runtime.deleteProjectGroup(params.groupId) }), defineMethod({ name: 'projectGroup.moveProject', + permission: 'workspace', params: ProjectGroupMoveProject, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -79,16 +85,19 @@ export const REPO_METHODS = [ ...FOLDER_WORKSPACE_METHODS, defineMethod({ name: 'projectGroup.scanNested', + permission: 'workspace', params: ProjectGroupScanNested, handler: async (params, { runtime }) => runtime.scanNestedRepos(params.path) }), defineMethod({ name: 'projectGroup.importNested', + permission: 'workspace', params: ProjectGroupImportNested, handler: async (params, { runtime }) => runtime.importNestedRepos(params) }), defineMethod({ name: 'repo.sparsePresets', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => ({ presets: await runtime.listSparsePresets(params.repo) @@ -96,6 +105,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.saveSparsePreset', + permission: 'workspace', params: RepoSparsePresetSave, handler: async (params, { runtime }) => ({ preset: await runtime.saveSparsePreset(params.repo, { @@ -107,6 +117,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.add', + permission: 'workspace', params: RepoPath, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -117,6 +128,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.create', + permission: 'workspace', params: RepoCreate, handler: async (params, context) => projectRepoResultVisibilityForClient( @@ -126,11 +138,13 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.gitAvailable', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => ({ available: await runtime.isGitAvailable() }) }), defineMethod({ name: 'repo.clone', + permission: 'workspace', params: RepoClone, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -141,6 +155,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.show', + permission: 'workspace', params: RepoSelector, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient(await context.runtime.showRepo(params.repo), context) @@ -148,6 +163,7 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.update', + permission: 'workspace', params: RepoUpdate, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -161,16 +177,19 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.rm', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.removeProject(params.repo) }), defineMethod({ name: 'repo.reorder', + permission: 'workspace', params: RepoReorder, handler: async (params, { runtime }) => runtime.reorderRepos(params.orderedIds) }), defineMethod({ name: 'repo.setBaseRef', + permission: 'workspace', params: RepoSetBaseRef, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( @@ -181,11 +200,13 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.baseRefDefault', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoBaseRefDefault(params.repo) }), defineMethod({ name: 'repo.searchRefs', + permission: 'workspace', params: RepoSearchRefs, handler: async (params, { runtime, clientCapabilities }) => projectRepoSearchRefsForClient( @@ -200,26 +221,31 @@ export const REPO_METHODS = [ }), defineMethod({ name: 'repo.hooks', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo) }), defineMethod({ name: 'repo.hooksCheck', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.checkRepoHooks(params.repo) }), defineMethod({ name: 'repo.setupScriptImports', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.inspectRepoSetupScriptImports(params.repo) }), defineMethod({ name: 'repo.issueCommandRead', + permission: 'workspace', params: RepoSelector, handler: async (params, { runtime }) => runtime.readRepoIssueCommand(params.repo) }), defineMethod({ name: 'repo.issueCommandWrite', + permission: 'workspace', params: RepoIssueCommandWrite, handler: async (params, { runtime }) => runtime.writeRepoIssueCommand(params.repo, params.content) diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts index 2fa62b53934..66aac3a889f 100644 --- a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts @@ -5,6 +5,7 @@ import { ClientCapabilitiesUpdate } from '../../../../shared/rpc-contract/runtim export const RUNTIME_CLIENT_CAPABILITY_METHODS = [ defineMethod({ name: 'runtime.clientCapabilities.update', + permission: 'workspace', params: ClientCapabilitiesUpdate, handler: (params, { updateClientCapabilities }) => { if (!updateClientCapabilities) { diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 762dc7af5bf..ef2176c0860 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -9,6 +9,7 @@ import { projectSessionTabsForClient } from './session-tabs-inventory' export const SESSION_TAB_CLOSE_METHODS = [ defineMethod({ name: 'session.tabs.close', + permission: 'workspace', params: CloseTab, handler: async (params, context) => { if (context.clientKind) { @@ -85,6 +86,7 @@ export const SESSION_TAB_CLOSE_METHODS = [ }), defineMethod({ name: 'session.tabs.closeLifecycle', + permission: 'workspace', params: CloseLifecycleTab, handler: async (params, context) => { if (context.clientKind) { diff --git a/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts b/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts index f2be1d4a61d..58cbaf909f9 100644 --- a/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts @@ -4,12 +4,14 @@ import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' export const SESSION_TAB_MARKDOWN_METHODS = [ defineMethod({ name: 'markdown.readTab', + permission: 'workspace', params: ActivateTab, handler: async (params, { runtime }) => runtime.readMobileMarkdownTab(params.worktree, params.tabId) }), defineMethod({ name: 'markdown.saveTab', + permission: 'workspace', params: SaveMarkdownTab, handler: async (params, { runtime }) => runtime.saveMobileMarkdownTab( diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index 38b824a9fee..76b72fb992f 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -11,6 +11,7 @@ import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-t export const SESSION_TAB_MUTATION_METHODS = [ defineMethod({ name: 'session.tabs.activate', + permission: 'workspace', params: ActivateTab, handler: async (params, { runtime, clientKind, pairedDeviceId, clientCapabilities }) => { if (clientKind) { @@ -41,6 +42,7 @@ export const SESSION_TAB_MUTATION_METHODS = [ }), defineMethod({ name: 'session.tabs.move', + permission: 'workspace', params: MoveTab, handler: async (params, { runtime, pairedDeviceId, clientCapabilities, clientKind }) => { let translated: Parameters[2] = params @@ -73,6 +75,7 @@ export const SESSION_TAB_MUTATION_METHODS = [ }), defineMethod({ name: 'session.tabs.updatePaneLayout', + permission: 'workspace', params: UpdatePaneLayout, handler: async (params, { runtime, pairedDeviceId, clientCapabilities, clientKind }) => { await assertVisibleMutationTab( @@ -94,6 +97,7 @@ export const SESSION_TAB_MUTATION_METHODS = [ }), defineMethod({ name: 'session.tabs.setTabProps', + permission: 'workspace', params: SetTabProps, handler: async (params, { runtime, pairedDeviceId, clientCapabilities, clientKind }) => { await assertVisibleMutationTab( diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index d549daffbaf..e53349c3e5b 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -23,6 +23,7 @@ import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../ export const SESSION_TAB_METHODS = [ defineMethod({ name: 'session.tabs.list', + permission: 'workspace', params: WorktreeTabSelector, handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) @@ -35,6 +36,7 @@ export const SESSION_TAB_METHODS = [ }), defineMethod({ name: 'session.tabs.listAll', + permission: 'workspace', params: null, handler: async (_params, context) => { await restoreStructuredTabsIfSupported(context) @@ -45,6 +47,7 @@ export const SESSION_TAB_METHODS = [ ...SESSION_TAB_CLOSE_METHODS, defineMethod({ name: 'session.tabs.createTerminal', + permission: 'workspace', params: CreateTerminalTab, handler: async ( params, @@ -93,6 +96,7 @@ export const SESSION_TAB_METHODS = [ }), defineStreamingMethod({ name: 'session.tabs.subscribe', + permission: 'workspace', params: WorktreeTabSelector, handler: async ( params, @@ -188,6 +192,7 @@ export const SESSION_TAB_METHODS = [ }), defineMethod({ name: 'session.tabs.unsubscribe', + permission: 'workspace', params: SessionTabsUnsubscribe, handler: async ( params, @@ -213,11 +218,13 @@ export const SESSION_TAB_METHODS = [ }), defineStreamingMethod({ name: 'session.tabs.subscribeAll', + permission: 'workspace', params: null, handler: (_params, context, emit) => subscribeSessionTabsInventory(context, emit) }), defineMethod({ name: 'session.tabs.unsubscribeAll', + permission: 'workspace', params: SessionTabsUnsubscribeAllParams, handler: async (params, { runtime, connectionId }) => { const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 39a3a73eb7c..7866de18eb8 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -67,6 +67,7 @@ function skillDeleteDependencies( export const SKILL_METHODS = [ defineMethod({ name: 'skills.discover', + permission: 'workspace', params: SkillsDiscoverParams, handler: async (params, { runtime }) => { // Why: the executing runtime owns WSL project preferences. Remote callers @@ -81,6 +82,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.previewDelete', + permission: 'workspace', params: SkillDeleteRequestSchema, handler: async (params, { runtime }) => previewSkillDeleteRequest( @@ -91,6 +93,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.delete', + permission: 'skills-admin', params: SkillDeleteRequestSchema, handler: async (params, { runtime }) => runSkillDeleteRequest( @@ -101,6 +104,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.share', + permission: 'skills-admin', params: AgentSkillShareRequestSchema, handler: async (params, { runtime, signal, clientKind }) => { runtime.assertAgentSkillSharingAllowed() @@ -126,6 +130,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.install', + permission: 'skills-admin', params: SkillInstallRequestSchema, handler: async (params, { runtime, signal, clientCapabilities }) => { const result = await runtime.installSharedSkillRequest(params, signal) @@ -145,12 +150,14 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.installBundle', + permission: 'skills-admin', params: SkillBundleInstallRequestSchema, handler: (params, { runtime, signal }) => runtime.installSharedSkillBundleRequest(params, signal) }), defineMethod({ name: 'skills.cancelInstall', + permission: 'skills-admin', params: SkillsCancelInstallParams, handler: (params, { runtime }) => ({ cancelled: runtime.cancelSharedSkillInstall(params.operationId) @@ -158,6 +165,7 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.getInstallProgress', + permission: 'workspace', params: SkillsGetInstallProgressParams, handler: (params, { runtime }) => { const progress = runtime.getSharedSkillInstallProgress(params.operationId) @@ -166,36 +174,43 @@ export const SKILL_METHODS = [ }), defineMethod({ name: 'skills.previewInstall', + permission: 'workspace', params: SkillInstallPreviewRequestSchema, handler: (params, { runtime }) => runtime.previewSharedSkillInstallRequest(params) }), defineMethod({ name: 'skills.removeInstall', + permission: 'skills-admin', params: SkillRemoveRequestSchema, handler: (params, { runtime }) => runtime.removeSharedSkillInstallRequest(params) }), defineMethod({ name: 'skills.listManagedInstalls', + permission: 'workspace', params: null, handler: (_params, { runtime }) => runtime.listManagedSkillInstalls() }), defineMethod({ name: 'skills.beginUpload', + permission: 'skills-admin', params: SkillUploadBeginRequestSchema, handler: (params, { runtime }) => runtime.beginSkillUpload(params) }), defineMethod({ name: 'skills.uploadChunk', + permission: 'skills-admin', params: SkillUploadChunkRequestSchema, handler: (params, { runtime }) => runtime.appendSkillUploadChunk(params) }), defineMethod({ name: 'skills.commitUpload', + permission: 'skills-admin', params: SkillUploadCommitRequestSchema, handler: (params, { runtime }) => runtime.commitSkillUpload(params.uploadId) }), defineMethod({ name: 'skills.cancelUpload', + permission: 'skills-admin', params: SkillUploadCommitRequestSchema, handler: (params, { runtime }) => runtime.cancelSkillUpload(params.uploadId) }) diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index 086a528ab0e..ff1b6914c40 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -10,21 +10,25 @@ import { export const SPEECH_METHODS = [ defineMethod({ name: 'speech.models.list', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.listMobileSpeechModels() }), defineMethod({ name: 'speech.models.download', + permission: 'workspace', params: SpeechModelAction, handler: async (params, { runtime }) => runtime.downloadMobileSpeechModel(params.modelId) }), defineMethod({ name: 'speech.models.delete', + permission: 'workspace', params: SpeechModelAction, handler: async (params, { runtime }) => runtime.deleteMobileSpeechModel(params.modelId) }), defineMethod({ name: 'speech.dictation.setup', + permission: 'workspace', params: DictationSetup, handler: async (params, { runtime }) => runtime.configureMobileDictation({ @@ -35,24 +39,28 @@ export const SPEECH_METHODS = [ }), defineMethod({ name: 'speech.dictation.start', + permission: 'workspace', params: DictationStart, handler: async (params, { runtime, clientId, connectionId }) => runtime.startMobileDictation({ ...params, clientId, connectionId }) }), defineMethod({ name: 'speech.dictation.chunk', + permission: 'workspace', params: DictationChunk, handler: (params, { runtime, clientId, connectionId }) => runtime.feedMobileDictation({ ...params, clientId, connectionId }) }), defineMethod({ name: 'speech.dictation.finish', + permission: 'workspace', params: DictationHandle, handler: async (params, { runtime, clientId, connectionId }) => runtime.finishMobileDictation({ ...params, clientId, connectionId }) }), defineMethod({ name: 'speech.dictation.cancel', + permission: 'workspace', params: DictationHandle, handler: async (params, { runtime, clientId, connectionId }) => runtime.cancelMobileDictation({ ...params, clientId, connectionId }) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 2c8e2520f9b..0f2e0b606c9 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -28,6 +28,7 @@ function listRegisteredSshTargetSummaries(): SshTargetSummary[] { export const SSH_METHODS = [ defineMethod({ name: 'ssh.getState', + permission: 'workspace', params: SshTarget, handler: (params) => ({ state: getPublicSshState(getRegisteredSshState(params.targetId) ?? null) @@ -35,6 +36,7 @@ export const SSH_METHODS = [ }), defineMethod({ name: 'ssh.connect', + permission: 'host-admin', params: SshTarget, handler: async (params) => { try { @@ -47,18 +49,21 @@ export const SSH_METHODS = [ }), defineMethod({ name: 'ssh.listTargets', + permission: 'workspace', params: null, // Why: legacy clients can call this method directly, so it must preserve the same HUB-private secret boundary. handler: () => ({ targets: listRegisteredSshTargetSummaries() }) }), defineMethod({ name: 'ssh.listTargetSummaries', + permission: 'workspace', params: null, // Why: paired clients need display identity only; SSH addresses, jump chains, and credentials remain HUB-private. handler: () => ({ targets: listRegisteredSshTargetSummaries() }) }), defineMethod({ name: 'ssh.listRemovedTargetLabels', + permission: 'workspace', params: null, handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() }) }) diff --git a/src/main/runtime/rpc/methods/stats.ts b/src/main/runtime/rpc/methods/stats.ts index 9cdfe5269d3..05558a933c5 100644 --- a/src/main/runtime/rpc/methods/stats.ts +++ b/src/main/runtime/rpc/methods/stats.ts @@ -3,6 +3,7 @@ import { defineMethod } from '../core' export const STATS_METHODS = [ defineMethod({ name: 'stats.summary', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => { return runtime.getStatsSummary() ?? {} diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index c8041bbabb4..cad7cded626 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -4,6 +4,7 @@ import { getRemoteServerUpdaterSnapshot } from '../../remote-server-updater' export const STATUS_METHODS = [ defineMethod({ name: 'status.get', + permission: 'workspace', params: null, handler: async (_params, { runtime, pairedDeviceId }) => { // Why: a status answered while the friendly-name lookup is still in flight publishes the bare diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts index 8faf017f493..4f8a071446b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -15,6 +15,7 @@ import { clientReadsStructuredSessionAgent } from './structured-agent-session-po export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ defineMethod({ name: AGENT_SESSION_AGENTS_METHOD, + permission: 'workspace', params: AgentsParams, // Read from the registrations the host is built from, as createSupport is: the answer is // fixed for this build, so it never waits on, or fails with, installing the host. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts index 2c45d75c662..eb25df049c4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -34,6 +34,7 @@ async function requireStore( export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ defineMethod({ name: 'agentSessionAttachment.uploadStart', + permission: 'workspace', params: AttachmentUploadStartParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) @@ -42,6 +43,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ }), defineMethod({ name: 'agentSessionAttachment.uploadAppend', + permission: 'workspace', params: AttachmentUploadAppendParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) @@ -50,6 +52,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ }), defineMethod({ name: 'agentSessionAttachment.uploadCommit', + permission: 'workspace', params: AttachmentUploadIdParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) @@ -58,6 +61,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ }), defineMethod({ name: 'agentSessionAttachment.uploadAbort', + permission: 'workspace', params: AttachmentUploadIdParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) @@ -66,6 +70,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ }), defineMethod({ name: 'agentSessionAttachment.read', + permission: 'workspace', params: AttachmentReadParams, handler: async (params, ctx) => { const { store } = await requireStore(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts index a68a41ec1db..11f63c2c95b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts @@ -13,6 +13,7 @@ import { OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ defineMethod({ name: 'agentSession.conversationOutline', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => readAgentSessionConversationOutline( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index ef6e89f8ee8..3398445c4b5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -15,6 +15,7 @@ import { HoldParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', + permission: 'workspace', params: HoldParams, handler: async (params, ctx) => { await requireInstalledStructuredHost(ctx, params.sessionId) @@ -23,6 +24,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ }), defineMethod({ name: 'agentSession.release', + permission: 'workspace', params: HoldParams, handler: async (_params, ctx) => { requireStructuredCleanupHost(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 97833114c2e..6259125a151 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -24,24 +24,28 @@ import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-la export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.handoffStatus', + permission: 'workspace', params: HandoffStatusParams, handler: async (params, ctx) => (await requireInstalledStructuredHost(ctx, params.sessionId)).handoffStatus(params.sessionId) }), defineMethod({ name: 'agentSession.commands', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => (await requireInstalledStructuredHost(ctx, params.sessionId)).readCommands(params.sessionId) }), defineMethod({ name: 'agentSession.options', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => (await requireInstalledStructuredHost(ctx, params.sessionId)).readOptions(params.sessionId) }), defineMethod({ name: 'agentSession.modelCatalog', + permission: 'workspace', params: ModelCatalogParams, // A structured chat's read names its session and builds the host, since it may come first; // terminal-backed chat's session-less read must not open the journal where none runs. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-prompt-response.ts b/src/main/runtime/rpc/methods/structured-agent-session-prompt-response.ts new file mode 100644 index 00000000000..557642c8232 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-prompt-response.ts @@ -0,0 +1,41 @@ +// Answers to an agent's approvals and questions, and its options. +import { defineMethod } from '../core' +import { + requireStructuredSessionHost as requireSessionHost, + structuredCallerFor as callerFor +} from './structured-agent-session-gate' +import { + RespondParams, + RespondToQuestionParams, + SetOptionParams +} from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS = [ + defineMethod({ + name: 'agentSession.respondToApproval', + permission: 'workspace', + params: RespondParams, + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { + ...params, + kind: 'approval' + }) + }), + defineMethod({ + name: 'agentSession.respondToQuestion', + permission: 'workspace', + params: RespondToQuestionParams, + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { + ...params, + kind: 'question' + }) + }), + defineMethod({ + name: 'agentSession.setOption', + permission: 'workspace', + params: SetOptionParams, + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).setOption(callerFor(ctx), params) + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts index c98dfa4c787..5689330e3c3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -15,18 +15,21 @@ import { export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [ defineMethod({ name: 'agentSession.queuedMessageSend', + permission: 'workspace', params: QueuedMessageActionParams, handler: async (params, ctx) => requireSessionHost(ctx, params.envelope.sessionId).queuedMessageSend(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.queuedMessageDelete', + permission: 'workspace', params: QueuedMessageActionParams, handler: async (params, ctx) => requireSessionHost(ctx, params.envelope.sessionId).queuedMessageDelete(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.queuedMessagesResume', + permission: 'workspace', params: QueuedMessagesResumeParams, handler: async (params, ctx) => requireSessionHost(ctx, params.envelope.sessionId).queuedMessagesResume( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index c28fe9856f6..235a2784b88 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -21,6 +21,7 @@ import { structuredAgentsReadBy } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ defineMethod({ name: 'agentSession.restartResumable', + permission: 'workspace', params: RestartResumableParams, handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) @@ -37,6 +38,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // Explicitly abandons the markers without resuming. Closing the dialog is a snooze and does // not call this method, so the status-bar entry can reopen the offer later. name: 'agentSession.restartResumableDismiss', + permission: 'workspace', params: RestartResumableParams, handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) @@ -61,6 +63,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // and what an opted-in launch runs without asking. Separate from `restartResume`, which sends // nothing, but reachable from a setting rather than only from a button. name: 'agentSession.restartContinue', + permission: 'workspace', params: RestartResumeParams, handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) @@ -77,6 +80,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // instead of an offer. Clients gate it on AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY. // It names one chat the client shows, as a send does, so no agent audience applies. name: 'agentSession.continueInterrupted', + permission: 'workspace', params: ContinueInterruptedParams, handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) @@ -89,6 +93,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // answers that nothing was resumed. No Orca surface calls it, but it is a PUBLISHED wire // method, so dropping it is a wire removal an older client would meet as an unknown method. name: 'agentSession.restartResume', + permission: 'workspace', params: RestartResumeParams, handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 118cfb418f5..b82f0e3c3a0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -28,6 +28,7 @@ import { OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ defineMethod({ name: 'agentSession.reveal', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index a4a2de57959..134c0bcf9c4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -45,6 +45,7 @@ export function bindStructuredAgentSessionStream( export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ defineStreamingMethod({ name: 'agentSession.subscribeStatus', + permission: 'workspace', params: null, handler: async (_params, ctx, emit) => { const host = requireHost(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts index 87ba26fe37b..872b791768d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts @@ -13,6 +13,7 @@ import { ThreadGoalParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS = [ defineMethod({ name: 'agentSession.threadGoal', + permission: 'workspace', params: ThreadGoalParams, handler: async (params, ctx) => requireSessionHost(ctx, params.envelope.sessionId).changeThreadGoal(callerFor(ctx), params) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts index a5442097f46..e8b2ee90d6f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts @@ -24,6 +24,7 @@ export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [ // its journal epoch already name them, so no host is installed to answer. True: the read applied. defineMethod({ name: 'agentSession.acknowledgeAttention', + permission: 'workspace', params: AcknowledgeAttentionParams, handler: (params, ctx) => { requireStructuredCapability(ctx) @@ -36,6 +37,7 @@ export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [ }), defineStreamingMethod({ name: 'agentSession.subscribeTurnCompletions', + permission: 'workspace', params: SubscribeTurnCompletionsParams, handler: async (params, ctx, emit) => { const host = requireHost(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts index ed486013a65..58f42d8e961 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts @@ -19,6 +19,7 @@ import { requireInstalledStructuredHost } from './structured-agent-session-gate' export const STRUCTURED_AGENT_SESSION_VISUAL_METHODS = [ defineMethod({ name: 'agentSession.readVisual', + permission: 'workspace', params: ReadVisualParams, handler: async (params, ctx): Promise => { const host = await requireInstalledStructuredHost(ctx) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 8536cf27ec0..7324ae7ffd2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -35,6 +35,7 @@ import { structuredAgentSessionCreateIntentFingerprint } from './structured-agent-session-create' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' +import { STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS } from './structured-agent-session-prompt-response' import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods' import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume' @@ -59,11 +60,8 @@ import { CreateSupportParams, HistoryParams, OptionsParams, - RespondParams, - RespondToQuestionParams, RewindParams, SendParams, - SetOptionParams, SubscribeParams, UnsubscribeParams } from './structured-agent-session-schemas' @@ -103,6 +101,7 @@ async function attachClientSuppliedLocation( export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.rewind', + permission: 'workspace', params: RewindParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) @@ -112,6 +111,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.conversationCommand', + permission: 'workspace', params: ConversationCommandParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) @@ -133,6 +133,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.createSupport', + permission: 'workspace', params: CreateSupportParams, handler: async (params, ctx) => { requireStructuredCreateSupportAdmission(ctx, params.agent) @@ -149,6 +150,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.create', + permission: 'workspace', params: CreateParams, handler: async (params, ctx) => { requireStructuredAgentAudience(ctx, params.agent) @@ -198,16 +200,19 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.ensure', + permission: 'workspace', params: AttachParams, handler: async (params, ctx) => attachClientSuppliedLocation(params, ctx) }), defineMethod({ name: 'agentSession.send', + permission: 'workspace', params: SendParams, handler: sendStructuredAgentSessionForClient }), defineMethod({ name: 'agentSession.cancel', + permission: 'workspace', params: CancelParams, handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params) }), @@ -216,6 +221,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ // Releasing a chat view, not ending a conversation: the record and journal stay on disk so the // same session can be attached again. Only the provider child and the in-memory entry go. name: 'agentSession.close', + permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => { const host = requireStructuredCleanupHost(ctx) @@ -228,32 +234,10 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ return { ok: true as const } } }), - defineMethod({ - name: 'agentSession.respondToApproval', - params: RespondParams, - handler: async (params, ctx) => - requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { - ...params, - kind: 'approval' - }) - }), - defineMethod({ - name: 'agentSession.respondToQuestion', - params: RespondToQuestionParams, - handler: async (params, ctx) => - requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { - ...params, - kind: 'question' - }) - }), - defineMethod({ - name: 'agentSession.setOption', - params: SetOptionParams, - handler: async (params, ctx) => - requireSessionHost(ctx, params.envelope.sessionId).setOption(callerFor(ctx), params) - }), + ...STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS, defineMethod({ name: 'agentSession.history', + permission: 'workspace', params: HistoryParams, handler: async (params, ctx) => { const host = await requireInstalledHost(ctx, params.sessionId) @@ -266,6 +250,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineStreamingMethod({ name: 'agentSession.subscribe', + permission: 'workspace', params: SubscribeParams, handler: async (params, ctx, emit) => { const host = await requireInstalledHost(ctx, params.sessionId) @@ -298,6 +283,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.unsubscribe', + permission: 'workspace', params: UnsubscribeParams, handler: async (params, ctx) => { requireStructuredCleanupHost(ctx) diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts index 0b728629dcb..74700378d45 100644 --- a/src/main/runtime/rpc/methods/terminal-orphan.ts +++ b/src/main/runtime/rpc/methods/terminal-orphan.ts @@ -4,6 +4,7 @@ import { TerminalAdoptOrphans } from '../../../../shared/rpc-contract/terminal-o export const TERMINAL_ORPHAN_METHODS = [ defineMethod({ name: 'terminal.adoptOrphans', + permission: 'workspace', params: TerminalAdoptOrphans, handler: async (params, { runtime }) => runtime.adoptTerminalOrphans(params) }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts index a9bcf1af695..9a52d8f585c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts @@ -66,7 +66,7 @@ export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs return } const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId) if (registration.released) { return } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index 818fdff043e..f192b40b0d8 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -62,7 +62,6 @@ export async function publishLegacyBinaryInitialSnapshot( let serialized = await serializeBudgetedMobileSnapshot( runtime, ptyId, - isMobile, mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) ) if (state.closed) { @@ -167,7 +166,6 @@ export async function publishLegacyBinaryInitialSnapshot( serialized = await serializeBudgetedMobileSnapshot( runtime, ptyId, - isMobile, mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, { kind: 'scrollback', displayMode: state.displayMode @@ -237,7 +235,6 @@ export async function publishLegacyBinaryInitialSnapshot( const recovery = await serializeBudgetedMobileSnapshot( runtime, ptyId, - isMobile, mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, recoveryFrame) ) if (state.closed) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 0fc55560ca5..e0775c87f7c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -22,6 +22,7 @@ import { TerminalResizeForClient } from './stream-schemas' export const TERMINAL_LIFECYCLE_METHODS = [ defineMethod({ name: 'terminal.wait', + permission: 'workspace', params: TerminalWait, handler: async (params, { runtime, signal }) => ({ wait: await runtime.waitForTerminal(params.terminal, { @@ -33,6 +34,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.create', + permission: 'workspace', params: TerminalCreateParams, handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => { // A focused terminal create predates paired-client navigation. Keep the @@ -84,6 +86,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.split', + permission: 'workspace', params: TerminalSplit, handler: async (params, { runtime }) => ({ split: await runtime.splitTerminal(params.terminal, { @@ -96,21 +99,25 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.stop', + permission: 'workspace', params: TerminalStop, handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) }), defineMethod({ name: 'terminal.closeAll', + permission: 'workspace', params: TerminalCloseAll, handler: async (params, { runtime }) => runtime.closeTerminalsForWorktree(params.worktree) }), defineMethod({ name: 'terminal.sleep', + permission: 'workspace', params: TerminalSleep, handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) }), defineMethod({ name: 'terminal.stopExact', + permission: 'workspace', params: TerminalStopExact, handler: async (params, { runtime }) => runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { @@ -120,6 +127,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.resizeForClient', + permission: 'workspace', params: TerminalResizeForClient, handler: async (params, { runtime }) => { // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). @@ -144,6 +152,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.focus', + permission: 'workspace', params: TerminalFocus, handler: async (params, { runtime, clientKind }) => ({ focus: await runtime.focusTerminal(params.terminal, { @@ -155,6 +164,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.close', + permission: 'workspace', params: TerminalHandle, handler: async (params, context) => ({ close: await withTerminalCloseAttribution( @@ -168,6 +178,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'terminal.closeTab', + permission: 'workspace', params: TerminalHandle, handler: async (params, context) => ({ close: await withTerminalCloseAttribution( @@ -181,6 +192,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'agentTeams.tmuxCompat', + permission: 'workspace', params: AgentTeamsTmuxCompat, handler: async (params, { runtime }) => ({ tmux: await runtime.handleAgentTeamsTmuxCompat(params) @@ -188,6 +200,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ }), defineMethod({ name: 'agentTeams.prepareLaunch', + permission: 'workspace', params: AgentTeamsPrepareLaunch, handler: async (params, { runtime }) => ({ launch: await runtime.prepareClaudeAgentTeamsLeader({ diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts index 735c9d21cfb..21b4f3f44b5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -32,6 +32,7 @@ export function installMultiplexFlowControl( stream.ackRecoverySnapshotInFlight = true let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null try { + // Why screen-only: this client is already behind, and a 0-row image tells it to keep its own history. const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { return @@ -79,6 +80,7 @@ export function installMultiplexFlowControl( alternateScreen: serialized.alternateScreen, terminalOwner: serialized.terminalOwner, truncatedByByteBudget: serialized.truncatedByByteBudget, + scrollbackRows: serialized.scrollbackRows, data: serialized.data } ) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index a2c9fadfb8d..e0644abd1a5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -26,7 +26,7 @@ export async function publishMultiplexInitialSnapshot( const forcedInitialSnapshotTruncated = process.env.ORCA_E2E_FORCE_REMOTE_TERMINAL_INITIAL_SNAPSHOT_TRUNCATED === '1' let read = await runtime.readTerminal(request.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId) if (state.closed || streams.get(request.streamId) !== stream) { return null } @@ -36,7 +36,7 @@ export async function publishMultiplexInitialSnapshot( stream.pendingOutputBytes = 0 stream.pendingOutputOverflowed = false read = await runtime.readTerminal(request.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId) if (state.closed || streams.get(request.streamId) !== stream) { return null } @@ -95,6 +95,7 @@ export async function publishMultiplexInitialSnapshot( cwd: serialized?.cwd, truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, + scrollbackRows: serialized?.scrollbackRows, source: serialized?.source, kittyKeyboardFlags: serialized?.kittyKeyboardFlags, alternateScreen: serialized?.alternateScreen, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts index e811614e400..bfd14a48c09 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -14,6 +14,7 @@ import { installMultiplexSubscribeFrame } from './terminal-multiplex-subscribe-f export const TERMINAL_MULTIPLEX_METHODS = [ defineStreamingMethod({ name: 'terminal.multiplex', + permission: 'workspace', params: TerminalMultiplex, handler: async ( _params, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts index f75afb29fa5..bad459b44cd 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -241,6 +241,7 @@ export function installMultiplexSlotFrames( pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, truncatedByByteBudget: serialized?.truncatedByByteBudget, + scrollbackRows: serialized?.scrollbackRows, // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. unavailable: serialized ? undefined : 'no-serializable-buffer', data: serialized?.data ?? '' diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index 68afc1e8d19..20fe9633b1b 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -13,6 +13,7 @@ import { export const TERMINAL_QUERY_METHODS = [ defineMethod({ name: 'terminal.list', + permission: 'workspace', params: TerminalListParams, handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit, { @@ -23,6 +24,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.resolveActive', + permission: 'workspace', params: TerminalResolveActive, handler: async (params, { runtime }) => ({ handle: await runtime.resolveActiveTerminal( @@ -33,6 +35,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.resolvePane', + permission: 'workspace', params: TerminalResolvePane, handler: async (params, { runtime }) => ({ terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) @@ -40,6 +43,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.recoverPane', + permission: 'workspace', params: TerminalRecoverPane, handler: async (params, { runtime }) => ({ terminal: await runtime.recoverTerminalPane( @@ -51,6 +55,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.show', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ terminal: await runtime.showTerminal(params.terminal) @@ -60,6 +65,7 @@ export const TERMINAL_QUERY_METHODS = [ // Read-only identity probe. Deliberately NOT `terminal.show`: this one resolves a structured // worker too, and must therefore never hand back anything that looks writable. name: 'terminal.resolveIdentity', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ identity: runtime.resolveTerminalIdentity(params.terminal) @@ -67,6 +73,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.read', + permission: 'workspace', params: TerminalRead, handler: async (params, { runtime }) => ({ terminal: await runtime.readTerminal(params.terminal, { @@ -78,6 +85,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.inspectProcess', + permission: 'workspace', params: TerminalInspectProcess, handler: async (params, { runtime }) => { const options = { @@ -96,6 +104,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.isRunningAgent', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) @@ -103,6 +112,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.agentStatus', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ agentStatus: await runtime.getTerminalAgentStatus(params.terminal) @@ -110,6 +120,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.rename', + permission: 'workspace', params: TerminalRename, handler: async (params, { runtime }) => ({ rename: await runtime.renameTerminal(params.terminal, params.title || null) @@ -117,6 +128,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.clearBuffer', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ clear: await runtime.clearTerminalBuffer(params.terminal) @@ -124,6 +136,7 @@ export const TERMINAL_QUERY_METHODS = [ }), defineMethod({ name: 'terminal.resetInputModes', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => ({ reset: await runtime.resetTerminalInputModes(params.terminal) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 8309d8c15c1..7880713b131 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -22,6 +22,7 @@ import { export const TERMINAL_SEND_METHODS = [ defineMethod({ name: 'terminal.send', + permission: 'workspace', params: TerminalSend, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts index f54498777d8..dab3d898297 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts @@ -171,7 +171,7 @@ function alwaysOversizeRuntime(): Pick { it('reproduces the defect: the raw budget lets a screen past the frame cap', async () => { - const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1', true) + const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1') expect(serialized).not.toBeNull() const data = serialized?.data ?? '' // Under the budget the desktop applies, which is measured on the text. @@ -192,7 +192,6 @@ describe('the mobile snapshot the page receives', () => { const serialized = await serializeBudgetedMobileSnapshot( denseRuntime(), 'pty-1', - true, budget(PAGE_BUDGET) ) expect(serialized).not.toBeNull() @@ -209,8 +208,8 @@ describe('the mobile snapshot the page receives', () => { it('trims further when the publication carries more metadata', async () => { const runtime = denseRuntime() const [plain, withRequestId] = await Promise.all([ - serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, budget(PAGE_BUDGET)), - serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, { + serializeBudgetedMobileSnapshot(runtime, 'pty-1', budget(PAGE_BUDGET)), + serializeBudgetedMobileSnapshot(runtime, 'pty-1', { bytes: PAGE_BUDGET, streamId: STREAM_ID, frame: { ...PUBLICATION, reason: 'a-reason-of-some-length', requestId: 999_999_999 } @@ -224,7 +223,6 @@ describe('the mobile snapshot the page receives', () => { const serialized = await serializeBudgetedMobileSnapshot( denseRuntime(), 'pty-1', - true, budget(PAGE_BUDGET) ) expect(serialized?.truncatedByByteBudget).toBe(true) @@ -235,8 +233,8 @@ describe('the mobile snapshot the page receives', () => { // exactly what it was served before: the payload size is not its transport's problem. const runtime = denseRuntime() const [withoutBudget, withBudget] = await Promise.all([ - serializeBudgetedMobileSnapshot(runtime, 'pty-1', true), - serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, budget(PAGE_BUDGET)) + serializeBudgetedMobileSnapshot(runtime, 'pty-1'), + serializeBudgetedMobileSnapshot(runtime, 'pty-1', budget(PAGE_BUDGET)) ]) expect(withoutBudget?.scrollbackRows).toBeGreaterThan(withBudget?.scrollbackRows ?? 0) }) @@ -254,12 +252,7 @@ describe('the mobile snapshot the page receives', () => { * knows the screen it holds is not the screen the host had, and the next byte of output fixes it. */ it('empties the text of a zero-row candidate it cannot fit, rather than posting it over', async () => { - const serialized = await serializeBudgetedMobileSnapshot( - denseRuntime(), - 'pty-1', - true, - budget(4096) - ) + const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1', budget(4096)) expect(serialized?.scrollbackRows).toBe(0) expect(serialized?.data).toBe('') expect(serialized?.truncatedByByteBudget).toBe(true) @@ -275,12 +268,7 @@ describe('the mobile snapshot the page receives', () => { * has rather than as a promise the host cannot keep. */ it('cannot go below the metadata the frame carries, and keeps the text empty there', async () => { - const serialized = await serializeBudgetedMobileSnapshot( - denseRuntime(), - 'pty-1', - true, - budget(16) - ) + const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1', budget(16)) expect(serialized?.scrollbackRows).toBe(0) expect(serialized?.data).toBe('') expect(serialized?.truncatedByByteBudget).toBe(true) @@ -290,7 +278,7 @@ describe('the mobile snapshot the page receives', () => { it('still sends an unbudgeted subscriber the screen it has always been sent', async () => { // The other half of ruling 15, and the compatibility one: the raw rule keeps its fallback, so // an older page and every socket client get the oversize screen rather than an empty frame. - const serialized = await serializeBudgetedMobileSnapshot(alwaysOversizeRuntime(), 'pty-1', true) + const serialized = await serializeBudgetedMobileSnapshot(alwaysOversizeRuntime(), 'pty-1') expect(serialized?.data.length).toBeGreaterThan(MOBILE_SNAPSHOT_BYTE_BUDGET) expect(serialized?.truncatedByByteBudget).toBe(true) }) @@ -330,7 +318,6 @@ describe('a snapshot that round one accepted at exactly the budget', () => { const serialized = await serializeBudgetedMobileSnapshot( exactlyAtRoundOnesBudgetRuntime(), 'pty-1', - true, budget(PAGE_BUDGET) ) expect(serialized).not.toBeNull() @@ -435,16 +422,11 @@ describe('the publication fields the budget has to assume', () => { // longer than the number that approved it, and no trimming is left to absorb them: this is the // margin, not a fixture with room in it. const data = 'x'.repeat(4096) - const serialized = await serializeBudgetedMobileSnapshot( - fixedScreenRuntime(data), - 'pty-1', - true, - { - bytes: measured({ kind: 'scrollback', displayMode: 'auto' }, data), - streamId: STREAM_ID, - frame: { kind: 'scrollback', displayMode: 'auto' } - } - ) + const serialized = await serializeBudgetedMobileSnapshot(fixedScreenRuntime(data), 'pty-1', { + bytes: measured({ kind: 'scrollback', displayMode: 'auto' }, data), + streamId: STREAM_ID, + frame: { kind: 'scrollback', displayMode: 'auto' } + }) expect(required(serialized).data).toBe('') }) }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts index 8f361bf95f2..5d7ba74ba23 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts @@ -53,7 +53,11 @@ export function buildSnapshotFrameMeta(options: SnapshotFrameOptions): Record { if (isTerminalSnapshotForcedUnavailable()) { return null } - if (!isMobile) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized - ? { - ...serialized, - data: (serialized.scrollbackAnsi ?? '') + serialized.data, - scrollbackRows: 0, - truncatedByByteBudget: false - } - : null - } + // Why desktop shares the ladder: a screen-only image left a remounted or reconnected pane with no history (#20158). const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] for (const rows of candidates) { const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) @@ -328,7 +318,7 @@ export async function sendMobileResizeRestream( if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { return false } - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true, snapshotByteBudget) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, snapshotByteBudget) if (!serialized) { return false } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts new file mode 100644 index 00000000000..7b3e74a249f --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { MOBILE_SNAPSHOT_BYTE_BUDGET } from '../../../scrollback-limits' +import { buildSnapshotFrameMeta } from './terminal-snapshot-payload' +import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' + +function historyRuntime(bytesPerRow: number): Pick { + return { + serializeTerminalBuffer: vi.fn( + async (_ptyId: string, options?: { scrollbackRows?: number }) => ({ + scrollbackAnsi: 'h'.repeat((options?.scrollbackRows ?? 0) * bytesPerRow), + data: 'SCREEN', + cols: 80, + rows: 24 + }) + ) + } +} + +describe('subscribe snapshot scrollback (#20158)', () => { + it('serializes history for every subscriber, not a screen-only image', async () => { + const runtime = historyRuntime(10) + const serialized = await serializeBudgetedMobileSnapshot(runtime, 'pty-1') + expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(1) + expect(runtime.serializeTerminalBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 1000 }) + expect(serialized?.scrollbackRows).toBe(1000) + expect(serialized?.data).toBe(`${'h'.repeat(10_000)}SCREEN`) + }) + + it('steps down the row ladder when history is over the byte budget', async () => { + // 1000 rows is over 512 KiB, 500 rows is not. + const bytesPerRow = Math.floor(MOBILE_SNAPSHOT_BYTE_BUDGET / 600) + const serialized = await serializeBudgetedMobileSnapshot(historyRuntime(bytesPerRow), 'pty-1') + expect(serialized?.scrollbackRows).toBe(500) + expect(serialized?.data.endsWith('SCREEN')).toBe(true) + }) +}) + +describe('SnapshotStart scrollbackRows', () => { + const base = { kind: 'scrollback' as const, cols: 80, rows: 24, data: '' } + + it('publishes how many history rows the image carries, including zero', () => { + expect(buildSnapshotFrameMeta({ ...base, scrollbackRows: 1000 }).scrollbackRows).toBe(1000) + expect(buildSnapshotFrameMeta({ ...base, scrollbackRows: 0 }).scrollbackRows).toBe(0) + }) + + it('omits the field when the publisher does not know, so the client reads it as unknown', () => { + expect('scrollbackRows' in buildSnapshotFrameMeta(base)).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts index 121cf9a7adb..379a2589795 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -20,6 +20,8 @@ export type SnapshotFrameOptions = { cwd?: string | null truncated?: boolean truncatedByByteBudget?: boolean + /** History rows this image carries above its screen; 0 tells the client to keep its own. */ + scrollbackRows?: number // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. unavailable?: TerminalSnapshotUnavailableReason source?: 'headless' | 'renderer' diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index 6fa2c936c0c..1bd7cd268c7 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -13,6 +13,7 @@ export const TERMINAL_SUBSCRIBE_METHODS = [ // Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. defineStreamingMethod({ name: 'terminal.subscribe', + permission: 'workspace', params: TerminalSubscribe, handler: async ( params, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts index c2c94efee6d..095bc694bfa 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts @@ -6,6 +6,7 @@ import { TerminalSetViewerColors } from './unary-schemas' export const TERMINAL_VIEWER_COLORS_METHODS = [ defineMethod({ name: 'terminal.setViewerColors', + permission: 'workspace', params: TerminalSetViewerColors, // Why: a headless host has no theme of its own to answer OSC 10/11 with, so it answers with // the colours of the paired client that last pushed; a host with its own window keeps its own. diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts index b977c113668..f24dea4602a 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -12,6 +12,7 @@ import { TerminalGetAutoRestoreFitParams } from '../../../../../shared/rpc-contr export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ defineMethod({ name: 'terminal.setDisplayMode', + permission: 'workspace', params: TerminalSetDisplayMode, handler: async (params, { runtime }) => { // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). @@ -33,6 +34,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ }), defineMethod({ name: 'terminal.restoreFit', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => { // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). @@ -45,6 +47,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ }), defineMethod({ name: 'terminal.getDisplayMode', + permission: 'workspace', params: TerminalHandle, handler: async (params, { runtime }) => { const leaf = runtime.resolveLeafForHandle(params.terminal) @@ -55,6 +58,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ }), defineMethod({ name: 'terminal.updateViewport', + permission: 'workspace', params: TerminalUpdateViewport, handler: async (params, { runtime }) => { // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). @@ -81,6 +85,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ defineMethod({ name: 'terminal.unsubscribe', + permission: 'workspace', params: TerminalUnsubscribe, handler: async (params, { runtime, connectionId, subscriptionRegistrationVersion }) => { if (params.requestId !== undefined) { @@ -112,6 +117,7 @@ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ }), defineMethod({ name: 'terminal.getAutoRestoreFit', + permission: 'workspace', params: TerminalGetAutoRestoreFitParams, handler: async (_params, { runtime }) => ({ ms: runtime.getMobileAutoRestoreFitMs() @@ -119,6 +125,7 @@ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ }), defineMethod({ name: 'terminal.setAutoRestoreFit', + permission: 'workspace', params: TerminalSetAutoRestoreFit, handler: async (params, { runtime }) => ({ ms: runtime.setMobileAutoRestoreFitMs(params.ms) diff --git a/src/main/runtime/rpc/methods/updater.ts b/src/main/runtime/rpc/methods/updater.ts index a14fb5ab6a4..98ccee8c768 100644 --- a/src/main/runtime/rpc/methods/updater.ts +++ b/src/main/runtime/rpc/methods/updater.ts @@ -10,21 +10,25 @@ import { UpdaterCheckParams } from '../../../../shared/rpc-contract/updater-para export const UPDATER_METHODS = [ defineMethod({ name: 'updater.getStatus', + permission: 'workspace', params: null, handler: (_params, { runtime }) => getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) }), defineMethod({ name: 'updater.check', + permission: 'host-admin', params: UpdaterCheckParams, handler: (params, { runtime }) => checkRemoteServerUpdater(runtime.getRuntimeId(), params) }), defineMethod({ name: 'updater.download', + permission: 'host-admin', params: null, handler: (_params, { runtime }) => downloadRemoteServerUpdater(runtime.getRuntimeId()) }), defineMethod({ name: 'updater.install', + permission: 'host-admin', params: null, handler: (_params, { runtime }) => installRemoteServerUpdater(runtime.getRuntimeId()) }) diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts index c96c91b436d..dbc0b4ef9f1 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.ts @@ -7,11 +7,13 @@ import { export const WORKSPACE_PORT_METHODS = [ defineMethod({ name: 'workspacePorts.scan', + permission: 'workspace', params: WorkspacePortScanParams, handler: async (params, { runtime }) => runtime.scanWorkspacePorts(params.repoId) }), defineMethod({ name: 'workspacePorts.kill', + permission: 'workspace', params: WorkspacePortKillParams, handler: async (params, { runtime }) => runtime.killWorkspacePort({ diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index f4f3ba99cae..dc0a01cfe60 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -15,6 +15,7 @@ import { export const WORKTREE_CATALOG_METHODS = [ defineMethod({ name: 'worktree.ps', + permission: 'workspace', params: WorktreePsParams, handler: async (params, context) => { const pendingAtScan = snapshotPendingWorktreeRemovals() @@ -37,6 +38,7 @@ export const WORKTREE_CATALOG_METHODS = [ }), defineMethod({ name: 'worktree.list', + permission: 'workspace', params: WorktreeListParams, handler: async (params, context) => { const pendingAtScan = snapshotPendingWorktreeRemovals() @@ -53,11 +55,13 @@ export const WORKTREE_CATALOG_METHODS = [ }), defineMethod({ name: 'worktree.listRetiredNames', + permission: 'workspace', params: WorktreeDetectedListParams, handler: async (params, { runtime }) => runtime.listRetiredWorktreeNames(params.repo) }), defineMethod({ name: 'worktree.detectedList', + permission: 'workspace', params: WorktreeDetectedListParams, handler: async (params, context) => { const pendingAtScan = snapshotPendingWorktreeRemovals() diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 39bc3d0618d..9f59a872e25 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -32,6 +32,7 @@ export const WORKTREE_METHODS = [ ...WORKTREE_CATALOG_METHODS, defineMethod({ name: 'worktree.teardownMissingTerminals', + permission: 'workspace', params: WorktreeTeardownMissingTerminalsParams, handler: async (params, { runtime }) => runtime.teardownMissingManagedWorktreeTerminals( @@ -42,6 +43,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.lineageList', + permission: 'workspace', params: null, handler: async (_params, { runtime }) => ({ lineage: await runtime.listWorktreeLineage(), @@ -50,6 +52,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.show', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => ({ worktree: await runtime.showManagedWorktree(params.worktree) @@ -57,11 +60,13 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.sleep', + permission: 'workspace', params: WorktreeSelector, handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree) }), defineMethod({ name: 'worktree.activate', + permission: 'workspace', params: WorktreeActivate, handler: async (params, { runtime, clientKind }) => // Why: clientKind ('mobile'|'runtime') scopes the host-renderer slept-agent @@ -79,6 +84,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.create', + permission: 'workspace', params: WorktreeCreate, handler: async (params, context) => // Why: a mobile create interrupted by a connection migration is retried with @@ -127,6 +133,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.prefetchCreateBase', + permission: 'workspace', params: WorktreePrefetchCreateBase, handler: async (params, { runtime }) => { await runtime.prefetchManagedWorktreeCreateBase({ @@ -138,6 +145,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.set', + permission: 'workspace', params: WorktreeSet, handler: async (params, { runtime }) => ({ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { @@ -193,12 +201,14 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.persistSortOrder', + permission: 'workspace', params: WorktreeSortOrder, handler: async (params, { runtime }) => runtime.persistManagedWorktreeSortOrder(params.orderedIds) }), defineMethod({ name: 'worktree.resolvePrBase', + permission: 'workspace', params: WorktreeResolvePrBase, handler: async (params, { runtime }) => runtime.resolveManagedPrBase({ @@ -211,6 +221,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.resolveMrBase', + permission: 'workspace', params: WorktreeResolveMrBase, handler: async (params, { runtime }) => runtime.resolveManagedMrBase({ @@ -223,6 +234,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.rm', + permission: 'workspace', params: WorktreeRemove, handler: async (params, context) => { const { runtime } = context @@ -274,6 +286,7 @@ export const WORKTREE_METHODS = [ }), defineMethod({ name: 'worktree.forceDeleteBranch', + permission: 'workspace', params: WorktreeForceDeleteBranch, handler: async (params, { runtime }) => { const hostId = resolvePairedCallerHostId( diff --git a/src/main/runtime/rpc/orchestration-contract-fence.test.ts b/src/main/runtime/rpc/orchestration-contract-fence.test.ts index 10d4d13b1fe..d3a4a50d0f4 100644 --- a/src/main/runtime/rpc/orchestration-contract-fence.test.ts +++ b/src/main/runtime/rpc/orchestration-contract-fence.test.ts @@ -27,6 +27,7 @@ describe('orchestration contract fence', () => { methods: [ defineMethod({ name: method, + permission: 'workspace', params: z.object({ subject: z.string() }), handler: effect }) diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index 3a6b734c259..c06e03bb41c 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -52,6 +52,7 @@ describe('durable orchestration mutation ledger', () => { methods: [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: Params, handler: ({ subject }) => ({ message: effect(subject) }) }) @@ -156,6 +157,7 @@ describe('durable orchestration mutation ledger', () => { methods: [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: Params, handler: effect }) @@ -269,6 +271,7 @@ describe('durable orchestration mutation ledger', () => { methods: [ defineMethod({ name: 'orchestration.workerRelease', + permission: 'workspace', params: z.object({ dispatch: z.string() }), handler: effect }) @@ -326,6 +329,7 @@ describe('durable orchestration mutation ledger', () => { methods: [ defineMethod({ name: 'orchestration.workerStart', + permission: 'workspace', params: z.object({ from: z.string(), task: z.string() }), handler: effect }) diff --git a/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts b/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts index 68310db7224..6db80a563ef 100644 --- a/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts @@ -30,6 +30,7 @@ function createHarness() { methods: [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: Params, handler: ({ subject }) => ({ message: effect(subject) }) }), @@ -118,6 +119,7 @@ describe('orchestration.requestShow', () => { methods: [ defineMethod({ name: 'orchestration.send', + permission: 'workspace', params: Params, handler: async () => { reportStarted?.() diff --git a/src/main/runtime/rpc/rpc-caller-scope.test.ts b/src/main/runtime/rpc/rpc-caller-scope.test.ts new file mode 100644 index 00000000000..e570a5d2bb0 --- /dev/null +++ b/src/main/runtime/rpc/rpc-caller-scope.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeTerminalListResult } from '../../../shared/runtime-terminal-contracts' +import type { RpcRequest, RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { ALL_RPC_METHODS } from './methods' +import type { RpcCallerScope } from './rpc-caller-scope' +import { RPC_METHOD_PERMISSIONS } from './rpc-method-permission' +import { SSH_BRIDGE_HOST_BINDERS } from './ssh-bridge-host-binding' +import { + CONTROL_GRANTED_SSH_BRIDGE_SCOPE as CONTROL_GRANTED, + HOST_BOUND_SSH_BRIDGE_SCOPE as HOST_BOUND +} from '../../ssh/ssh-bridge-caller-scope.test-fixture' + +const PAIRED: RpcCallerScope = { kind: 'runtime-paired', grants: [] } + +function request(method: string, params?: unknown): RpcRequest { + return { id: `req-${method}`, authToken: 'unused', method, params } +} + +async function dispatchAs( + scope: RpcCallerScope | undefined, + method: string, + params?: unknown, + runtime = new OrcaRuntimeService() +): Promise { + return new RpcDispatcher({ runtime }).dispatch( + request(method, params), + scope ? { callerScope: scope } : undefined + ) +} + +async function dispatchStreamingAs( + scope: RpcCallerScope, + method: string, + params?: unknown +): Promise { + const replies: RpcResponse[] = [] + await new RpcDispatcher({ runtime: new OrcaRuntimeService() }).dispatchStreaming( + request(method, params), + (raw) => replies.push(JSON.parse(raw)), + { callerScope: scope } + ) + const [reply] = replies + if (!reply) { + throw new Error('no reply') + } + return reply +} + +function errorCode(response: RpcResponse): string | null { + return response.ok ? null : response.error.code +} + +function terminalSummary(handle: string, executionHostId: 'ssh:box-1' | 'ssh:box-2' | 'local') { + return { + handle, + ptyId: null, + worktreeId: `wt-${handle}`, + worktreePath: `/wt/${handle}`, + branch: 'main', + tabId: 'tab', + leafId: 'leaf', + title: null, + connected: true, + writable: true, + lastOutputAt: null, + preview: `secret output of ${handle}`, + executionHostId + } as const +} + +describe('every RPC method declares a known permission', () => { + it('rejects nothing outside the permission vocabulary', () => { + for (const method of ALL_RPC_METHODS) { + expect(RPC_METHOD_PERMISSIONS, method.name).toContain(method.permission) + } + }) + + it('classifies desktop control and owner administration as non-workspace', () => { + const permissionOf = (name: string) => ALL_RPC_METHODS.find((m) => m.name === name)?.permission + expect(permissionOf('computer.click')).toBe('desktop-control') + expect(permissionOf('computer.typeText')).toBe('desktop-control') + expect(permissionOf('computer.permissionsStatus')).toBe('workspace') + expect(permissionOf('accounts.selectClaude')).toBe('accounts-admin') + expect(permissionOf('settings.update')).toBe('settings-write') + expect(permissionOf('skills.install')).toBe('skills-admin') + expect(permissionOf('skills.share')).toBe('skills-admin') + expect(permissionOf('pairing.provisionRelay')).toBe('pairing-admin') + expect(permissionOf('ssh.connect')).toBe('host-admin') + expect(permissionOf('network.browserTunnel')).toBe('host-admin') + }) +}) + +describe('SSH bridge without the per-host opt-in', () => { + it('binds only registered workspace methods', () => { + const permissionOf = (name: string) => ALL_RPC_METHODS.find((m) => m.name === name)?.permission + for (const method of SSH_BRIDGE_HOST_BINDERS.keys()) { + expect(permissionOf(method), method).toBe('workspace') + } + }) + + it.each([ + ['computer.click', { app: 'Finder' }], + ['accounts.selectClaude', {}], + ['settings.update', {}], + ['skills.install', {}], + ['terminal.create', {}], + ['orchestration.dispatch', { task: 'task_1' }], + ['files.read', {}] + ])('refuses %s before any handler runs', async (method, params) => { + const response = await dispatchAs(HOST_BOUND, method, params) + expect(errorCode(response)).toBe('forbidden') + }) + + it('enforces the same refusal on the streaming transport', async () => { + const response = await dispatchStreamingAs(HOST_BOUND, 'computer.click', { app: 'Finder' }) + expect(errorCode(response)).toBe('forbidden') + }) + + it("refuses a terminal handle that resolves to another host's terminal", async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + ...terminalSummary('term_other', 'ssh:box-2'), + paneRuntimeId: 1, + rendererGraphEpoch: 0 + }) + const read = vi.spyOn(runtime, 'readTerminal') + const response = await dispatchAs( + HOST_BOUND, + 'terminal.read', + { terminal: 'term_other' }, + runtime + ) + expect(errorCode(response)).toBe('forbidden') + expect(read).not.toHaveBeenCalled() + }) + + it('refuses a terminal whose host cannot be named', async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'showTerminal').mockRejectedValue(new Error('terminal_not_found')) + const send = vi.spyOn(runtime, 'sendTerminal') + const response = await dispatchAs( + HOST_BOUND, + 'terminal.send', + { terminal: 'term_unknown', text: 'rm -rf ~' }, + runtime + ) + expect(errorCode(response)).toBe('forbidden') + expect(send).not.toHaveBeenCalled() + }) + + it("reads the bridged host's own terminal", async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + ...terminalSummary('term_own', 'ssh:box-1'), + paneRuntimeId: 1, + rendererGraphEpoch: 0 + }) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_own', + status: 'running', + tail: ['ok'], + truncated: false, + nextCursor: null + }) + const response = await dispatchAs( + HOST_BOUND, + 'terminal.read', + { terminal: 'term_own' }, + runtime + ) + expect(response.ok).toBe(true) + }) + + it("lists only the bridged host's terminals", async () => { + const runtime = new OrcaRuntimeService() + const listing: RuntimeTerminalListResult = { + terminals: [ + terminalSummary('term_own', 'ssh:box-1'), + terminalSummary('term_local', 'local'), + terminalSummary('term_other', 'ssh:box-2') + ], + totalCount: 3, + truncated: false, + topologyRevisions: { 'wt-term_own': 1, 'wt-term_local': 2 }, + hostScope: { hostIds: ['local', 'ssh:box-1', 'ssh:box-2'], omittedHostIds: [] } + } + vi.spyOn(runtime, 'listTerminals').mockResolvedValue(listing) + const response = await dispatchAs(HOST_BOUND, 'terminal.list', {}, runtime) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + terminals: [terminalSummary('term_own', 'ssh:box-1')], + totalCount: 1, + truncated: false, + topologyRevisions: { 'wt-term_own': 1 }, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: [] } + }) + }) + + it("refuses a terminal listing selected by another host's worktree", async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminalSummary('term_local', 'local')], + totalCount: 1, + truncated: false + }) + const response = await dispatchAs( + HOST_BOUND, + 'terminal.list', + { worktree: 'path:/Users/me/secret' }, + runtime + ) + expect(errorCode(response)).toBe('forbidden') + }) +}) + +describe('SSH bridge with the per-host opt-in', () => { + it('reaches workspace methods outside its own host', async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminalSummary('term_local', 'local')], + totalCount: 1, + truncated: false + }) + const response = await dispatchAs(CONTROL_GRANTED, 'terminal.list', {}, runtime) + expect(response.ok).toBe(true) + }) + + it.each([ + ['computer.click', { app: 'Finder' }], + ['accounts.selectClaude', {}], + ['settings.update', {}], + ['skills.install', {}], + ['ssh.connect', {}], + ['pairing.provisionRelay', {}] + ])('still refuses owner-only %s', async (method, params) => { + const response = await dispatchAs(CONTROL_GRANTED, method, params) + expect(errorCode(response)).toBe('forbidden') + }) +}) + +describe('paired runtime clients', () => { + it('cannot drive the desktop without a pairing-time grant', async () => { + const response = await dispatchAs(PAIRED, 'computer.click', { app: 'Finder' }) + expect(errorCode(response)).toBe('forbidden') + }) + + it('may drive the desktop when the pairing granted it', async () => { + const response = await dispatchAs( + { kind: 'runtime-paired', grants: ['desktop-control'] }, + 'computer.click', + {} + ) + expect(errorCode(response)).not.toBe('forbidden') + }) + + it.each(['settings.update', 'accounts.selectClaude', 'skills.install', 'updater.check'])( + 'keep the remote UI surface: %s', + async (method) => { + const response = await dispatchAs(PAIRED, method, {}) + expect(errorCode(response)).not.toBe('forbidden') + } + ) + + it.each(['pairing.provisionRelay', 'pairing.getEndpoints', 'notifications.registerPush'])( + 'are refused pairing and push administration: %s', + async (method) => { + const response = await dispatchAs(PAIRED, method, {}) + expect(errorCode(response)).toBe('forbidden') + } + ) + + it('learn an unregistered method is missing rather than forbidden', async () => { + const response = await dispatchAs(PAIRED, 'computer.methodFromTheFuture', {}) + expect(errorCode(response)).toBe('method_not_found') + }) +}) + +describe('the owner', () => { + it('reaches desktop control unscoped', async () => { + const response = await dispatchAs(undefined, 'computer.click', {}) + expect(errorCode(response)).not.toBe('forbidden') + }) +}) + +describe('a pinned in-process bridge dispatcher', () => { + it('ignores a wider per-call scope', async () => { + const dispatcher = new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + callerScope: HOST_BOUND + }) + const response = await dispatcher.dispatch(request('computer.click', { app: 'Finder' }), { + callerScope: { kind: 'owner' } + }) + expect(errorCode(response)).toBe('forbidden') + }) +}) diff --git a/src/main/runtime/rpc/rpc-caller-scope.ts b/src/main/runtime/rpc/rpc-caller-scope.ts new file mode 100644 index 00000000000..b44b8d8c2a2 --- /dev/null +++ b/src/main/runtime/rpc/rpc-caller-scope.ts @@ -0,0 +1,89 @@ +/** + * Which permissions each kind of caller holds, checked once by the dispatcher for every transport. + * + * A transport stamps the scope it proved (device token, SSH bridge credential); an unstamped call is + * this host's own desktop or owner-token CLI. Every non-owner row is deny-by-default: a method is + * reachable only when its declared permission is in the row. + */ +import { MOBILE_RPC_METHOD_ALLOWLIST } from '../runtime-rpc/runtime-rpc-mobile-method-allowlist' +import type { RpcMethodPermission, RuntimeDeviceGrant } from './rpc-method-permission' +import type { OrcaRuntimeService } from '../orca-runtime' +import { + bindSshBridgeCall, + SSH_BRIDGE_HOST_BINDERS, + SSH_BRIDGE_REMOTE_CONTROL_HINT, + type SshBridgeCallBinding +} from './ssh-bridge-host-binding' + +export type { RuntimeDeviceGrant } + +export type RpcCallerScope = + | { kind: 'owner' } + | { kind: 'mobile' } + | { kind: 'runtime-paired'; grants: readonly RuntimeDeviceGrant[] } + /** The `orca` CLI of a remote SSH host, relayed through this host's bridge. `remoteCliControl` + * is the per-target opt-in that lets that host's CLI act outside its own terminals. */ + | { kind: 'ssh-bridge'; targetId: string; remoteCliControl: boolean } + +export const OWNER_RPC_CALLER_SCOPE: RpcCallerScope = { kind: 'owner' } + +// Why: a paired desktop, web client or `--host runtime:` CLI is this runtime's full remote UI, so its +// standard set covers what that UI drives here (settings, accounts, skills, SSH and server updates). +// Everything else (desktop control, pairing and push admin) is refused unless granted at pairing. +const RUNTIME_PAIRED_STANDARD: ReadonlySet = new Set([ + 'workspace', + 'settings-write', + 'accounts-admin', + 'skills-admin', + 'host-admin' +]) + +/** + * Returns the refusal message, or null when the caller may invoke the method. `permission` is + * undefined for a method this build does not register; only the mobile row answers that by name, + * because phones read `forbidden` (not `method_not_found`) as "this desktop predates the method". + */ +export function denyRpcMethodForCaller( + scope: RpcCallerScope, + methodName: string, + permission: RpcMethodPermission | undefined +): string | null { + if (scope.kind === 'mobile') { + return MOBILE_RPC_METHOD_ALLOWLIST.has(methodName) + ? null + : `Method '${methodName}' is not available to mobile clients` + } + if (scope.kind === 'owner' || permission === undefined) { + return null + } + if (scope.kind === 'runtime-paired') { + return !RUNTIME_PAIRED_STANDARD.has(permission) && + !scope.grants.some((grant) => grant === permission) + ? `Method '${methodName}' needs the '${permission}' permission, which this paired client was not granted when it paired.` + : null + } + if (scope.remoteCliControl) { + return permission === 'workspace' + ? null + : `Method '${methodName}' is not available to an SSH host's orca CLI.` + } + return permission === 'workspace' && SSH_BRIDGE_HOST_BINDERS.has(methodName) + ? null + : `Method '${methodName}' is not available to the orca CLI on SSH host '${scope.targetId}'. ${SSH_BRIDGE_REMOTE_CONTROL_HINT}` +} + +/** + * Binds an allowed call's selectors to the caller's host; only the unopted SSH bridge is bound. + * Null (not a resolved promise) when unbound, so other callers' dispatch gains no extra tick. + */ +export function bindRpcCallToCallerScope( + scope: RpcCallerScope, + runtime: OrcaRuntimeService, + methodName: string, + params: unknown +): Promise | null { + if (scope.kind !== 'ssh-bridge' || scope.remoteCliControl) { + return null + } + return bindSshBridgeCall(runtime, scope.targetId, methodName, params) +} diff --git a/src/main/runtime/rpc/rpc-method-permission.ts b/src/main/runtime/rpc/rpc-method-permission.ts new file mode 100644 index 00000000000..43d06460f62 --- /dev/null +++ b/src/main/runtime/rpc/rpc-method-permission.ts @@ -0,0 +1,37 @@ +/** + * What a method lets its caller do to this Orca, declared on every method definition. + * + * The dispatcher grants each caller kind a set of these (rpc-caller-scope.ts), so a new method + * cannot ship without someone deciding which callers may reach it. + */ +export type RpcMethodPermission = + /** Projects, worktrees, terminals, files, git, browser, orchestration and integrations. */ + | 'workspace' + /** Drives this machine's own desktop: clicks, keys and app state outside Orca. */ + | 'desktop-control' + /** Adds, removes or switches agent and integration accounts and their credentials. */ + | 'accounts-admin' + /** Changes Orca settings. */ + | 'settings-write' + /** Installs, publishes or removes agent skills. */ + | 'skills-admin' + /** Pairing offers, relay credentials and push registration for paired devices. */ + | 'pairing-admin' + /** Updates, managed servers, SSH connections, plugins and network tunnels on this host. */ + | 'host-admin' + +/** Administrative permissions a paired runtime client holds only when granted at pairing time. */ +export const RUNTIME_DEVICE_GRANTS = [ + 'desktop-control' +] as const satisfies readonly RpcMethodPermission[] +export type RuntimeDeviceGrant = (typeof RUNTIME_DEVICE_GRANTS)[number] + +export const RPC_METHOD_PERMISSIONS: readonly RpcMethodPermission[] = [ + 'workspace', + 'desktop-control', + 'accounts-admin', + 'settings-write', + 'skills-admin', + 'pairing-admin', + 'host-admin' +] diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 7dc5dc0586a..14f960439e6 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -22,6 +22,12 @@ import { routeDispatcherClientHostedBrowserRpc } from './dispatcher-client-brows import { needsLocalCallerFingerprint } from './dispatcher-caller-fingerprint' import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming-feature-emitter' import { resolveRpcCallerIdentity } from './rpc-caller-identity' +import { + bindRpcCallToCallerScope, + denyRpcMethodForCaller, + OWNER_RPC_CALLER_SCOPE, + type RpcCallerScope +} from './rpc-caller-scope' import { needsOrchestrationCallerResolution, resolveOrchestrationSessionCaller, @@ -34,6 +40,7 @@ export type RpcStreamingDispatcherDependencies = { orchestrationMutations: OrchestrationMutationExecutor legacyOrchestration: OrchestrationLegacyCompatibility meta: () => RpcEnvelopeMeta + pinnedCallerScope?: RpcCallerScope } export class RpcStreamingDispatcher { @@ -50,6 +57,13 @@ export class RpcStreamingDispatcher { this.dependencies const envelopeMeta = meta() const method = registry.get(request.method) + const callerScope = + this.dependencies.pinnedCallerScope ?? options?.callerScope ?? OWNER_RPC_CALLER_SCOPE + const denial = denyRpcMethodForCaller(callerScope, request.method, method?.permission) + if (denial) { + reply(JSON.stringify(errorResponse(request.id, envelopeMeta, 'forbidden', denial))) + return + } if (!method) { reply( JSON.stringify( @@ -88,6 +102,12 @@ export class RpcStreamingDispatcher { return } const params = parsedParams.value + const pendingBinding = bindRpcCallToCallerScope(callerScope, runtime, request.method, params) + const binding = pendingBinding ? await pendingBinding : null + if (binding?.kind === 'denied') { + reply(JSON.stringify(errorResponse(request.id, envelopeMeta, 'forbidden', binding.message))) + return + } if (!isStreamingMethod(method)) { try { @@ -170,7 +190,14 @@ export class RpcStreamingDispatcher { orchestrationCaller?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) - reply(JSON.stringify(successResponse(request.id, envelopeMeta, result))) + const filtered = binding?.filterResult?.(result) ?? { kind: 'allowed', result } + reply( + JSON.stringify( + filtered.kind === 'denied' + ? errorResponse(request.id, envelopeMeta, 'forbidden', filtered.message) + : successResponse(request.id, envelopeMeta, filtered.result) + ) + ) } catch (error) { reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) } diff --git a/src/main/runtime/rpc/ssh-bridge-credentials.ts b/src/main/runtime/rpc/ssh-bridge-credentials.ts new file mode 100644 index 00000000000..4b549880669 --- /dev/null +++ b/src/main/runtime/rpc/ssh-bridge-credentials.ts @@ -0,0 +1,32 @@ +/** + * Short-lived credentials for the `orca` CLI an SSH host runs through this host's bridge. + * + * One credential per invocation, bound to the SSH target that relayed it, revoked when the + * invocation settles. The runtime socket maps it to that target's bridge scope, so the child CLI + * never holds the owner token. + */ +import { randomBytes } from 'node:crypto' +import type { RpcCallerScope } from './rpc-caller-scope' + +export type SshBridgeCallerScope = Extract + +export type SshBridgeCredential = { + token: string + revoke: () => void +} + +export class SshBridgeCredentialRegistry { + private readonly bindings = new Map() + + mint(scope: SshBridgeCallerScope): SshBridgeCredential { + const token = `sshb_${randomBytes(24).toString('hex')}` + this.bindings.set(token, { ...scope }) + return { token, revoke: () => this.bindings.delete(token) } + } + + resolve(token: string): SshBridgeCallerScope | null { + return this.bindings.get(token) ?? null + } +} + +export const sshBridgeCredentials = new SshBridgeCredentialRegistry() diff --git a/src/main/runtime/rpc/ssh-bridge-host-binding.ts b/src/main/runtime/rpc/ssh-bridge-host-binding.ts new file mode 100644 index 00000000000..a03a4c0e8c7 --- /dev/null +++ b/src/main/runtime/rpc/ssh-bridge-host-binding.ts @@ -0,0 +1,155 @@ +/** + * Keeps an SSH host's relayed `orca` CLI inside that host's own terminals. + * + * Without the per-target opt-in, the bridge reaches only these methods, and each selector must + * resolve to a terminal on the bridged host; orchestration callers are bound the same way. Terminals whose host cannot be named are refused: + * failing closed is the only safe answer for a credential that must not reach other hosts. + */ +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { RuntimeTerminalListResult } from '../../../shared/runtime-terminal-contracts' +import type { OrcaRuntimeService } from '../orca-runtime' +import { + findSshBridgeOrchestrationViolation, + isHostTerminal, + parseSshBridgeSelectors, + SSH_BRIDGE_ORCHESTRATION_METHODS, + type SshBridgeSelectors +} from './ssh-bridge-orchestration-binding' + +export const SSH_BRIDGE_REMOTE_CONTROL_HINT = + 'To let that host\'s CLI control this Orca, enable "Allow this host\'s orca CLI to control Orca" in Settings > SSH for that host.' + +export type SshBridgeResultFilter = ( + result: unknown +) => { kind: 'denied'; message: string } | { kind: 'allowed'; result: unknown } + +export type SshBridgeCallBinding = + | { kind: 'denied'; message: string } + | { kind: 'allowed'; filterResult?: SshBridgeResultFilter } + +type SshBridgeBinder = ( + runtime: OrcaRuntimeService, + targetId: string, + methodName: string, + selectors: SshBridgeSelectors +) => Promise + +const bindTerminalHandle: SshBridgeBinder = async (runtime, targetId, _methodName, selectors) => { + const handle = selectors.terminal + return handle && (await isHostTerminal(runtime, toSshExecutionHostId(targetId), handle)) + ? { kind: 'allowed' } + : { kind: 'denied', message: outsideHostMessage(targetId, `terminal '${handle ?? ''}'`) } +} + +const bindOrchestration: SshBridgeBinder = async (runtime, targetId, methodName, selectors) => { + const violation = await findSshBridgeOrchestrationViolation( + runtime, + toSshExecutionHostId(targetId), + methodName, + selectors + ) + return violation + ? { kind: 'denied', message: outsideHostMessage(targetId, violation) } + : { kind: 'allowed' } +} + +/** Every method an unopted bridge may reach, each with how its selectors are bound to the host. */ +export const SSH_BRIDGE_HOST_BINDERS: ReadonlyMap = new Map< + string, + SshBridgeBinder +>([ + ['status.get', async () => ({ kind: 'allowed' })], + [ + 'terminal.list', + async (_runtime, targetId, _methodName, { worktree }) => ({ + kind: 'allowed', + filterResult: (result) => filterTerminalListToHost(result, targetId, worktree) + }) + ], + ['terminal.show', bindTerminalHandle], + ['terminal.read', bindTerminalHandle], + ['terminal.send', bindTerminalHandle], + ['terminal.wait', bindTerminalHandle], + ...SSH_BRIDGE_ORCHESTRATION_METHODS.map((method): [string, SshBridgeBinder] => [ + method, + bindOrchestration + ]) +]) + +export function bindSshBridgeCall( + runtime: OrcaRuntimeService, + targetId: string, + methodName: string, + params: unknown +): Promise { + const bind = SSH_BRIDGE_HOST_BINDERS.get(methodName) + // Why: the dispatcher admits only table methods, so a miss is unreachable and must fail closed. + return bind + ? bind(runtime, targetId, methodName, parseSshBridgeSelectors(params)) + : Promise.resolve({ + kind: 'denied', + message: outsideHostMessage(targetId, `method '${methodName}'`) + }) +} + +function filterTerminalListToHost( + result: unknown, + targetId: string, + worktreeSelector: string | undefined +): ReturnType { + const hostId = toSshExecutionHostId(targetId) + if (!isTerminalListResult(result)) { + return { kind: 'denied', message: 'Terminal listing could not be scoped to the SSH host.' } + } + const terminals = result.terminals.filter((terminal) => terminal.executionHostId === hostId) + if (worktreeSelector && terminals.length !== result.terminals.length) { + return { + kind: 'denied', + message: outsideHostMessage(targetId, `worktree '${worktreeSelector}'`) + } + } + const worktreeIds = new Set(terminals.map((terminal) => terminal.worktreeId)) + const scoped: RuntimeTerminalListResult = { + terminals, + totalCount: terminals.length, + truncated: result.truncated && terminals.length === result.terminals.length, + ...(result.visualLayouts + ? { + visualLayouts: result.visualLayouts.filter((layout) => worktreeIds.has(layout.worktreeId)) + } + : {}), + ...(result.topologyRevisions + ? { + topologyRevisions: Object.fromEntries( + Object.entries(result.topologyRevisions).filter(([worktreeId]) => + worktreeIds.has(worktreeId) + ) + ) + } + : {}), + ...(result.hostScope + ? { + hostScope: { + hostIds: result.hostScope.hostIds.filter((id) => id === hostId), + omittedHostIds: result.hostScope.omittedHostIds.filter((id) => id === hostId) + } + } + : {}) + } + return { kind: 'allowed', result: scoped } +} + +function isTerminalListResult(value: unknown): value is RuntimeTerminalListResult { + return ( + typeof value === 'object' && + value !== null && + 'terminals' in value && + Array.isArray(value.terminals) && + 'truncated' in value && + typeof value.truncated === 'boolean' + ) +} + +function outsideHostMessage(targetId: string, subject: string): string { + return `The orca CLI on SSH host '${targetId}' can only reach that host's own terminals and the coordinator that dispatched them, and ${subject} is outside that. ${SSH_BRIDGE_REMOTE_CONTROL_HINT}` +} diff --git a/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts new file mode 100644 index 00000000000..eca0f9227a5 --- /dev/null +++ b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts @@ -0,0 +1,371 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { OrchestrationDb } from '../orchestration/db' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { createOrchestrationRpcHarness } from './methods/orchestration/rpc-test-harness' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import { HOST_BOUND_SSH_BRIDGE_SCOPE as HOST_BOUND } from '../../ssh/ssh-bridge-caller-scope.test-fixture' + +const SentMessageResult = z.object({ message: z.object({ id: z.string() }) }) +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const SIBLING_PANE = 'tab_sibling:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const LOCAL_PANE = 'tab_local:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +// A worker on the relay-route SSH host, a sibling on another host, and a local coordinator. +const HOSTS: Record = { + term_worker: 'ssh:box-1', + term_box_peer: 'ssh:box-1', + term_coord: 'local', + term_local: 'local', + term_sibling: 'ssh:box-2' +} + +describe('SSH bridge orchestration without the per-host opt-in', () => { + const harness = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + + afterEach(() => harness.cleanup()) + + function setup(): void { + ;({ db, runtime } = harness.setup()) + const panes: Record = { + term_coord: harness.coordinatorPaneKey, + term_worker: WORKER_PANE, + term_sibling: SIBLING_PANE, + term_local: LOCAL_PANE + } + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) + vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( + (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null + ) + vi.spyOn(runtime, 'showTerminal').mockImplementation(async (handle) => { + const host = HOSTS[handle] + if (!host) { + throw new Error('terminal_not_found') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding reads only executionHostId. + return { handle, executionHostId: host } as Awaited< + ReturnType + > + }) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') + } + + function dispatchFromCoordinator(name: string, paneKey: string) { + const task = db.createTask({ spec: `${name} work` }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'terminal', handle: 'term_coord', paneKey: harness.coordinatorPaneKey }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: `term_${name}`, + paneKey, + processIncarnation: `runtime_test:term_${name}:1`, + worktreeId: `repo::${name}`, + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.dispatch.id) + return { taskId: task.id, dispatchId: started.dispatch.id } + } + + function callAsBridge(method: string, params: Record): Promise { + return new RpcDispatcher({ runtime }).dispatch( + { + id: `req-${method}`, + authToken: 'unused', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + }, + { callerScope: HOST_BOUND } + ) + } + + function forbidden(response: RpcResponse): boolean { + return !response.ok && response.error.code === 'forbidden' + } + + it('lets a worker on the host report worker_done to the local coordinator that dispatched it', async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const response = await callAsBridge('orchestration.send', { + from: 'term_worker', + senderPaneKey: WORKER_PANE, + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ ...worker, outcome: 'succeeded' }) + }) + expect(response).toMatchObject({ ok: true, result: { lifecycle: { action: 'completed' } } }) + expect(db.getTask(worker.taskId)?.status).toBe('completed') + }) + + it('lets that worker check its own mailbox and message its coordinator by handle', async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + expect((await callAsBridge('orchestration.check', { terminal: 'term_worker' })).ok).toBe(true) + expect((await callAsBridge('orchestration.inbox', { terminal: 'term_worker' })).ok).toBe(true) + const status = await callAsBridge('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'progress', + type: 'status' + }) + expect(forbidden(status)).toBe(false) + }) + + it.each<[string, string, Record]>([ + ['a sender on another host', 'orchestration.send', { from: 'term_sibling', subject: 'x' }], + ['a sender with no handle', 'orchestration.send', { subject: 'x' }], + [ + "a sibling's pane key", + 'orchestration.send', + { from: 'term_worker', senderPaneKey: SIBLING_PANE, subject: 'x' } + ], + [ + 'a local terminal that is not its coordinator', + 'orchestration.send', + { + from: 'term_worker', + to: 'term_local', + subject: 'x' + } + ], + ['a group address', 'orchestration.send', { from: 'term_worker', to: '@all', subject: 'x' }], + ["another host's mailbox", 'orchestration.check', { terminal: 'term_sibling' }], + ['an unscoped inbox', 'orchestration.inbox', {}], + [ + "a local terminal's question", + 'orchestration.ask', + { + from: 'term_local', + question: 'Proceed?' + } + ] + ])('refuses %s', async (_case, method, params) => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + expect(forbidden(await callAsBridge(method, params))).toBe(true) + }) + + it("refuses a report that names another worker's Dispatch", async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) + const response = await callAsBridge('orchestration.send', { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ ...sibling, outcome: 'succeeded' }) + }) + expect(forbidden(response)).toBe(true) + expect(db.getTask(sibling.taskId)?.status).toBe('dispatched') + }) + + it('refuses a run the caller is no party to', async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + const other = db.createRun({ + objective: 'Other', + coordinatorHandle: 'term_local', + coordinatorPaneKey: LOCAL_PANE + }) + const response = await callAsBridge('orchestration.check', { + terminal: 'term_box_peer', + run: other.id + }) + expect(forbidden(response)).toBe(true) + }) + + function callAsOwner(method: string, params: Record): Promise { + return new RpcDispatcher({ runtime }).dispatch({ + id: `owner-${method}`, + authToken: 'unused', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + }) + } + + function placeCoordinatorOnHost(): void { + vi.mocked(runtime.showTerminal).mockImplementation(async (handle) => { + const host = handle === 'term_coord' ? 'ssh:box-1' : HOSTS[handle] + if (!host) { + throw new Error('terminal_not_found') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding reads only executionHostId. + return { handle, executionHostId: host } as Awaited< + ReturnType + > + }) + } + + it("lets a worker reply to coordinator mail routed to its own Dispatch's mailbox", async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const sent = await callAsOwner('orchestration.send', { + from: 'term_coord', + to: 'term_worker', + subject: 'Please confirm', + body: 'Continue?', + type: 'status' + }) + expect(sent).toMatchObject({ + ok: true, + result: { message: { to_handle: `dispatch:${worker.dispatchId}` } } + }) + const { message } = SentMessageResult.parse(sent.ok ? sent.result : null) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_worker', + id: message.id, + body: 'Confirmed' + }) + expect(reply.ok).toBe(true) + }) + + it('lets an SSH coordinator answer a worker question stored in its own Run mailbox', async () => { + setup() + placeCoordinatorOnHost() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' + const { message, question } = db.createQuestion({ + runId, + dispatchId: worker.dispatchId, + askerHandle: 'term_worker', + question: 'May I continue?', + options: [] + }) + expect(message.to_handle).toBe(`run:${runId}`) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_coord', + id: message.id, + body: 'Proceed' + }) + expect(reply.ok).toBe(true) + expect(db.getQuestion(question.message_id)?.status).toBe('answered') + }) + + it("lets a worker address its own Run's mailbox and its own Dispatch explicitly", async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' + for (const to of [`run:${runId}`, `dispatch:${worker.dispatchId}`]) { + const sent = await callAsBridge('orchestration.send', { + from: 'term_worker', + to, + subject: 'p' + }) + expect(forbidden(sent)).toBe(false) + } + const asked = await callAsBridge('orchestration.ask', { + from: 'term_worker', + to: `run:${runId}`, + question: 'Proceed?', + timeoutMs: 1 + }) + expect(forbidden(asked)).toBe(false) + }) + + it('refuses a reply to mail addressed to another terminal, Dispatch, or Run', async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) + const other = db.createRun({ + objective: 'Other', + coordinatorHandle: 'term_local', + coordinatorPaneKey: LOCAL_PANE + }) + for (const to of ['term_local', `dispatch:${sibling.dispatchId}`, `run:${other.id}`]) { + const original = db.insertMessage({ from: 'term_coord', to, subject: 's', body: 's' }) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_worker', + id: original.id, + body: 'forged' + }) + expect(forbidden(reply)).toBe(true) + expect(db.getMessageById(original.id)?.read).toBe(0) + } + }) + + it("refuses a worker reply to its Run's question, which only the coordinator owns", async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' + const { message } = db.createQuestion({ + runId, + dispatchId: worker.dispatchId, + askerHandle: 'term_worker', + question: 'May I continue?', + options: [] + }) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_worker', + id: message.id, + body: 'Proceed' + }) + expect(forbidden(reply)).toBe(true) + }) + + it("refuses explicit addresses for another worker's Dispatch or an unrelated Run", async () => { + setup() + dispatchFromCoordinator('worker', WORKER_PANE) + const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) + const other = db.createRun({ + objective: 'Other', + coordinatorHandle: 'term_local', + coordinatorPaneKey: LOCAL_PANE + }) + for (const to of [`dispatch:${sibling.dispatchId}`, `run:${other.id}`]) { + const sent = await callAsBridge('orchestration.send', { + from: 'term_worker', + to, + subject: 'p' + }) + expect(forbidden(sent)).toBe(true) + } + }) + + it('refuses a forged pane claiming the Dispatch mailbox', async () => { + setup() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + // A process on the worker's handle that does not hold the Dispatch's pane owns no Dispatch mail. + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_worker' + ? SIBLING_PANE + : handle === 'term_coord' + ? harness.coordinatorPaneKey + : null + ) + const original = db.insertMessage({ + from: 'term_coord', + to: `dispatch:${worker.dispatchId}`, + subject: 's', + body: 's' + }) + const reply = await callAsBridge('orchestration.reply', { + from: 'term_worker', + id: original.id, + body: 'forged' + }) + expect(forbidden(reply)).toBe(true) + }) + + it("lets an SSH coordinator address a same-host worker's Dispatch but not another host's", async () => { + setup() + placeCoordinatorOnHost() + const worker = dispatchFromCoordinator('worker', WORKER_PANE) + const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) + const send = (to: string) => + callAsBridge('orchestration.send', { from: 'term_coord', to, subject: 'p' }) + expect(forbidden(await send(`dispatch:${worker.dispatchId}`))).toBe(false) + expect(forbidden(await send(`dispatch:${sibling.dispatchId}`))).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts new file mode 100644 index 00000000000..3293abf33f2 --- /dev/null +++ b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts @@ -0,0 +1,166 @@ +/** + * Lets an SSH host's relayed `orca` CLI take part in orchestration as that host's own terminals. + * + * Without the per-target opt-in, the caller (`from` / `terminal`) must be a terminal on the bridged + * host. A party outside the host is reachable only as the coordinator of a Dispatch the caller works + * on, so a worker reports back to whoever dispatched it but cannot message or read anyone else. + * Routing stores mail under `run:`/`dispatch:` addresses, so ownership is resolved to those exact + * mailboxes the caller's live pane reads, never to a Run it merely shares. + */ +import { z } from 'zod' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrchestrationDb } from '../orchestration/db' +import { + resolveTerminalOwnedMailboxes, + type TerminalOwnedMailboxes +} from './methods/orchestration/messaging/terminal-owned-mailboxes' + +const TERMINAL_CALLER_METHODS: ReadonlySet = new Set([ + 'orchestration.check', + 'orchestration.inbox' +]) + +export const SSH_BRIDGE_ORCHESTRATION_METHODS: readonly string[] = [ + ...TERMINAL_CALLER_METHODS, + 'orchestration.send', + 'orchestration.ask', + 'orchestration.reply' +] + +/** The refused subject, or null when the call stays inside the caller's own orchestration. */ +export async function findSshBridgeOrchestrationViolation( + runtime: OrcaRuntimeService, + hostId: ExecutionHostId, + methodName: string, + selectors: SshBridgeSelectors +): Promise { + const caller = selectors[TERMINAL_CALLER_METHODS.has(methodName) ? 'terminal' : 'from'] + if (!caller || !(await isHostTerminal(runtime, hostId, caller))) { + return `terminal '${caller ?? ''}'` + } + const paneKey = runtime.getTerminalPaneKey(caller) ?? undefined + const claimedPane = + selectors[methodName === 'orchestration.check' ? 'terminalPaneKey' : 'senderPaneKey'] + // Why: the pane key is the lifecycle identity, so it must be the caller's own, not a sibling's. + if (claimedPane && claimedPane !== paneKey) { + return `pane '${claimedPane}'` + } + const db = runtime.getOrchestrationDb() + const ownDispatch = db.getActiveDispatchForIdentity(caller, paneKey) + const run = selectors.run + if (run && ownDispatch?.run_id !== run && db.getRun(run)?.coordinator_handle !== caller) { + return `run '${run}'` + } + const owned = resolveTerminalOwnedMailboxes(runtime, db, caller) + if (methodName === 'orchestration.reply') { + const id = selectors.id ?? '' + const original = db.getMessageById(id) + return original && owned.addresses.has(original.to_handle) && (!run || run === original.run_id) + ? null + : `message '${id}'` + } + if (TERMINAL_CALLER_METHODS.has(methodName)) { + return null + } + const dispatchId = readPayloadDispatchId(selectors) + if (dispatchId) { + const named = db.getDispatchContextById(dispatchId) + const party = + named && + (named.assignee_handle === caller || + (paneKey !== undefined && named.assignee_pane_key === paneKey) || + named.creator_handle === caller) + if (!party) { + return `dispatch '${dispatchId}'` + } + } + const to = selectors.to + if ( + !to || + (ownDispatch?.creator_handle && to === ownDispatch.creator_handle) || + (await isOwnCanonicalRecipient(db, owned, runtime, hostId, to)) + ) { + return null + } + return (await isHostTerminal(runtime, hostId, to)) ? null : `recipient '${to}'` +} + +// A canonical address is in scope when it is the caller's own mailbox, the Run mailbox its held +// Dispatch reports to, or a same-host worker's Dispatch in the Run the caller coordinates. +async function isOwnCanonicalRecipient( + db: OrchestrationDb, + owned: TerminalOwnedMailboxes, + runtime: OrcaRuntimeService, + hostId: ExecutionHostId, + to: string +): Promise { + if (owned.addresses.has(to) || (owned.dispatch && to === `run:${owned.dispatch.run_id}`)) { + return true + } + if (!to.startsWith('dispatch:') || owned.runId === undefined) { + return false + } + const dispatch = db.getDispatchContextById(to.slice('dispatch:'.length)) + return ( + dispatch?.run_id === owned.runId && + dispatch.assignee_handle !== null && + (await isHostTerminal(runtime, hostId, dispatch.assignee_handle)) + ) +} + +export async function isHostTerminal( + runtime: OrcaRuntimeService, + hostId: ExecutionHostId, + handle: string +): Promise { + return (await resolveTerminalHost(runtime, handle)) === hostId +} + +async function resolveTerminalHost( + runtime: OrcaRuntimeService, + handle: string +): Promise { + try { + return (await runtime.showTerminal(handle)).executionHostId ?? null + } catch { + return null + } +} + +function readPayloadDispatchId(selectors: SshBridgeSelectors): string | null { + if (!selectors.payload) { + return null + } + try { + return parseSshBridgeSelectors(JSON.parse(selectors.payload)).dispatchId ?? null + } catch { + // Why: the handler owns malformed-payload errors; nothing here can name a Dispatch. + return null + } +} + +// Why: a missing, non-string or empty selector names nothing, and the handler owns rejecting it. +const BridgeSelector = z.string().min(1).optional().catch(undefined) + +/** The selector fields the bridge binds to the caller's host; anything else names nothing. */ +const SshBridgeCallSelectors = z + .object({ + terminal: BridgeSelector, + terminalPaneKey: BridgeSelector, + senderPaneKey: BridgeSelector, + from: BridgeSelector, + to: BridgeSelector, + run: BridgeSelector, + id: BridgeSelector, + payload: BridgeSelector, + dispatchId: BridgeSelector, + worktree: BridgeSelector + }) + .catch({}) + +export type SshBridgeSelectors = z.infer + +export function parseSshBridgeSelectors(params: unknown): SshBridgeSelectors { + return SshBridgeCallSelectors.parse(params) +} diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 48afad4f3ac..d24eaeb10de 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -33,6 +33,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.pairing-stream', + permission: 'workspace', params: null, handler: async (_params, ctx) => { receivedPairing = ctx.pairing @@ -56,6 +57,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'terminal.subscribe', + permission: 'workspace', params: z.object({ terminal: z.string() }), handler: async (params, { runtime }, emit) => { const read = await (runtime as OrcaRuntimeService).readTerminal(params.terminal) @@ -100,6 +102,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.stream', + permission: 'workspace', params: null, handler: async (_params, _ctx, emit) => { emitFn = emit @@ -156,6 +159,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.subscribe', + permission: 'workspace', params: null, handler: async (_params, { runtime }, emit) => { emit({ type: 'scrollback', lines: '' }) @@ -170,6 +174,7 @@ describe('RpcDispatcher streaming', () => { }), defineMethod({ name: 'test.unsubscribe', + permission: 'workspace', params: z.object({ subscriptionId: z.string() }), handler: async (params, { runtime }) => { ;(runtime as OrcaRuntimeService).cleanupSubscription(params.subscriptionId) @@ -218,6 +223,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineMethod({ name: 'status.get', + permission: 'workspace', params: null, handler: async () => ({ status: 'ok' }) }) @@ -256,6 +262,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.stream', + permission: 'workspace', params: null, handler: async () => {} }) @@ -277,6 +284,7 @@ describe('RpcDispatcher streaming', () => { methods: [ defineStreamingMethod({ name: 'test.explode', + permission: 'workspace', params: null, handler: async () => { throw new Error('boom') diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 19e22540c1f..f07a595c50b 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -339,8 +339,9 @@ describe('terminal multiplex RPC', () => { ).toMatchObject({ requestId: 7 }) + // Desktop initial snapshots carry history, not just the screen (#20158). expect(runtime.serializeTerminalBuffer).toHaveBeenCalledWith('pty-1', { - scrollbackRows: 0 + scrollbackRows: 1000 }) expect(runtime.serializeAuthoritativeTerminalBuffer).toHaveBeenLastCalledWith('pty-1', { scrollbackRows: 5000 diff --git a/src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts b/src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts new file mode 100644 index 00000000000..b0ad5514ad7 --- /dev/null +++ b/src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts @@ -0,0 +1,143 @@ +import type { Socket } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocket, WebSocketServer } from 'ws' + +/** + * Guards config/patches/ws@8.22.0.patch. Under Electron on arm64 kernels with + * 39-bit virtual addresses, `socket.write()` of a zero-length buffer fails with + * EFAULT and destroys the socket, so every empty ping, auto-pong or empty frame + * closed the remote runtime connection with 1006. These sockets fail the same + * way, so the cases fail if the patch is dropped or a ws bump loses it. + */ + +type Peer = { ws: WebSocket; writeLengths: number[] } + +const cleanups: (() => void)[] = [] + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } +}) + +function failEmptyWritesLikeArm64Electron(socket: Socket): number[] { + const writeLengths: number[] = [] + const write = socket.write.bind(socket) + // ws calls write(chunk, cb); Node treats a function encoding as the callback. + vi.spyOn(socket, 'write').mockImplementation((chunk, encoding, cb) => { + writeLengths.push(chunk.length) + if (chunk.length === 0) { + socket.destroy(Object.assign(new Error('write EFAULT'), { code: 'EFAULT' })) + return false + } + return write(chunk, encoding, cb) + }) + return writeLengths +} + +async function connectPair(): Promise<{ server: Peer; client: Peer }> { + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + cleanups.push(() => wss.close()) + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() + if (typeof address !== 'object' || address === null) { + throw new Error('WebSocketServer has no TCP address') + } + const { port } = address + + const serverPeer = new Promise((resolve) => { + wss.once('connection', (ws, request) => { + resolve({ ws, writeLengths: failEmptyWritesLikeArm64Electron(request.socket) }) + }) + }) + const clientWs = new WebSocket(`ws://127.0.0.1:${port}`) + cleanups.push(() => clientWs.terminate()) + let clientWriteLengths: number[] = [] + clientWs.once('upgrade', (response) => { + clientWriteLengths = failEmptyWritesLikeArm64Electron(response.socket) + }) + await new Promise((resolve, reject) => { + clientWs.once('open', () => resolve()) + clientWs.once('error', reject) + }) + const server = await serverPeer + return { server, client: { ws: clientWs, writeLengths: clientWriteLengths } } +} + +function sendCallback(): { + done: Promise + cb: (err?: Error | null) => void +} { + let cb: (err?: Error | null) => void = () => {} + const done = new Promise((resolve) => { + cb = vi.fn((err?: Error | null) => resolve(err)) + }) + return { done, cb } +} + +function closedCode(ws: WebSocket): Promise { + return new Promise((resolve) => ws.once('close', (code) => resolve(code))) +} + +describe('ws empty-payload frame writes', () => { + it('server heartbeat ping with no payload reaches the client and keeps the socket open', async () => { + const { server, client } = await connectPair() + const received = new Promise((resolve) => client.ws.once('ping', resolve)) + const { done, cb } = sendCallback() + + server.ws.ping(undefined, undefined, cb) + + expect(await done).toBeFalsy() + expect((await received).length).toBe(0) + // ws auto-pongs the client's empty ping back on the server's socket. + await new Promise((resolve) => server.ws.once('pong', () => resolve())) + expect(server.writeLengths).not.toContain(0) + expect(client.writeLengths).not.toContain(0) + expect(server.ws.readyState).toBe(WebSocket.OPEN) + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('auto-pong to an empty client ping does not write an empty payload on the server', async () => { + const { server, client } = await connectPair() + const pong = new Promise((resolve) => client.ws.once('pong', resolve)) + + client.ws.ping() + + expect((await pong).length).toBe(0) + expect(server.writeLengths).not.toContain(0) + expect(client.writeLengths).not.toContain(0) + expect(server.ws.readyState).toBe(WebSocket.OPEN) + }) + + it('empty pong and empty text frames are delivered with exactly one callback each', async () => { + const { server, client } = await connectPair() + const pong = new Promise((resolve) => client.ws.once('pong', resolve)) + const message = new Promise((resolve) => + client.ws.once('message', (data: Buffer) => resolve(data.toString())) + ) + const pongCallback = sendCallback() + const sendCallbackState = sendCallback() + + server.ws.pong(undefined, undefined, pongCallback.cb) + server.ws.send('', sendCallbackState.cb) + + expect(await pongCallback.done).toBeFalsy() + expect(await sendCallbackState.done).toBeFalsy() + expect((await pong).length).toBe(0) + expect(await message).toBe('') + expect(pongCallback.cb).toHaveBeenCalledTimes(1) + expect(sendCallbackState.cb).toHaveBeenCalledTimes(1) + expect(server.writeLengths).not.toContain(0) + }) + + it('a close with no code completes the closing handshake instead of 1006', async () => { + const { server, client } = await connectPair() + const clientClosed = closedCode(client.ws) + + server.ws.close() + + expect(await clientClosed).toBe(1005) + expect(server.writeLengths).not.toContain(0) + expect(client.writeLengths).not.toContain(0) + }) +}) diff --git a/src/main/runtime/runtime-pairing-desktop-control-grant.test.ts b/src/main/runtime/runtime-pairing-desktop-control-grant.test.ts new file mode 100644 index 00000000000..3b047705da6 --- /dev/null +++ b/src/main/runtime/runtime-pairing-desktop-control-grant.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { DeviceRegistry } from './device-registry' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { getServeOptions } from '../startup/serve-options' +import { parseArgs as parseOrcadArgs } from '../orcad/orcad-command-arguments' + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), 'orca-desktop-control-grant-')) +} + +async function dispatchOverWebSocket( + server: OrcaRuntimeRpcServer, + deviceToken: string, + method: string, + params: unknown = { app: 'Finder' } +): Promise> { + const replies: Record[] = [] + await server['handleWebSocketMessage']( + JSON.stringify({ id: `req-${method}`, method, deviceToken, params }), + (response) => replies.push(JSON.parse(response)), + () => {} + ) + return replies[0] ?? {} +} + +function errorCode(reply: Record): unknown { + const error = reply.error + return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined +} + +describe('desktop control for paired runtime clients', () => { + it('is refused to a runtime pairing that did not grant it', async () => { + const userDataPath = tempDir() + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('laptop', 'runtime') + const reply = await dispatchOverWebSocket(server, device.token, 'computer.click') + expect(errorCode(reply)).toBe('forbidden') + }) + + it('passes the permission gate for a pairing created with the grant', async () => { + const userDataPath = tempDir() + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('laptop', 'runtime', 'network', [ + 'desktop-control' + ]) + const reply = await dispatchOverWebSocket(server, device.token, 'computer.click') + expect(errorCode(reply)).not.toBe('forbidden') + }) +}) + +describe('pairing and push administration for paired runtime clients', () => { + it.each<[string, unknown]>([ + ['pairing.provisionRelay', {}], + ['notifications.registerPush', { token: 't', platform: 'ios' }] + ])('refuses %s without reaching its service', async (method, params) => { + const userDataPath = tempDir() + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('laptop', 'runtime') + const registerPush = vi.spyOn(runtime, 'registerMobilePushDevice') + const reply = await dispatchOverWebSocket(server, device.token, method, params) + expect(errorCode(reply)).toBe('forbidden') + expect(registerPush).not.toHaveBeenCalled() + }) +}) + +describe('pairing-time grants on the device registry', () => { + it('persist across a reload and are dropped for mobile devices', () => { + const userDataPath = tempDir() + const registry = new DeviceRegistry(userDataPath) + const runtime = registry.addDevice('laptop', 'runtime', 'network', ['desktop-control']) + const phone = registry.addDevice('phone', 'mobile', 'network', ['desktop-control']) + + const reloaded = new DeviceRegistry(userDataPath) + expect(reloaded.getDevice(runtime.deviceId)?.grants).toEqual(['desktop-control']) + expect(reloaded.getDevice(phone.deviceId)?.grants).toBeUndefined() + }) + + it('never widen a pending offer that was advertised without them', () => { + const registry = new DeviceRegistry(tempDir()) + const plain = registry.getOrCreatePendingDevice('cli', 'runtime') + const granted = registry.getOrCreatePendingDevice('cli', 'runtime', 'network', [ + 'desktop-control' + ]) + expect(granted.token).not.toBe(plain.token) + expect(registry.getDevice(plain.deviceId)?.grants).toBeUndefined() + expect(registry.getOrCreatePendingDevice('cli', 'runtime').token).toBe(plain.token) + }) +}) + +describe('the --grant-desktop-control serve flag', () => { + it('reaches the desktop serve options in both argv shapes', () => { + expect(getServeOptions(['/AppRun', '--serve']).grantDesktopControl).toBe(false) + expect( + getServeOptions(['/AppRun', '--serve', '--serve-grant-desktop-control']).grantDesktopControl + ).toBe(true) + expect(() => + getServeOptions(['/AppRun', '--serve', '--grant-desktop-control', '--mobile-pairing']) + ).toThrow('--grant-desktop-control applies only to the default runtime pairing offer.') + }) + + it('reaches orcad', () => { + expect(parseOrcadArgs(['--grant-desktop-control'])).toEqual({ grantDesktopControl: true }) + expect(() => parseOrcadArgs(['--grant-desktop-control', '--no-pairing'])).toThrow( + '--grant-desktop-control applies only to the default runtime pairing offer' + ) + }) +}) diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index cdc3ab1c60f..bfcbb4e5fe1 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -14,6 +14,7 @@ import type { } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' import { + getRepoExecutionHostId, getSshTargetIdForExecutionHost, parseExecutionHostId, type ExecutionHostId @@ -162,7 +163,10 @@ export class RuntimeProjectHostSetupController { return this.linkRepo(args, initialRepo) } catch (error) { if (repoWasCreated) { - this.deps.getStore()?.removeProject?.(initialRepo.id) + // Why: host-scoped so a rollback never takes a same-id row on another host. + this.deps + .getStore() + ?.removeProjectForHost?.(initialRepo.id, getRepoExecutionHostId(initialRepo)) this.deps.invalidateResolvedWorktrees() this.deps.invalidateWorktreeScan(initialRepo.id) invalidateAuthorizedRootsCache() diff --git a/src/main/runtime/runtime-remove-project-host-scope.test.ts b/src/main/runtime/runtime-remove-project-host-scope.test.ts index ab9a781d0db..43b9d44ae78 100644 --- a/src/main/runtime/runtime-remove-project-host-scope.test.ts +++ b/src/main/runtime/runtime-remove-project-host-scope.test.ts @@ -1,7 +1,7 @@ /** * The same repo id is allowed on two execution hosts (persistence.ts `removeProjectForHost`). - * `repo.rm` resolves a *row*, so the deletion it performs must be scoped to that row's host: - * `Store.removeProject` is id-only and would take the sibling host's registration with it. + * `repo.rm` resolves a *row*, so the deletion it performs must be scoped to that row's host, + * never the sibling host's registration. * A `path:`/`name:` selector resolves unambiguously even when the id is duplicated, so this * is reachable — and since #11994 every paired device now learns about it immediately. */ @@ -32,13 +32,6 @@ function makeRepos(): Repo[] { function createRuntime() { const repos = makeRepos() - const removeProject = vi.fn((id: string) => { - for (let index = repos.length - 1; index >= 0; index -= 1) { - if (repos[index].id === id) { - repos.splice(index, 1) - } - } - }) const removeProjectForHost = vi.fn((id: string, hostId: string) => { for (let index = repos.length - 1; index >= 0; index -= 1) { const repo = repos[index] @@ -57,10 +50,9 @@ function createRuntime() { setWorktreeMeta: vi.fn(), removeWorktreeMeta: vi.fn(), getGitHubCache: () => null, - removeProject, removeProjectForHost } as never) - return { runtime, repos, removeProject, removeProjectForHost } + return { runtime, repos, removeProjectForHost } } describe('repo.rm with the same repo id on two execution hosts', () => { @@ -81,10 +73,9 @@ describe('repo.rm with the same repo id on two execution hosts', () => { }) it('refuses a bare duplicated id rather than guessing a host', async () => { - const { runtime, repos, removeProject, removeProjectForHost } = createRuntime() + const { runtime, repos, removeProjectForHost } = createRuntime() await expect(runtime.removeProject('dup')).rejects.toThrow('selector_ambiguous') - expect(removeProject).not.toHaveBeenCalled() expect(removeProjectForHost).not.toHaveBeenCalled() expect(repos).toHaveLength(2) }) diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index 27260d23744..c4a02d656f4 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -105,22 +105,11 @@ export class RuntimeRepositorySettingsController { async remove(repoSelector: string): Promise<{ removed: true }> { const store = this.deps.getStore() - if (!store?.removeProject) { + if (!store?.removeProjectForHost) { throw new Error('runtime_unavailable') } const repo = await this.deps.resolveRepo(repoSelector) - const hostId = getRepoExecutionHostId(repo) - const idExistsOnOtherHost = store - .getRepos() - .some((entry) => entry.id === repo.id && getRepoExecutionHostId(entry) !== hostId) - if (idExistsOnOtherHost) { - if (!store.removeProjectForHost) { - throw new Error('runtime_unavailable') - } - store.removeProjectForHost(repo.id, hostId) - } else { - store.removeProject(repo.id) - } + store.removeProjectForHost(repo.id, getRepoExecutionHostId(repo)) this.deps.forgetTerminalTopology(repo.id) this.deps.invalidateResolvedWorktrees() this.deps.invalidateWorktreeScan(repo.id) diff --git a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts index 8b7446e6f40..b9187315478 100644 --- a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts +++ b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts @@ -32,6 +32,7 @@ describe('runtime RPC browser-host admission', () => { const blockingMethod = (name: 'browser.clientHost.attach' | 'terminal.wait') => defineStreamingMethod({ name, + permission: 'workspace', params: null, handler: async (_params, { signal }) => { await new Promise((resolve) => { @@ -102,6 +103,7 @@ describe('runtime RPC browser-host admission', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-host-fairness-')) const blockingHost = defineStreamingMethod({ name: 'browser.clientHost.attach', + permission: 'workspace', params: null, handler: async (_params, { signal }) => await new Promise((resolve) => signal?.addEventListener('abort', () => resolve())) @@ -195,6 +197,7 @@ describe('runtime RPC browser-host admission', () => { ) => defineStreamingMethod({ name, + permission: 'workspace', params: null, handler: async (_params, { signal }) => await new Promise((resolve) => signal?.addEventListener('abort', () => resolve())) diff --git a/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts b/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts new file mode 100644 index 00000000000..79e67e708e0 --- /dev/null +++ b/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { sshBridgeCredentials } from './rpc/ssh-bridge-credentials' +import type { RpcResponse } from './rpc/core' + +const BRIDGE_SCOPE = { kind: 'ssh-bridge', targetId: 'box-1', remoteCliControl: false } as const + +function createServer(): OrcaRuntimeRpcServer { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-bridge-')) + return new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: false + }) +} + +async function send( + server: OrcaRuntimeRpcServer, + authToken: string, + method: string, + params: unknown = {} +): Promise { + return server['handleMessage'](JSON.stringify({ id: `req-${method}`, authToken, method, params })) +} + +function errorCode(response: RpcResponse): string | null { + return response.ok ? null : response.error.code +} + +describe('the runtime socket and SSH bridge credentials', () => { + it('scopes a bridge credential to its SSH target instead of owner authority', async () => { + const server = createServer() + const credential = sshBridgeCredentials.mint(BRIDGE_SCOPE) + try { + expect(errorCode(await send(server, credential.token, 'computer.click'))).toBe('forbidden') + expect(errorCode(await send(server, credential.token, 'accounts.selectClaude'))).toBe( + 'forbidden' + ) + expect(errorCode(await send(server, credential.token, 'skills.install'))).toBe('forbidden') + expect((await send(server, credential.token, 'status.get')).ok).toBe(true) + } finally { + credential.revoke() + } + }) + + it('rejects a credential once its invocation settled', async () => { + const server = createServer() + const credential = sshBridgeCredentials.mint(BRIDGE_SCOPE) + credential.revoke() + expect(errorCode(await send(server, credential.token, 'status.get'))).toBe('unauthorized') + }) + + it('keeps the owner token unscoped', async () => { + const server = createServer() + const response = await send(server, server['authToken'], 'computer.click') + expect(errorCode(response)).not.toBe('forbidden') + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 7d8bba9f958..987d2b66ada 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -1,3 +1,4 @@ +import type { RuntimeDeviceGrant } from '../rpc/rpc-caller-scope' import type { DeviceEntry, DeviceRegistry, DeviceScope } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' @@ -130,6 +131,8 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { // Why: STA-2370 — recorded on the grant so a "This computer only" client reconnecting cannot make the // next launch bind every interface. Defaults to network reach, which is what every other caller means. reach?: RuntimePairingReach + // Why: administrative permissions exist only when granted here, never added to a paired device later. + grants?: readonly RuntimeDeviceGrant[] }): | PairingOfferUnavailable | { @@ -167,9 +170,10 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { let device: DeviceEntry try { const reach = args.reach ?? 'network' + const grants = args.grants ?? [] device = args.rotate - ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach) - : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach) + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach, grants) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach, grants) } catch (error) { console.error('[runtime] Failed to persist pairing credential:', error) return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts index b79b87a5be5..ad1bd49cbad 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts @@ -5,6 +5,8 @@ import type { RpcRequest, RpcResponse } from '../rpc/core' import { errorResponse } from '../rpc/errors' import { RuntimeRpcBinaryRouting } from './runtime-rpc-binary-routing' import { classifyRuntimeLongPoll, type RuntimeLongPollClass } from './runtime-rpc-long-poll' +import { OWNER_RPC_CALLER_SCOPE, type RpcCallerScope } from '../rpc/rpc-caller-scope' +import { sshBridgeCredentials } from '../rpc/ssh-bridge-credentials' export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { // Why: Unix socket dispatch is one-shot and auths via the shared token from the 0o600 metadata file. See §3.1. @@ -21,7 +23,7 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { if ('error' in parsed) { return parsed.error } - const request = parsed.request + const { request, callerScope } = parsed // Why: long-poll admission fence; short RPCs bypass the counter. See §7 risk #2. const longPoll = classifyRuntimeLongPoll(request) @@ -36,7 +38,8 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { try { return await this.dispatcher.dispatch(request, { - signal: longPoll ? context?.signal : undefined + signal: longPoll ? context?.signal : undefined, + callerScope }) } finally { this.releaseLongPoll(longPoll) @@ -111,7 +114,9 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { } } - protected parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { + protected parseAndAuth( + rawMessage: string + ): { request: RpcRequest; callerScope: RpcCallerScope } | { error: RpcResponse } { let request: RpcRequest try { request = JSON.parse(rawMessage) as RpcRequest @@ -128,11 +133,15 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { if (typeof request.authToken !== 'string' || request.authToken.length === 0) { return { error: this.buildError(request.id, 'unauthorized', 'Missing auth token') } } - if (request.authToken !== this.authToken) { - return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } + if (request.authToken === this.authToken) { + return { request, callerScope: OWNER_RPC_CALLER_SCOPE } } - - return { request } + // Why: a bridged SSH CLI holds only its invocation's credential, scoped to that SSH target. + const bridgeScope = sshBridgeCredentials.resolve(request.authToken) + if (bridgeScope) { + return { request, callerScope: bridgeScope } + } + return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } } protected buildError(id: string, code: string, message: string): RpcResponse { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index dd714b77528..3c07d5ae265 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -10,7 +10,7 @@ import type { WebSocketTransport } from '../rpc/ws-transport' import type { DeviceScope } from '../device-registry' import { RuntimeRpcRequestAdmission } from './runtime-rpc-request-admission' import { classifyRuntimeLongPoll } from './runtime-rpc-long-poll' -import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc-mobile-method-allowlist' +import type { RpcCallerScope } from '../rpc/rpc-caller-scope' // Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary. function injectDeviceScope(response: string, scope: DeviceScope): string { @@ -73,18 +73,11 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) return } - if (device.scope === 'mobile' && !MOBILE_RPC_METHOD_ALLOWLIST.has(request.method)) { - reply( - JSON.stringify( - this.buildError( - request.id, - 'forbidden', - `Method '${request.method}' is not available to mobile clients` - ) - ) - ) - return - } + // Why: the dispatcher enforces this scope's permissions; it never trusts a request field. + const callerScope: RpcCallerScope = + device.scope === 'mobile' + ? { kind: 'mobile' } + : { kind: 'runtime-paired', grants: device.grants ?? [] } // Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected. if (wsTransport && ws) { @@ -140,6 +133,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { pairedDeviceId: device.deviceId, // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, + callerScope, clientCapabilities: authenticatedSocket?.clientCapabilities, updateClientCapabilities: authenticatedSocket && device.scope === 'mobile' diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 24940d7f2db..b25168334c6 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -26,7 +26,6 @@ export type RuntimeStore = { createFolderWorkspace?: Store['createFolderWorkspace'] updateFolderWorkspace?: Store['updateFolderWorkspace'] removeFolderWorkspace?: Store['removeFolderWorkspace'] - removeProject?: Store['removeProject'] removeProjectForHost?: Store['removeProjectForHost'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] diff --git a/src/main/runtime/terminal-tail-buffer.ts b/src/main/runtime/terminal-tail-buffer.ts index de9a36331fb..5cf9cb1809b 100644 --- a/src/main/runtime/terminal-tail-buffer.ts +++ b/src/main/runtime/terminal-tail-buffer.ts @@ -8,6 +8,7 @@ import { trimTerminalLineRight } from './terminal-tail-line-controls' import { MAX_TAIL_CHARS, MAX_TAIL_LINES, MAX_TAIL_PARTIAL_CHARS } from './terminal-tail-limits' +import { maxUpwardCursorReach } from './terminal-tail-redraw-reach' import { appendNormalizedToMultilineTailBufferUnwindowed, type RetainedTailRedrawCursor @@ -223,24 +224,6 @@ export function appendNormalizedToTailBuffer( // Why a window: the unwindowed impl below is O(tail) per chunk (~93% of the event loop under TUI flood, findings log 2026-07-03); a redraw only touches rows the cursor reaches, so window the suffix and share the prefix by reference. Equivalence fuzz-verified in retained-tail-redraw-window.equivalence.test.ts. const REDRAW_WINDOW_SAFETY_ROWS = 8 -// Why module-level: this ran `new RegExp` per redraw chunk — i.e. per TUI frame per PTY. -// Safe to share because `maxUpwardCursorReach` is synchronous and non-reentrant; it resets -// `lastIndex` before every scan. -const CURSOR_UP_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[(\\d*)(?:;[\\d;]*)?A`, 'g') - -function maxUpwardCursorReach( - normalizedChunk: string, - previousRedrawCursor: RetainedTailRedrawCursor | null -): number { - let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 - CURSOR_UP_PATTERN.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = CURSOR_UP_PATTERN.exec(normalizedChunk)) !== null) { - reach += match[1] ? Number.parseInt(match[1], 10) : 1 - } - return reach -} - function appendNormalizedToMultilineTailBuffer( previousLines: string[], boundedPreviousPartialLine: string, @@ -256,7 +239,11 @@ function appendNormalizedToMultilineTailBuffer( newlyCompletedLines: string[] } { const windowRows = - maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS + maxUpwardCursorReach( + normalizedChunk, + previousRedrawCursor, + previousLines.length - REDRAW_WINDOW_SAFETY_ROWS + ) + REDRAW_WINDOW_SAFETY_ROWS if (windowRows >= previousLines.length) { const unwindowed = appendNormalizedToMultilineTailBufferUnwindowed( previousLines, diff --git a/src/main/runtime/terminal-tail-per-character-redraw.test-fixture.ts b/src/main/runtime/terminal-tail-per-character-redraw.test-fixture.ts new file mode 100644 index 00000000000..1ab234cd347 --- /dev/null +++ b/src/main/runtime/terminal-tail-per-character-redraw.test-fixture.ts @@ -0,0 +1,253 @@ +// Test-only: the redraw row model as it was before #11315 (one string rebuild per character), +// vendored so wall-time and equivalence tests can measure the current model against it. +import { + hasCanonicalNumericCsiParams, + parseAnsiControlSequence +} from './terminal-ansi-normalization' +import { ownRetainedString } from '../../shared/own-retained-string' +import { clampTerminalPreviewCursor, trimTerminalLineRight } from './terminal-tail-line-controls' +import { MAX_TAIL_CHARS, MAX_TAIL_LINES, MAX_TAIL_PARTIAL_CHARS } from './terminal-tail-limits' +import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' + +export function appendPerCharacterRedrawReference( + previousLines: string[], + boundedPreviousPartialLine: string, + normalizedChunk: string, + previousPartialWasCapped: boolean, + previousRedrawCursor: RetainedTailRedrawCursor | null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number + newlyCompletedLines: string[] +} { + const rows: RetainedTerminalRow[] = [ + ...previousLines.map((line) => ({ text: line, completed: true })), + { text: boundedPreviousPartialLine, completed: false } + ] + let cursorRow = previousRedrawCursor + ? Math.max(0, rows.length - 1 - previousRedrawCursor.rowFromEnd) + : rows.length - 1 + let cursorColumn = previousRedrawCursor?.column ?? boundedPreviousPartialLine.length + let newCompleteLines = 0 + const newlyCompletedLines: string[] = [] + let newlyCompletedLineCharacters = 0 + let newlyCompletedLineStart = 0 + let truncated = previousPartialWasCapped + + const retainNewlyCompletedLine = (line: string): void => { + newlyCompletedLines.push(line) + newlyCompletedLineCharacters += line.length + while ( + newlyCompletedLines.length - newlyCompletedLineStart > MAX_TAIL_LINES || + newlyCompletedLineCharacters > MAX_TAIL_CHARS + ) { + newlyCompletedLineCharacters -= newlyCompletedLines[newlyCompletedLineStart]!.length + newlyCompletedLineStart += 1 + } + // Why: a single PTY chunk can carry unbounded newlines; compact in batches while retaining the suffix needed for stable pagination. + if (newlyCompletedLineStart >= MAX_TAIL_LINES) { + newlyCompletedLines.splice(0, newlyCompletedLineStart) + newlyCompletedLineStart = 0 + } + } + + const ensureCursorRow = (): void => { + while (cursorRow >= rows.length) { + rows.push({ text: '', completed: false }) + } + } + const trimRows = (): void => { + const maxRows = MAX_TAIL_LINES + 1 + if (rows.length <= maxRows) { + return + } + const removeCount = rows.length - maxRows + rows.splice(0, removeCount) + cursorRow = Math.max(0, cursorRow - removeCount) + truncated = true + } + const moveCursorToColumn = (nextColumn: number): void => { + cursorColumn = clampTerminalPreviewCursor(nextColumn) + } + const markCursorRowRewritten = (): void => { + ensureCursorRow() + rows[cursorRow]!.completed = false + } + const writeChar = (char: string): void => { + ensureCursorRow() + markCursorRowRewritten() + const row = rows[cursorRow]! + if (cursorColumn > row.text.length) { + row.text = `${row.text}${' '.repeat(cursorColumn - row.text.length)}` + } + row.text = + cursorColumn >= row.text.length + ? `${row.text}${char}` + : `${row.text.slice(0, cursorColumn)}${char}${row.text.slice(cursorColumn + 1)}` + cursorColumn += 1 + } + const eraseLine = (mode: number): void => { + ensureCursorRow() + markCursorRowRewritten() + const row = rows[cursorRow]! + if (mode === 0) { + row.text = row.text.slice(0, cursorColumn) + } else if (mode === 1) { + const deleteCount = Math.min(cursorColumn + 1, row.text.length) + row.text = `${' '.repeat(deleteCount)}${row.text.slice(deleteCount)}` + } else if (mode === 2) { + row.text = '' + } + } + + for (let index = 0; index < normalizedChunk.length; index += 1) { + const char = normalizedChunk[index] + if (char === '\n') { + ensureCursorRow() + rows[cursorRow]!.completed = true + newCompleteLines += 1 + retainNewlyCompletedLine(trimTerminalLineRight(rows[cursorRow]!.text)) + cursorRow += 1 + cursorColumn = 0 + ensureCursorRow() + trimRows() + continue + } + if (char === '\r') { + cursorColumn = 0 + continue + } + if (char === '\u0008') { + cursorColumn = Math.max(0, cursorColumn - 1) + continue + } + if (char === '\u001b') { + const parsed = parseAnsiControlSequence(normalizedChunk, index) + if (!parsed) { + continue + } + index = parsed.endIndex + if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { + continue + } + const firstParam = parsed.firstParam ?? 1 + if (parsed.final === 'A') { + cursorRow = Math.max(0, cursorRow - firstParam) + rows.splice(cursorRow + 1) + } else if (parsed.final === 'K') { + eraseLine(parsed.firstParam ?? 0) + } else if (parsed.final === 'G' || parsed.final === '`') { + moveCursorToColumn(firstParam - 1) + } else if (parsed.final === 'D') { + cursorColumn = Math.max(0, cursorColumn - firstParam) + } else if (parsed.final === 'C') { + moveCursorToColumn(cursorColumn + firstParam) + } + continue + } + writeChar(char) + } + + return finalizeRetainedTerminalRows( + rows, + cursorRow, + cursorColumn, + truncated, + newCompleteLines, + newlyCompletedLineStart > 0 + ? newlyCompletedLines.slice(newlyCompletedLineStart) + : newlyCompletedLines + ) +} + +type RetainedTerminalRow = { + text: string + completed: boolean +} + +function finalizeRetainedTerminalRows( + rows: RetainedTerminalRow[], + cursorRow: number, + cursorColumn: number, + initialTruncated: boolean, + newCompleteLines: number, + newlyCompletedLines: string[] +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number + newlyCompletedLines: string[] +} { + let truncated = initialTruncated + let retainedRows = rows.map((row) => ({ ...row, text: trimTerminalLineRight(row.text) })) + + if (retainedRows.length > MAX_TAIL_LINES + 1) { + const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) + retainedRows = retainedRows.slice(removeCount) + cursorRow = Math.max(0, cursorRow - removeCount) + truncated = true + } + + let totalChars = retainedRows.reduce((sum, row) => sum + row.text.length, 0) + let trimStartIndex = 0 + while (trimStartIndex < retainedRows.length - 1 && totalChars > MAX_TAIL_CHARS) { + totalChars -= retainedRows[trimStartIndex]!.text.length + trimStartIndex += 1 + } + if (trimStartIndex > 0) { + retainedRows = retainedRows.slice(trimStartIndex) + cursorRow = Math.max(0, cursorRow - trimStartIndex) + truncated = true + } + while ( + retainedRows.length > 1 && + cursorRow < retainedRows.length - 1 && + retainedRows.at(-1)?.completed === false && + retainedRows.at(-1)?.text.length === 0 + ) { + retainedRows.pop() + } + + const lastRow = retainedRows.at(-1) + let partialLine = lastRow && !lastRow.completed ? lastRow.text : '' + let lines = (lastRow && !lastRow.completed ? retainedRows.slice(0, -1) : retainedRows).map( + (row) => row.text + ) + + if (partialLine.length > MAX_TAIL_PARTIAL_CHARS) { + partialLine = partialLine.slice(-MAX_TAIL_PARTIAL_CHARS) + truncated = true + } + if (lines.length > MAX_TAIL_LINES) { + lines = lines.slice(lines.length - MAX_TAIL_LINES) + truncated = true + } + const outputRowCount = lines.length + 1 + const defaultCursorRow = outputRowCount - 1 + const defaultCursorColumn = partialLine.length + const redrawCursor = + cursorRow === defaultCursorRow && cursorColumn === defaultCursorColumn + ? null + : { + rowFromEnd: Math.max(0, outputRowCount - 1 - cursorRow), + column: clampTerminalPreviewCursor(cursorColumn) + } + + return { + lines, + // Why only the partial: redraw rows are built character by character and never sliced from + // the chunk, but the partial is re-sliced from its own row on every chunk, so it alone can + // accumulate a backing string across frames. Owning the rows too costs 20-36% on TUI floods + // for no measured retention. + partialLine: ownRetainedString(partialLine), + redrawCursor, + truncated, + newCompleteLines, + newlyCompletedLines + } +} diff --git a/src/main/runtime/terminal-tail-redraw-buffer.ts b/src/main/runtime/terminal-tail-redraw-buffer.ts index 2dd6d72badd..1b7e0968ed9 100644 --- a/src/main/runtime/terminal-tail-redraw-buffer.ts +++ b/src/main/runtime/terminal-tail-redraw-buffer.ts @@ -3,8 +3,15 @@ import { parseAnsiControlSequence } from './terminal-ansi-normalization' import { ownRetainedString } from '../../shared/own-retained-string' -import { clampTerminalPreviewCursor, trimTerminalLineRight } from './terminal-tail-line-controls' +import { clampTerminalPreviewCursor } from './terminal-tail-line-controls' import { MAX_TAIL_CHARS, MAX_TAIL_LINES, MAX_TAIL_PARTIAL_CHARS } from './terminal-tail-limits' +import { + eraseRetainedRow, + retainedRow, + retainedRowSnapshot, + writeRetainedRow, + type RetainedTerminalRow +} from './terminal-tail-redraw-row' export function appendNormalizedToMultilineTailBufferUnwindowed( previousLines: string[], @@ -21,8 +28,8 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( newlyCompletedLines: string[] } { const rows: RetainedTerminalRow[] = [ - ...previousLines.map((line) => ({ text: line, completed: true })), - { text: boundedPreviousPartialLine, completed: false } + ...previousLines.map((line) => retainedRow(line, true)), + retainedRow(boundedPreviousPartialLine, false) ] let cursorRow = previousRedrawCursor ? Math.max(0, rows.length - 1 - previousRedrawCursor.rowFromEnd) @@ -53,75 +60,54 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( const ensureCursorRow = (): void => { while (cursorRow >= rows.length) { - rows.push({ text: '', completed: false }) + rows.push(retainedRow('', false)) } } + // Rows before this index are already dropped by the line cap but not yet spliced out. + let trimmedRows = 0 const trimRows = (): void => { - const maxRows = MAX_TAIL_LINES + 1 - if (rows.length <= maxRows) { + const excess = rows.length - trimmedRows - (MAX_TAIL_LINES + 1) + if (excess <= 0) { return } - const removeCount = rows.length - maxRows - rows.splice(0, removeCount) - cursorRow = Math.max(0, cursorRow - removeCount) + trimmedRows += excess truncated = true + // Why batched: splicing one row off a full tail per newline made a long chunk, such as a TUI + // replaying its whole history, cost O(lines x MAX_TAIL_LINES). + if (trimmedRows >= MAX_TAIL_LINES) { + spliceTrimmedRows() + } + } + const spliceTrimmedRows = (): void => { + rows.splice(0, trimmedRows) + cursorRow -= trimmedRows + trimmedRows = 0 } const moveCursorToColumn = (nextColumn: number): void => { cursorColumn = clampTerminalPreviewCursor(nextColumn) } - const markCursorRowRewritten = (): void => { + const cursorRowForEdit = (): RetainedTerminalRow => { ensureCursorRow() - rows[cursorRow]!.completed = false - } - const writeText = (text: string): void => { - ensureCursorRow() - markCursorRowRewritten() const row = rows[cursorRow]! - if (cursorColumn > row.text.length) { - row.text = `${row.text}${' '.repeat(cursorColumn - row.text.length)}` - } - row.text = - cursorColumn >= row.text.length - ? `${row.text}${text}` - : `${row.text.slice(0, cursorColumn)}${text}${row.text.slice(cursorColumn + text.length)}` - cursorColumn += text.length + row.completed = false + return row + } + const writeText = (start: number, end: number): void => { + writeRetainedRow(cursorRowForEdit(), cursorColumn, normalizedChunk, start, end) + cursorColumn += end - start } const eraseLine = (mode: number): void => { - ensureCursorRow() - markCursorRowRewritten() - const row = rows[cursorRow]! - if (mode === 0) { - row.text = row.text.slice(0, cursorColumn) - } else if (mode === 1) { - const deleteCount = Math.min(cursorColumn + 1, row.text.length) - row.text = `${' '.repeat(deleteCount)}${row.text.slice(deleteCount)}` - } else if (mode === 2) { - row.text = '' - } + eraseRetainedRow(cursorRowForEdit(), mode, cursorColumn) } - let textStart = 0 - const writePendingText = (end: number): void => { - if (end > textStart) { - writeText( - end === textStart + 1 ? normalizedChunk[textStart]! : normalizedChunk.slice(textStart, end) - ) - } - textStart = end + 1 - } for (let index = 0; index < normalizedChunk.length; index += 1) { const char = normalizedChunk[index] if (char === '\n') { - writePendingText(index) ensureCursorRow() - rows[cursorRow]!.completed = true - newCompleteLines += 1 - // Own only retained text; a short erase-to-end result can otherwise pin a large span. const row = rows[cursorRow]! - const trimmed = trimTerminalLineRight(row.text) - const completedLine = ownRetainedString(trimmed) - row.text = trimmed === row.text ? completedLine : ownRetainedString(row.text) - retainNewlyCompletedLine(completedLine) + row.completed = true + newCompleteLines += 1 + retainNewlyCompletedLine(retainedRowSnapshot(row)) cursorRow += 1 cursorColumn = 0 ensureCursorRow() @@ -129,29 +115,25 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( continue } if (char === '\r') { - writePendingText(index) cursorColumn = 0 continue } if (char === '\u0008') { - writePendingText(index) cursorColumn = Math.max(0, cursorColumn - 1) continue } if (char === '\u001b') { - writePendingText(index) const parsed = parseAnsiControlSequence(normalizedChunk, index) if (!parsed) { continue } index = parsed.endIndex - textStart = index + 1 if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { continue } const firstParam = parsed.firstParam ?? 1 if (parsed.final === 'A') { - cursorRow = Math.max(0, cursorRow - firstParam) + cursorRow = Math.max(trimmedRows, cursorRow - firstParam) rows.splice(cursorRow + 1) } else if (parsed.final === 'K') { eraseLine(parsed.firstParam ?? 0) @@ -164,9 +146,18 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( } continue } + let textEnd = index + 1 + while ( + textEnd < normalizedChunk.length && + !isRedrawControlCode(normalizedChunk.charCodeAt(textEnd)) + ) { + textEnd += 1 + } + writeText(index, textEnd) + index = textEnd - 1 } - writePendingText(normalizedChunk.length) + spliceTrimmedRows() return finalizeRetainedTerminalRows( rows, cursorRow, @@ -179,16 +170,15 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( ) } +function isRedrawControlCode(code: number): boolean { + return code === 0x0a || code === 0x0d || code === 0x08 || code === 0x1b +} + export type RetainedTailRedrawCursor = { rowFromEnd: number column: number } -type RetainedTerminalRow = { - text: string - completed: boolean -} - function finalizeRetainedTerminalRows( rows: RetainedTerminalRow[], cursorRow: number, @@ -205,10 +195,10 @@ function finalizeRetainedTerminalRows( newlyCompletedLines: string[] } { let truncated = initialTruncated - let retainedRows = rows.map((row) => { - const trimmed = trimTerminalLineRight(row.text) - return { ...row, text: trimmed === row.text ? row.text : ownRetainedString(trimmed) } - }) + let retainedRows = rows.map((row) => ({ + text: retainedRowSnapshot(row), + completed: row.completed + })) if (retainedRows.length > MAX_TAIL_LINES + 1) { const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) @@ -264,7 +254,10 @@ function finalizeRetainedTerminalRows( return { lines, - // Completed rows own their storage at newline; the final partial may still reference a span. + // Why only the partial: redraw runs are owned as they are written, so rows never slice the + // chunk, but the partial is re-sliced from its own row on every chunk, so it alone can + // accumulate a backing string across frames. Owning the rows too costs 20-36% on TUI floods + // for no measured retention. partialLine: ownRetainedString(partialLine), redrawCursor, truncated, diff --git a/src/main/runtime/terminal-tail-redraw-reach.ts b/src/main/runtime/terminal-tail-redraw-reach.ts new file mode 100644 index 00000000000..bd5641d6367 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-reach.ts @@ -0,0 +1,72 @@ +import { + hasCanonicalNumericCsiParams, + parseAnsiControlSequence +} from './terminal-ansi-normalization' +import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' + +// Why module-level: this ran `new RegExp` per redraw chunk — i.e. per TUI frame per PTY. +// Safe to share because `summedUpwardCursorReach` is synchronous and non-reentrant; it resets +// `lastIndex` before every scan. +const CURSOR_UP_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[(\\d*)(?:;[\\d;]*)?A`, 'g') + +function summedUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null +): number { + let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 + CURSOR_UP_PATTERN.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = CURSOR_UP_PATTERN.exec(normalizedChunk)) !== null) { + reach += match[1] ? Number.parseInt(match[1], 10) : 1 + } + return reach +} + +// Why net: a TUI repaints each frame with a cursor-up to the same rows, so summing every CUU in +// a multi-frame chunk overstated the reach and forced the O(tail) unwindowed path. Mirrors the +// redraw model's own tokenizing, so only rows its cursor can actually visit are counted. +function netUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null +): number { + let rowFromEnd = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 + let reach = rowFromEnd + for (let index = 0; index < normalizedChunk.length; index += 1) { + const code = normalizedChunk.charCodeAt(index) + if (code === 0x0a) { + rowFromEnd -= 1 + continue + } + if (code !== 0x1b) { + continue + } + const parsed = parseAnsiControlSequence(normalizedChunk, index) + if (!parsed) { + continue + } + index = parsed.endIndex + if ( + parsed.kind === 'csi' && + parsed.final === 'A' && + hasCanonicalNumericCsiParams(parsed.params) + ) { + rowFromEnd += parsed.firstParam ?? 1 + if (rowFromEnd > reach) { + reach = rowFromEnd + } + } + } + return reach +} + +export function maxUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null, + windowLimit: number +): number { + const summed = summedUpwardCursorReach(normalizedChunk, previousRedrawCursor) + // The cheap sum is already an upper bound; only pay for the exact scan when it could still window. + return summed < windowLimit || windowLimit <= 0 + ? summed + : netUpwardCursorReach(normalizedChunk, previousRedrawCursor) +} diff --git a/src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts b/src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts new file mode 100644 index 00000000000..c4b84573dd9 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { mulberry32 } from '../../shared/agent-tui-ansi-fuzz-stream' +import { + eraseRetainedRow, + retainedRow, + retainedRowSnapshot, + writeRetainedRow +} from './terminal-tail-redraw-row' +import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer' +import { appendPerCharacterRedrawReference } from './terminal-tail-per-character-redraw.test-fixture' + +// Differential guard: the cached string/cell row must match the original one-character-at-a-time +// string row on every write, erase, and newline snapshot, including identical rewrites and +// trailing spaces/tabs. + +/** The pre-cache row semantics: per-character string writes. */ +class ReferenceRow { + constructor(public text: string) {} + + write(column: number, run: string): void { + for (const char of run.split('')) { + if (column > this.text.length) { + this.text += ' '.repeat(column - this.text.length) + } + this.text = + column >= this.text.length + ? `${this.text}${char}` + : `${this.text.slice(0, column)}${char}${this.text.slice(column + 1)}` + column += 1 + } + } + + erase(mode: number, column: number): void { + if (mode === 0) { + this.text = this.text.slice(0, column) + } else if (mode === 1) { + const count = Math.min(column + 1, this.text.length) + this.text = `${' '.repeat(count)}${this.text.slice(count)}` + } else if (mode === 2) { + this.text = '' + } + } + + snapshot(): string { + return this.text.replace(/[ \t]+$/, '') + } +} + +function randomRun(rng: () => number, reference: string, column: number): string { + const roll = rng() + const length = 1 + Math.floor(rng() * 12) + if (roll < 0.3 && column < reference.length) { + // Identical rewrite, sometimes running past the row end. + return reference.slice(column, column + length) || 'z' + } + const alphabet = roll < 0.6 ? ' \t' : 'ab \ty' + let run = '' + for (let index = 0; index < length; index += 1) { + run += alphabet[Math.floor(rng() * alphabet.length)] + } + return run +} + +function randomInitialRow(rng: () => number): string { + const kinds = ['', 'panel', 'x'.repeat(30), `panel${' '.repeat(40)}`, `a\t \t${'\t'.repeat(9)}`] + return kinds[Math.floor(rng() * kinds.length)]! +} + +describe('retained terminal row equivalence', () => { + it('matches per-character string rows across 3,000 randomized edit sequences', () => { + const rng = mulberry32(0x26316) + for (let sequence = 0; sequence < 3_000; sequence += 1) { + const initial = randomInitialRow(rng) + const row = retainedRow(initial, true) + const reference = new ReferenceRow(initial) + const ops: string[] = [] + for (let step = 0; step < 24; step += 1) { + const width = reference.text.length + const column = Math.floor(rng() * (width + 6)) + const roll = rng() + if (roll < 0.55) { + const run = randomRun(rng, reference.text, column) + ops.push(`w${column}:${JSON.stringify(run)}`) + reference.write(column, run) + writeRetainedRow(row, column, `<${run}>`, 1, run.length + 1) + } else if (roll < 0.75) { + const mode = Math.floor(rng() * 4) + ops.push(`e${mode}@${column}`) + reference.erase(mode, column) + eraseRetainedRow(row, mode, column) + } + // Newline snapshots interleave with edits, so cached snapshots must invalidate exactly. + if (rng() < 0.5) { + expect(retainedRowSnapshot(row), `${JSON.stringify(initial)} ${ops.join(' ')}`).toBe( + reference.snapshot() + ) + } + } + expect(retainedRowSnapshot(row), `${JSON.stringify(initial)} ${ops.join(' ')}`).toBe( + reference.snapshot() + ) + } + }) + + it('matches the per-character redraw model across 2,000 randomized chunk streams', () => { + const rng = mulberry32(0x263160) + const pick = (items: T[]): T => items[Math.floor(rng() * items.length)]! + const tokens: (() => string)[] = [ + () => 'abc y'.slice(0, 1 + Math.floor(rng() * 5)), + () => 'x'.repeat(Math.floor(rng() * 300)), + () => pick([' ', '\t']).repeat(Math.floor(rng() * 200)), + () => pick(['\n', '\r', '\b', '\r\n']), + () => `\x1b[${pick(['', '1', '2', '3'])}A`, + () => `\x1b[${pick(['', '0', '1', '2', '3'])}K`, + () => `\x1b[${Math.floor(rng() * 400)}${pick(['G', 'C', 'D'])}`, + () => `\x1b[1A\r${pick(['y', 'y'.repeat(33), 'panel'])}\n` + ] + type TailState = ReturnType + const advance = ( + model: typeof appendPerCharacterRedrawReference, + state: TailState, + chunk: string + ): TailState => model(state.lines, state.partialLine, chunk, false, state.redrawCursor) + for (let stream = 0; stream < 2_000; stream += 1) { + const empty: TailState = { + lines: [], + partialLine: '', + redrawCursor: null, + truncated: false, + newCompleteLines: 0, + newlyCompletedLines: [] + } + let current = empty + let reference = empty + for (let chunkIndex = 0; chunkIndex < 6; chunkIndex += 1) { + let chunk = '' + const count = Math.floor(rng() * 40) + for (let token = 0; token < count; token += 1) { + chunk += pick(tokens)() + } + current = advance(appendNormalizedToMultilineTailBufferUnwindowed, current, chunk) + reference = advance(appendPerCharacterRedrawReference, reference, chunk) + expect(current, `stream ${stream} chunk ${chunkIndex}`).toEqual(reference) + } + } + }) +}) diff --git a/src/main/runtime/terminal-tail-redraw-row.ts b/src/main/runtime/terminal-tail-redraw-row.ts new file mode 100644 index 00000000000..8bde12e3abd --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-row.ts @@ -0,0 +1,179 @@ +import { ownRetainedString } from '../../shared/own-retained-string' + +// Flat-string rows: one splice per written run, which V8 flattens with a memcpy. The caches let a +// row the cursor only revisits, or rewrites with identical text, skip its newline snapshot. +export type RetainedTerminalRow = { + text: string + /** Right-trimmed snapshot; null after a content change. */ + snapshot: string | null + /** Index after the last non-space/tab, or -1 when unknown. */ + contentEnd: number + /** Lower bound on the all-space prefix, so a repeated erase-to-start is a no-op. */ + blankPrefix: number + /** Upper bound on the backing string `text` may slice; 0 when `text` owns its storage. */ + backing: number + completed: boolean +} + +export function retainedRow(text: string, completed: boolean): RetainedTerminalRow { + // A carried row may be a snapshot that slices up to twice its length. + return { + text, + snapshot: null, + contentEnd: -1, + blankPrefix: 0, + backing: 2 * text.length, + completed + } +} + +function isTrimmedWhitespace(code: number): boolean { + return code === 0x20 || code === 0x09 +} + +/** Write source[start, end) at `column`; identical characters leave the row clean. */ +export function writeRetainedRow( + row: RetainedTerminalRow, + column: number, + source: string, + start: number, + end: number +): void { + const text = row.text + const runLength = end - start + const overlap = Math.max(0, Math.min(text.length - column, runLength)) + let offset = 0 + while ( + offset < overlap && + text.charCodeAt(column + offset) === source.charCodeAt(start + offset) + ) { + offset += 1 + } + if (offset === runLength) { + return + } + // Why own: the row can outlive this chunk as a tail line, and a long run is a slice of it. + if (column === 0 && runLength >= text.length) { + row.text = ownRetainedString(source.slice(start, end)) + row.backing = 0 + } else if (column >= text.length) { + const gap = column - text.length + const run = ownRetainedString(source.slice(start, end)) + row.text = gap > 0 ? `${text}${' '.repeat(gap)}${run}` : `${text}${run}` + } else { + // Skip the identical prefix so a write past the row end can append instead of splicing. + const at = column + offset + const run = ownRetainedString(source.slice(start + offset, end)) + if (offset === overlap) { + row.text = `${text}${run}` + } else { + row.text = `${text.slice(0, at)}${run}${text.slice(column + runLength)}` + row.backing = Math.max(row.backing, text.length) + } + } + noteWrite(row, column, column + runLength, writtenEnd(column, source, start, end)) +} + +function writtenEnd(column: number, source: string, start: number, end: number): number { + for (let index = end - 1; index >= start; index -= 1) { + if (!isTrimmedWhitespace(source.charCodeAt(index))) { + return column + index + 1 - start + } + } + return -1 +} + +/** Note a content-changing write of [column, runEnd) whose last non-whitespace ends at `written`. */ +function noteWrite( + row: RetainedTerminalRow, + column: number, + runEnd: number, + written: number +): void { + row.snapshot = null + row.blankPrefix = Math.min(row.blankPrefix, column) + const contentEnd = row.contentEnd + if (contentEnd > runEnd) { + return + } + if (contentEnd === -1) { + // Unknown content may survive past the run. + row.contentEnd = runEnd >= row.text.length ? written : -1 + } else if (written !== -1) { + row.contentEnd = written + } else if (contentEnd > column) { + row.contentEnd = -1 + } +} + +/** CSI K in modes 0/1/2 at `column`; other modes are no-ops. */ +export function eraseRetainedRow(row: RetainedTerminalRow, mode: number, column: number): void { + const text = row.text + if (mode === 2 || mode === 0) { + const keep = mode === 2 ? 0 : column + if (keep >= text.length) { + return + } + row.text = keep === 0 ? '' : text.slice(0, keep) + row.backing = keep === 0 ? 0 : Math.max(row.backing, text.length) + ownShrunkText(row) + row.snapshot = null + row.blankPrefix = Math.min(row.blankPrefix, keep) + if (row.contentEnd > keep) { + row.contentEnd = -1 + } + return + } + if (mode !== 1) { + return + } + const blankCount = Math.min(column + 1, text.length) + let firstNonBlank = row.blankPrefix + while (firstNonBlank < blankCount && text.charCodeAt(firstNonBlank) === 0x20) { + firstNonBlank += 1 + } + row.blankPrefix = Math.max(row.blankPrefix, blankCount) + if (firstNonBlank >= blankCount) { + return + } + row.text = `${' '.repeat(blankCount)}${text.slice(blankCount)}` + row.backing = Math.max(row.backing, text.length) + row.snapshot = null + if (row.contentEnd !== -1 && row.contentEnd <= blankCount) { + row.contentEnd = 0 + } +} + +/** The right-trimmed row text; rebuilt only after a content change. */ +export function retainedRowSnapshot(row: RetainedTerminalRow): string { + if (row.snapshot !== null) { + return row.snapshot + } + const text = row.text + let end = row.contentEnd + if (end === -1) { + end = text.length + while (end > 0 && isTrimmedWhitespace(text.charCodeAt(end - 1))) { + end -= 1 + } + row.contentEnd = end + } + if (end === text.length) { + ownShrunkText(row) + row.snapshot = row.text + } else { + // Why own a short trim: the slice would otherwise pin the whole padded row while retained. + const backing = Math.max(row.backing, text.length) + row.snapshot = end * 2 < backing ? ownRetainedString(text.slice(0, end)) : text.slice(0, end) + } + return row.snapshot +} + +// Why cumulative: a chain of erases that each keep half the row never crosses a per-erase +// threshold, yet nested slices still pin the original row. +function ownShrunkText(row: RetainedTerminalRow): void { + if (row.backing > 2 * row.text.length) { + row.text = ownRetainedString(row.text) + row.backing = 0 + } +} diff --git a/src/main/runtime/terminal-tail-redraw-scaling.test.ts b/src/main/runtime/terminal-tail-redraw-scaling.test.ts new file mode 100644 index 00000000000..bee99d65e49 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-scaling.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { MAX_TAIL_LINES } from './terminal-tail-limits' +import * as redrawBuffer from './terminal-tail-redraw-buffer' +import { buildRestoredTerminalTailSeed } from './terminal-tail-restore-seed' + +// #11315: an SSH TUI (Pi ask_user) repainting full-width rows pinned Electron main at 100% CPU +// because every character written behind the row end rebuilt the whole row string. + +const ESC = '\x1b' + +// One frame: climb to the top of a three-row panel and overwrite every row in place. +function overwriteFrame(width: number): string { + return [ + `${ESC}[3A\r${'a'.repeat(width)}\n`, + `\r${ESC}[5G${'b'.repeat(width)}\n`, + `\r${'c'.repeat(width)}\n` + ].join('') +} + +function redrawMillis(width: number): number { + const seed = `${'x'.repeat(width)}\n`.repeat(3) + let tail = appendNormalizedToTailBuffer([], '', seed, null) + const chunk = overwriteFrame(width).repeat(36) + let fastest = Number.POSITIVE_INFINITY + for (let round = 0; round < 8; round += 1) { + const started = performance.now() + tail = appendNormalizedToTailBuffer(tail.lines, tail.partialLine, chunk, tail.redrawCursor) + fastest = Math.min(fastest, performance.now() - started) + } + return fastest +} + +// Paint one wide row, then revisit it with cursor-up/newline; `edit` runs before each newline. +function wideRowRevisits(width: number, revisits: number, edit = ''): string { + return `${ESC}[1A\r${'x'.repeat(width)}\n${`${ESC}[1A${edit}\n`.repeat(revisits)}` +} + +// Each join of a row-sized cell array is an O(width) rebuild. +function countWideJoins(width: number, run: () => void): number { + const join = vi.spyOn(Array.prototype, 'join') + try { + run() + return join.mock.contexts.filter((cells) => Array.isArray(cells) && cells.length >= width) + .length + } finally { + join.mockRestore() + } +} + +describe('terminal tail redraw cost', () => { + it('keeps the overwritten rows exact', () => { + const width = 40 + const seed = appendNormalizedToTailBuffer([], '', `${'x'.repeat(width)}\n`.repeat(3), null) + const next = appendNormalizedToTailBuffer( + seed.lines, + seed.partialLine, + overwriteFrame(width).repeat(2), + seed.redrawCursor + ) + expect(next.lines.slice(-3)).toEqual([ + 'a'.repeat(width), + ` ${'b'.repeat(width)}`, + 'c'.repeat(width) + ]) + }) + + it('grows linearly with row width when a TUI overwrites full rows in place', () => { + // Warm both sizes so JIT tiering does not land on one side of the ratio. + redrawMillis(1000) + redrawMillis(8000) + const narrow = redrawMillis(1000) + const wide = redrawMillis(8000) + // 8x the width is ~8x the bytes; the per-character row rebuild made this ~64x. + expect(wide / narrow).toBeLessThan(24) + }) + + it('windows a multi-frame chunk by the net cursor reach, not the summed cursor-ups', () => { + const history = Array.from({ length: MAX_TAIL_LINES }, (_, index) => `history ${index}`) + const panelRows = 40 + const frame = `${ESC}[${panelRows}A${Array.from( + { length: panelRows }, + (_, row) => `\r${ESC}[2Kchoice ${row}\n` + ).join('')}` + const unwindowed = vi.spyOn(redrawBuffer, 'appendNormalizedToMultilineTailBufferUnwindowed') + try { + // 60 frames climb 2400 rows in total but never more than one panel above the end. + appendNormalizedToTailBuffer(history, '', frame.repeat(60), null) + expect(unwindowed).toHaveBeenCalledTimes(1) + expect(unwindowed.mock.calls[0]![0].length).toBeLessThan(panelRows * 2) + } finally { + unwindowed.mockRestore() + } + }) + + it('keeps only the newest MAX_TAIL_LINES rows when one chunk replays a long history', () => { + const history = Array.from({ length: MAX_TAIL_LINES }, (_, index) => `history ${index}`) + // A full-screen TUI redraw replays every line it has ever printed in one write. + const replay = Array.from({ length: 20_000 }, (_, index) => `replayed ${index}\n`).join('') + const next = appendNormalizedToTailBuffer(history, '', replay, { rowFromEnd: 3, column: 0 }) + expect(next.lines).toHaveLength(MAX_TAIL_LINES) + expect(next.lines.at(-1)).toBe('replayed 19999') + expect(next.truncated).toBe(true) + }) + + it('does not rebuild an unchanged wide row on every cursor-up/newline revisit', () => { + const width = 32_000 + let tail: ReturnType | undefined + const joins = countWideJoins(width, () => { + tail = appendNormalizedToTailBuffer([], '', wideRowRevisits(width, 4_000), null) + }) + expect(joins).toBe(0) + expect(tail?.lines).toEqual(['x'.repeat(width)]) + }) + + it('never joins a wide row when every revisit repeats the same one-cell edit', () => { + const width = 64_000 + let tail: ReturnType | undefined + const joins = countWideJoins(width, () => { + tail = appendNormalizedToTailBuffer([], '', wideRowRevisits(width, 2_000, '\ry'), null) + }) + expect(joins).toBe(0) + expect(tail?.lines).toEqual([`y${'x'.repeat(width - 1)}`]) + }) + + it('seeds a restored tail without rebuilding a revisited wide row', () => { + const width = 32_000 + let seedLines: string[] | undefined + const joins = countWideJoins(width, () => { + seedLines = buildRestoredTerminalTailSeed(wideRowRevisits(width, 4_000))?.lines + }) + expect(joins).toBe(0) + expect(seedLines).toEqual(['x'.repeat(width)]) + }) +}) diff --git a/src/main/runtime/terminal-tail-redraw-wall-time.test.ts b/src/main/runtime/terminal-tail-redraw-wall-time.test.ts new file mode 100644 index 00000000000..b570532be92 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-wall-time.test.ts @@ -0,0 +1,206 @@ +import { writeFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { readPtyTail, runtimeWithLeaf } from './orca-runtime-pty-leaf.test-fixture' +import { normalizeTerminalChunk } from './terminal-ansi-normalization' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { appendPerCharacterRedrawReference } from './terminal-tail-per-character-redraw.test-fixture' +import * as redrawBuffer from './terminal-tail-redraw-buffer' +import { buildRestoredTerminalTailSeed } from './terminal-tail-restore-seed' + +// #11315 review probes, timed against the vendored pre-#11315 row model on all three main-thread +// entry points. ORCA_TAIL_WALL_TIME_FULL=1 runs them at restore-cap scale; ORCA_TAIL_WALL_TIME_TABLE +// names a file that receives the measured table. + +const ESC = '\x1b' +const FULL = process.env.ORCA_TAIL_WALL_TIME_FULL === '1' +const WIDTH = FULL ? 128_000 : 8_000 +const BUDGET = FULL ? 256 * 1024 - 16 : 32 * 1024 +const FLOOD_CHUNKS = FULL ? 4 : 1 +// The review probes used 1,500-4,000 revisits; more only lengthens the old model's run. +const MAX_REVISITS = 4_000 +// Generous so a loaded CI host does not flake; the measured bar is 1.5x. +const MAX_RATIO = 3 +const SLACK_MS = 30 +const ROUNDS = Number(process.env.ORCA_TAIL_WALL_TIME_ROUNDS ?? 3) + +type Probe = { name: string; chunks: string[] } + +const letter = (index: number): string => String.fromCharCode(0x61 + (index % 26)) + +/** One wide row, then as many revisits as fit the byte budget. */ +function revisits(name: string, row: string, unit: (index: number) => string): Probe { + const count = Math.min( + MAX_REVISITS, + Math.max(1, Math.floor((BUDGET - row.length) / unit(0).length)) + ) + const parts = Array.from({ length: count }, (_, index) => unit(index)) + return { name, chunks: [`${ESC}[1A\r${row}\n${parts.join('')}`] } +} + +/** 64 KiB live chunks against a persistent tail holding one wide row. */ +function flood(name: string, width: number, unit: (index: number) => string): Probe { + const perChunk = Math.floor((64 * 1024) / unit(0).length) + const chunks = [`${'x'.repeat(width)}\n`] + for (let chunk = 0; chunk < FLOOD_CHUNKS; chunk += 1) { + chunks.push(Array.from({ length: perChunk }, (_, index) => unit(index + chunk)).join('')) + } + return { name, chunks } +} + +function probes(): Probe[] { + const x = 'x'.repeat(WIDTH) + const half = Math.floor(WIDTH / 2) + const rows = [`${'x'.repeat(half)}\n${'y'.repeat(half)}\n`] + const twoRowCount = Math.floor((BUDGET - WIDTH) / 10) + rows.push( + Array.from({ length: twoRowCount }, (_, i) => `${ESC}[2A${letter(i)}\n${letter(i)}\n`).join('') + ) + const panel = (label: string, width: number): string => + `\r${ESC}[2K${label} ${'─'.repeat(width - label.length - 1)}\n` + const frame = `${ESC}[4A${['title', 'option a', 'option b', 'hint'].map((l) => panel(l, 200)).join('')}` + return [ + revisits('cursor-only, trailing spaces', `panel${' '.repeat(WIDTH - 5)}`, () => `${ESC}[1A\n`), + revisits('cursor-only, trailing tabs', `panel${'\t'.repeat(WIDTH - 5)}`, () => `${ESC}[1A\n`), + revisits('33 identical edits per revisit', x, () => `${ESC}[1A${'\ry'.repeat(33)}\n`), + revisits('alternating one-character edit', x, (i) => `${ESC}[1A\r${i % 2 ? 'a' : 'b'}\n`), + revisits( + 'changing edit, trailing spaces', + `p${' '.repeat(WIDTH - 1)}`, + (i) => `${ESC}[A${letter(i)}\n` + ), + revisits( + 'changing edit, trailing tabs', + `p${'\t'.repeat(WIDTH - 1)}`, + (i) => `${ESC}[A${letter(i)}\n` + ), + revisits('ESC[4000G write', x, (i) => `${ESC}[A${ESC}[4000G${letter(i)}\n`), + revisits('ESC[1K erase', x, (i) => `${ESC}[A\r${letter(i)}${ESC}[4000G${ESC}[1K\n`), + revisits('ESC[K then write', x, (i) => `${ESC}[A${ESC}[2000G${ESC}[K${letter(i)}\n`), + { + name: 'ESC[1K erase, no newline', + chunks: [ + `${x}${Array.from({ length: Math.floor((BUDGET - WIDTH) / 12) }, (_, i) => `\r${letter(i)}${ESC}[4000G${ESC}[1K`).join('')}` + ] + }, + { + name: 'one row, no newline', + chunks: [ + `${x}${Array.from({ length: Math.floor((BUDGET - WIDTH) / 2) }, (_, i) => `\r${letter(i)}`).join('')}` + ] + }, + { name: 'two alternating rows', chunks: [rows.join('')] }, + { name: 'write/backspace runs', chunks: [`${'x'.repeat(4_000)}\r${'ab\b'.repeat(2_000)}`] }, + { + name: '#11315 panel repaint flood', + chunks: [`${'\n'.repeat(4)}${frame.repeat(Math.floor(BUDGET / frame.length))}`] + }, + ...[4_000, 16_000, 128_000].flatMap((width) => [ + flood(`live 64 KiB flood, ${width} cols`, width, (i) => `${ESC}[A${letter(i)}\n`), + flood( + `live 64 KiB ESC[1K flood, ${width} cols`, + width, + (i) => `${ESC}[A\r${letter(i)}${ESC}[4000G${ESC}[1K\n` + ) + ]) + ] +} + +type Entry = { name: string; run: (chunks: string[]) => { ms: number; result: unknown } } + +function timed(run: () => unknown): { ms: number; result: unknown } { + const started = performance.now() + const result = run() + return { ms: performance.now() - started, result } +} + +const entries: Entry[] = [ + { name: 'restore', run: (chunks) => timed(() => buildRestoredTerminalTailSeed(chunks.join(''))) }, + { + name: 'append', + run: (chunks) => + timed(() => { + let tail: Pick< + ReturnType, + 'lines' | 'partialLine' | 'redrawCursor' + > = { lines: [], partialLine: '', redrawCursor: null } + let pending = '' + for (const chunk of chunks) { + const normalized = normalizeTerminalChunk(chunk, pending) + pending = normalized.pendingAnsi + tail = appendNormalizedToTailBuffer( + tail.lines, + tail.partialLine, + normalized.text, + tail.redrawCursor + ) + } + return tail + }) + }, + { + name: 'onPtyData', + run: (chunks) => { + const ptyId = 'pty-wall-time' + const { runtime } = runtimeWithLeaf(ptyId) + const measured = timed(() => { + chunks.forEach((chunk, index) => runtime.onPtyData(ptyId, chunk, index + 1)) + return readPtyTail(runtime, ptyId) + }) + void runtime.onPtyExit(ptyId, 0) + return measured + } + } +] + +function withPerCharacterRows(run: () => T): T { + const reference = vi + .spyOn(redrawBuffer, 'appendNormalizedToMultilineTailBufferUnwindowed') + .mockImplementation(appendPerCharacterRedrawReference) + try { + return run() + } finally { + reference.mockRestore() + } +} + +describe('terminal tail redraw wall time against the per-character row model', () => { + it( + 'is no slower than the old row model on any review probe', + () => { + const table: string[] = [] + const tablePath = process.env.ORCA_TAIL_WALL_TIME_TABLE + const only = process.env.ORCA_TAIL_WALL_TIME_PROBE + for (const probe of probes().filter((p) => !only || p.name.includes(only))) { + for (const entry of entries) { + let current = Number.POSITIVE_INFINITY + let reference = Number.POSITIVE_INFINITY + // Alternate the order so JIT warm-up and GC debt do not favor one model. + for (let round = 0; round < ROUNDS; round += 1) { + const runReference = (): ReturnType => + withPerCharacterRows(() => entry.run(probe.chunks)) + const before = round % 2 === 1 ? runReference() : null + const now = entry.run(probe.chunks) + const after = before ?? runReference() + expect(now.result, `${probe.name} via ${entry.name}`).toEqual(after.result) + current = Math.min(current, now.ms) + reference = Math.min(reference, after.ms) + } + table.push( + `| ${probe.name} | ${entry.name} | ${reference.toFixed(1)} | ${current.toFixed(1)} |` + ) + if (tablePath) { + writeFileSync( + tablePath, + ['| Probe | Entry | Old ms | Now ms |', '|---|---|---:|---:|', ...table].join('\n') + ) + } + expect( + current, + `${probe.name} via ${entry.name}: ${current} vs ${reference} ms` + ).toBeLessThan(reference * MAX_RATIO + SLACK_MS) + } + } + }, + FULL ? 1_800_000 : 180_000 + ) +}) diff --git a/src/main/runtime/terminal-tail-row-retention.test.ts b/src/main/runtime/terminal-tail-row-retention.test.ts index e49fd7fdd0c..5ec5ede2c09 100644 --- a/src/main/runtime/terminal-tail-row-retention.test.ts +++ b/src/main/runtime/terminal-tail-row-retention.test.ts @@ -61,6 +61,45 @@ describe('retained terminal tail row storage', () => { expect(lines.at(-1)).toBe(`step ${chunkCount - 1} done`) }) + it('does not pin a padded redraw row behind its trimmed text', () => { + let tail = appendNormalizedToTailBuffer([], '', 'warm\n') + const before = collectHeap() + for (let index = 0; index < 250; index += 1) { + const text = `\x1b[0Arow-${String(index).padStart(5, '0')}-completed${' '.repeat(64_000)}\n` + tail = appendNormalizedToTailBuffer(tail.lines, tail.partialLine, text, tail.redrawCursor) + } + const retained = collectHeap() - before + + expect(tail.lines.at(-1)).toBe('row-00249-completed') + // Sliced from the padded row, 250 short lines pinned about 16 MB. + expect(retained).toBeLessThan(4 * 1024 * 1024) + }) + + it.each([ + ['half', 0.5], + ['quarter', 0.75] + ])('does not pin a 64 KiB row that chained erases shrink by a %s at a time', (_, ratio) => { + const shrinkChunk = (index: number): string => { + const char = String.fromCharCode(65 + (index % 26)) + let chunk = `\x1b[0A${char.repeat(65_536)}` + // Each \r + identical prefix + CSI K keeps at least half of the current row. + for (let keep = Math.floor(65_536 * ratio); keep >= 16; keep = Math.floor(keep * ratio)) { + chunk += `\r${char.repeat(keep)}\x1b[K` + } + return `${chunk}\n` + } + let lines = appendNormalizedToTailBuffer([], '', shrinkChunk(0)).lines + const before = collectHeap() + for (let index = 1; index <= 200; index += 1) { + lines = appendNormalizedToTailBuffer(lines, '', shrinkChunk(index)).lines + } + const retained = collectHeap() - before + + expect(lines.at(-1)).toMatch(/^[A-Z]{1,20}$/) + // Nested slices pinned each completed row's 64 KiB original: about 13 MB. + expect(retained).toBeLessThan(4 * 1024 * 1024) + }) + it('routes every retained row and partial line through ownRetainedString', () => { const own = vi.spyOn(ownership, 'ownRetainedString') try { @@ -108,9 +147,8 @@ describe('retained terminal tail row storage', () => { expect(redrawn.lines).toEqual(['row one']) expect(redrawn.partialLine).toBe('rewritten two') - // Only the final partial is retained; carried rows need no copies. - expect(own).toHaveBeenCalledTimes(1) - expect(own).toHaveBeenLastCalledWith('rewritten two') + // Only the written run and the partial are owned; carried rows are never re-copied. + expect(own.mock.calls).toEqual([['rewritten two'], ['rewritten two']]) } finally { own.mockRestore() } diff --git a/src/main/serve-update-handoff.ts b/src/main/serve-update-handoff.ts index ab831a90d49..221c7c027a6 100644 --- a/src/main/serve-update-handoff.ts +++ b/src/main/serve-update-handoff.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { app } from 'electron' +import { quitProcess } from './startup/process-quit-request' import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath, @@ -79,7 +80,7 @@ export function installServeSupervisorDisconnectQuit( if (!isServeMode || !hasServeUpdateSupervisor()) { return () => undefined } - const quit = (): void => app.quit() + const quit = (): void => quitProcess() parent.once('disconnect', quit) return () => parent.off('disconnect', quit) } diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index f84baa16a73..18c79e26ed7 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -523,7 +523,7 @@ describe('a retained source is never deleted', () => { [ 'a folder workspace on a folder-only host', () => { - store.removeProject('repo-1') + store.removeProjectForHost('repo-1', `ssh:${TARGET.id}`) const group = store.createProjectGroup({ name: 'folders', parentPath: '/srv/folders', @@ -640,7 +640,7 @@ describe('a delta move against a rollback of an update taken before it', () => { it('protects a folder-only delta the same way', async () => { await convertedThenChangedOnOlderBuild() - store.removeProject('repo-2') + store.removeProjectForHost('repo-2', `ssh:${TARGET.id}`) const group = store.createProjectGroup({ name: 'folders', parentPath: '/srv/folders', diff --git a/src/main/ssh/orcad-remote-node-runtime-report.ts b/src/main/ssh/orcad-remote-node-runtime-report.ts index 5fb05424ee7..6e4e75eb7be 100644 --- a/src/main/ssh/orcad-remote-node-runtime-report.ts +++ b/src/main/ssh/orcad-remote-node-runtime-report.ts @@ -1,4 +1,5 @@ /** What the host-side runtime-store scripts print, shared by the POSIX and Windows installers. */ +import { RelayHostAnsweredError } from './ssh-relay-host-answered-failure' export const REMOTE_NODE_RUNTIME_READY = 'ORCA_NODE_RUNTIME_READY' export const REMOTE_NODE_RUNTIME_MISSING = 'ORCA_NODE_RUNTIME_MISSING' @@ -9,7 +10,7 @@ export const REMOTE_NODE_RUNTIME_EXIT_PREFIX = 'ORCA_RUNTIME_EXIT=' export const REMOTE_NODE_RUNTIME_VERIFIED_MARKER = '.verified' /** The pinned runtime ran on the host and did not report its version: a host verdict, with evidence. */ -export class RemoteNodeRuntimeSelfTestError extends Error { +export class RemoteNodeRuntimeSelfTestError extends RelayHostAnsweredError { constructor( readonly exitStatus: number | null, readonly output: string @@ -22,7 +23,7 @@ export class RemoteNodeRuntimeSelfTestError extends Error { } /** The host answered, and what it answered is that something rewrote or removed our verified bytes. */ -export class RemoteNodeRuntimeSecurityModifiedError extends Error { +export class RemoteNodeRuntimeSecurityModifiedError extends RelayHostAnsweredError { constructor(readonly detail: string) { super(`Security software on the host removed or modified the pinned Node runtime: ${detail}`) this.name = 'RemoteNodeRuntimeSecurityModifiedError' @@ -65,6 +66,8 @@ export function assertRemoteNodeRuntimePromoted(promoted: string): void { ) } if (promoted.trim().split(/\r?\n/).at(-1)?.trim() !== REMOTE_NODE_RUNTIME_READY) { - throw new Error(`The host did not verify the pinned Node runtime: ${promoted.trim()}`) + throw new RelayHostAnsweredError( + `The host did not verify the pinned Node runtime: ${promoted.trim()}` + ) } } diff --git a/src/main/ssh/orcad-remote-node-runtime-windows.ts b/src/main/ssh/orcad-remote-node-runtime-windows.ts index e0b568d8509..9f386dd0493 100644 --- a/src/main/ssh/orcad-remote-node-runtime-windows.ts +++ b/src/main/ssh/orcad-remote-node-runtime-windows.ts @@ -80,11 +80,25 @@ export function windowsNodeRuntimeProbeCommand( } /** Cheap warm-path check: a published runtime has its marker and node.exe; no re-hash. */ -export function windowsNodeRuntimePresentCommand(runtimeDir: string): string { +/** `runs`: also run node.exe, reporting a refusal as promotion does (see the POSIX twin). */ +export function windowsNodeRuntimePresentCommand(runtimeDir: string, runs = false): string { + const ready = runs + ? [ + "$runOut = ''; $runStatus = $null", + 'try { $runOut = ((& $exe --version 2>&1) | ForEach-Object { "$_" }) -join "`n"; $runStatus = $LASTEXITCODE } catch { $runStatus = -1; $runOut = $_.Exception.Message }', + `if (($runStatus -eq 0) -and ($runOut.Trim() -eq ${powerShellLiteral(`v${NODE_RUNTIME_PIN.version}`)})) { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)} } else {`, + `Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_SELFTEST_FAILED)}`, + `Write-Output (${powerShellLiteral(REMOTE_NODE_RUNTIME_EXIT_PREFIX)} + $runStatus)`, + 'Write-Output ($runOut.Substring(0, [Math]::Min(4000, $runOut.Length)))', + '}' + ].join('\n') + : `Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)}` return powerShellCommand( [ ...runtimeVariables(runtimeDir), - `if ((Test-Path -LiteralPath $verified -PathType Leaf) -and (Test-Path -LiteralPath $exe -PathType Leaf)) { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)} } else { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_MISSING)} }` + `if ((Test-Path -LiteralPath $verified -PathType Leaf) -and (Test-Path -LiteralPath $exe -PathType Leaf)) {`, + ready, + `} else { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_MISSING)} }` ].join('\n') ) } diff --git a/src/main/ssh/orcad-remote-node-runtime.ts b/src/main/ssh/orcad-remote-node-runtime.ts index 42b0680628f..8587fe04545 100644 --- a/src/main/ssh/orcad-remote-node-runtime.ts +++ b/src/main/ssh/orcad-remote-node-runtime.ts @@ -133,20 +133,32 @@ export function probeRemoteNodeRuntimeCommand( } /** Cheap warm-path check: a published runtime has its marker and an executable; no re-hash. */ +/** + * READY when the verified runtime is in place. With `run`, the same command also runs it and + * reports a refusal the way promotion does, so a host whose exec policy or libraries changed since + * the install is caught before a launch, at no extra round trip. + */ export function remoteNodeRuntimePresentCommand( host: RemoteHostPlatform, - runtimeDir: string + runtimeDir: string, + run = false ): string { if (isWindowsRemoteHost(host)) { - return windowsNodeRuntimePresentCommand(runtimeDir) + return windowsNodeRuntimePresentCommand(runtimeDir, run) } const executable = shellEscape( joinRemotePath(host, runtimeDir, ...ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE.split('/')) ) const verified = shellEscape(joinRemotePath(host, runtimeDir, VERIFIED_MARKER)) + const ready = run + ? `{ orca_rt_out=$(${executable} --version 2>&1); orca_rt_status=$?; ` + + `if [ "$orca_rt_out" = ${shellEscape(`v${NODE_RUNTIME_PIN.version}`)} ]; then echo ${REMOTE_NODE_RUNTIME_READY}; ` + + `else echo ${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}; echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_rt_status"; ` + + `printf '%s\\n' "$orca_rt_out" | head -c 4000; fi; }` + : `echo ${REMOTE_NODE_RUNTIME_READY}` return ( `if [ -f ${verified} ] && [ -x ${executable} ]; ` + - `then echo ${REMOTE_NODE_RUNTIME_READY}; else echo ${REMOTE_NODE_RUNTIME_MISSING}; fi` + `then ${ready}; else echo ${REMOTE_NODE_RUNTIME_MISSING}; fi` ) } diff --git a/src/main/ssh/relay-socket-path-limit.test.ts b/src/main/ssh/relay-socket-path-limit.test.ts index 8d58fbb10e0..1208c470d5d 100644 --- a/src/main/ssh/relay-socket-path-limit.test.ts +++ b/src/main/ssh/relay-socket-path-limit.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-opencode-runtime', () => ({ ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') @@ -169,8 +169,11 @@ describe('remote unix socket path limit', () => { }) describe('relay launch with a long remote $HOME', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') }) it('keeps the launched socket path inside the remote sun_path limit', async () => { diff --git a/src/main/ssh/relay-socket-path-limit.ts b/src/main/ssh/relay-socket-path-limit.ts index 34ce21565b6..328f42e1f8f 100644 --- a/src/main/ssh/relay-socket-path-limit.ts +++ b/src/main/ssh/relay-socket-path-limit.ts @@ -11,7 +11,7 @@ */ import { createHash } from 'node:crypto' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { unixSocketPathByteLimit } from '../../shared/unix-socket-path-limit' +import { unixSocketPathByteLimit, unixSocketPathFits } from '../../shared/unix-socket-path-limit' export function remoteUnixSocketPathByteLimit(host: RemoteHostPlatform): number | null { if (isWindowsRemoteHost(host)) { @@ -21,8 +21,10 @@ export function remoteUnixSocketPathByteLimit(host: RemoteHostPlatform): number } export function remoteSocketPathFitsLimit(host: RemoteHostPlatform, sockPath: string): boolean { - const limit = remoteUnixSocketPathByteLimit(host) - return limit === null || Buffer.byteLength(sockPath, 'utf8') <= limit + return ( + isWindowsRemoteHost(host) || + unixSocketPathFits(sockPath, host.os === 'darwin' ? 'darwin' : 'linux') + ) } /** Fixed-length, per-uid base. `/tmp` is the only POSIX directory whose length is not user-dependent. */ diff --git a/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts new file mode 100644 index 00000000000..a0b540777e3 --- /dev/null +++ b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts @@ -0,0 +1,26 @@ +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' +import type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' + +/** A bridged SSH CLI whose host the user opted in to control Orca, i.e. the pre-scope behaviour. */ +export const CONTROL_GRANTED_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { + kind: 'ssh-bridge', + targetId: 'box-1', + remoteCliControl: true +} + +/** The default bridge: reaches only that host's own terminals. */ +export const HOST_BOUND_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { + kind: 'ssh-bridge', + targetId: 'box-1', + remoteCliControl: false +} + +/** Runs the bridge as a host the user opted in, for tests of the surface such a host reaches. */ +export function runControlGrantedRemoteOrcaCli( + runtime: OrcaRuntimeService, + request: Omit +): Promise { + return runRemoteOrcaCli(runtime, { ...request, callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE }) +} diff --git a/src/main/ssh/ssh-host-node-runtime-mode.ts b/src/main/ssh/ssh-host-node-runtime-mode.ts new file mode 100644 index 00000000000..bbe7c1e8757 --- /dev/null +++ b/src/main/ssh/ssh-host-node-runtime-mode.ts @@ -0,0 +1,17 @@ +/** + * Hosts whose relay last launched on the opt-in host-Node runtime (`legacy`: host Node plus an + * npm install on the host). That path is outside the supported ladder, so the card says so. + */ +const hostNodeTargets = new Set() + +export function recordSshRelayRuntimeStep(targetId: string, hostNode: boolean): void { + if (hostNode) { + hostNodeTargets.add(targetId) + } else { + hostNodeTargets.delete(targetId) + } +} + +export function isSshRelayOnHostNodeRuntime(targetId: string): boolean { + return hostNodeTargets.has(targetId) +} diff --git a/src/main/ssh/ssh-hostile-host-cells.test.ts b/src/main/ssh/ssh-hostile-host-cells.test.ts index 0b1ffb85d85..498d569bac3 100644 --- a/src/main/ssh/ssh-hostile-host-cells.test.ts +++ b/src/main/ssh/ssh-hostile-host-cells.test.ts @@ -184,10 +184,23 @@ describe('hostileHostCellViolations', () => { target: 'linux-x64-glibc', unavailableReason: 'home_noexec', deployError: 'home directory is mounted noexec', - refusals: [{ step: 'A', reason: 'noexec' }], + refusals: [ + { step: 'A', reason: 'noexec' }, + { step: 'legacy', reason: 'host_node_missing' } + ], forbiddenToolCalls: [] } expect(hostileHostCellViolations(noexec, observed)).toEqual([]) + // The fallback's read-only npm probe is allowed; an install is not. + expect( + hostileHostCellViolations(noexec, { ...observed, forbiddenToolCalls: ['npm --version'] }) + ).toEqual([]) + expect( + hostileHostCellViolations(noexec, { + ...observed, + forbiddenToolCalls: ['npm --version', 'npm ci --omit=dev'] + }) + ).toEqual(['toolchain invoked: npm ci --omit=dev']) expect( hostileHostCellViolations(noexec, { ...observed, @@ -198,7 +211,7 @@ describe('hostileHostCellViolations', () => { ] }) ).toEqual([ - 'refusals A:noexec > C:noexec, expected A:noexec', + 'refusals A:noexec > C:noexec, expected A:noexec > legacy:host_node_missing', 'rung D reason no_runtime, expected home_noexec' ]) }) @@ -221,11 +234,11 @@ describe('hostileHostCellViolations', () => { { step: 'B', reason: 'artifacts_unavailable' }, { step: 'C', reason: 'libc_floor' } ], - forbiddenToolCalls: ['npm --version'] + forbiddenToolCalls: ['npm --version', 'npm install', 'make BUILDTYPE=Release -C build'] }) ).toEqual([ 'refusals A:libc_floor > B:artifacts_unavailable > C:libc_floor, expected A:libc_floor', - 'toolchain invoked: npm --version', + 'toolchain invoked: npm install; make BUILDTYPE=Release -C build', 'deploy failed: Node.js was not found on the remote host', 'settled on nothing, expected B' ]) diff --git a/src/main/ssh/ssh-hostile-host-cells.ts b/src/main/ssh/ssh-hostile-host-cells.ts index f15d0daf218..ed07ae9f9fd 100644 --- a/src/main/ssh/ssh-hostile-host-cells.ts +++ b/src/main/ssh/ssh-hostile-host-cells.ts @@ -38,8 +38,6 @@ export type HostileHostExpectation = reason: RemoteRuntimeUnavailableReason refusals: readonly RungRefusal[] } - /** The ladder fell through to the host-npm path, which cannot run on this host either. */ - | { outcome: 'legacy_failed'; refusals: readonly RungRefusal[] } /** The host opted out: the ladder never runs and nothing enters the pinned runtime store. */ | { outcome: 'legacy_opt_out' } @@ -138,7 +136,7 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ }, { // The musl Node links libstdc++, so its self-test proves the missing library; B has no - // compat runtime yet and C finds no host Node. + // compat runtime yet, C finds no host Node, and the host-Node fallback proves it has none. id: 'alpine-musl-no-libstdcxx', dockerfile: [`FROM ${ALPINE_3_20}`, ALPINE_SSHD], expect: { @@ -147,12 +145,14 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ refusals: [ { step: 'A', reason: 'missing_lib' }, { step: 'B', reason: 'runtime_unavailable' }, - { step: 'C', reason: 'host_node_missing' } + { step: 'C', reason: 'host_node_missing' }, + { step: 'legacy', reason: 'host_node_missing' } ] } }, { - // A host Node 20 does not help: rung C would load its addons from the same noexec tree. + // A proved noexec skips B and C (same tree) for the host-Node fallback, which finds Node 20 + // without npm; that answered "no Node" is the only way to D. id: 'ubuntu2204-node20-noexec-home', dockerfile: [ `FROM ${NODE_20} AS host-node`, @@ -166,7 +166,10 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ expect: { outcome: 'unavailable', reason: 'home_noexec', - refusals: [{ step: 'A', reason: 'noexec' }] + refusals: [ + { step: 'A', reason: 'noexec' }, + { step: 'legacy', reason: 'host_node_missing' } + ] } }, { @@ -254,7 +257,7 @@ export function selectHostileHostCells( } export type HostileHostObservation = { - /** The rung the ladder settled on, or null when it never settled (legacy failure). */ + /** The rung the ladder settled on, or null when it never settled. */ settledRung: SshRemoteRuntimeRung | null /** The server target of a launched relay, when the ladder resolved one. */ target: ServerTarget | null @@ -282,10 +285,11 @@ export function hostileHostCellViolations( `refusals ${describeRefusals(observed.refusals)}, expected ${describeRefusals(expectedRefusals)}` ) } - // Why every ladder outcome: no rung reached before legacy may reach for npm or a compiler. - const legacy = expect.outcome === 'legacy_failed' || expect.outcome === 'legacy_opt_out' - if (!legacy && observed.forbiddenToolCalls.length > 0) { - violations.push(`toolchain invoked: ${observed.forbiddenToolCalls.join('; ')}`) + // Why every ladder outcome: nothing may install or compile. A version probe is read-only; the + // host-Node fallback asks `npm --version` before it settles D on a host with no usable npm. + const toolchainActions = observed.forbiddenToolCalls.filter((call) => !isReadOnlyToolProbe(call)) + if (expect.outcome !== 'legacy_opt_out' && toolchainActions.length > 0) { + violations.push(`toolchain invoked: ${toolchainActions.join('; ')}`) } switch (expect.outcome) { case 'launched': @@ -309,14 +313,6 @@ export function hostileHostCellViolations( ) } break - case 'legacy_failed': - if (observed.settledRung !== null) { - violations.push(`settled on ${observed.settledRung}, expected the host-npm path to fail`) - } - if (!observed.deployError) { - violations.push('deploy succeeded on a host with no runnable runtime') - } - break case 'legacy_opt_out': // The host-Node path's own verdict depends on the host's Node, so only the ladder is judged. if (observed.settledRung !== null) { @@ -327,6 +323,11 @@ export function hostileHostCellViolations( return violations } +/** A shim call that only asks a tool its version, as ` --version` or ` -v`. */ +export function isReadOnlyToolProbe(call: string): boolean { + return /^\S+ (?:--version|-v)$/.test(call.trim()) +} + export function parseForbiddenToolLog(contents: string): string[] { return contents .split('\n') diff --git a/src/main/ssh/ssh-hostile-host-test-fixture.ts b/src/main/ssh/ssh-hostile-host-test-fixture.ts index dedaea2d5e8..be52dae1395 100644 --- a/src/main/ssh/ssh-hostile-host-test-fixture.ts +++ b/src/main/ssh/ssh-hostile-host-test-fixture.ts @@ -300,7 +300,7 @@ export function hostileHostSshTarget(target: HostileHostTarget): SshTarget { port: target.port, username: target.username, identityFile: target.identityFile, - identitiesOnly: true, - remoteRuntime: 'pinned-node' + // No remoteRuntime: the matrix proves the ladder a host gets by default. + identitiesOnly: true } } diff --git a/src/main/ssh/ssh-relay-answered-failure-producers.test.ts b/src/main/ssh/ssh-relay-answered-failure-producers.test.ts new file mode 100644 index 00000000000..1daee192722 --- /dev/null +++ b/src/main/ssh/ssh-relay-answered-failure-producers.test.ts @@ -0,0 +1,208 @@ +/** + * The real producers behind the relay ladder's "did the host answer?" question: system-ssh + * directory upload, system sftp, and the warm runtime check. Each runs real processes where it can. + */ +import { randomBytes } from 'node:crypto' +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import type { SshTarget } from '../../shared/ssh-types' +import { runProcess, runProcessSync } from '../../shared/child-process/run-process' +import { + REMOTE_NODE_RUNTIME_READY, + REMOTE_NODE_RUNTIME_VERIFIED_MARKER, + RemoteNodeRuntimeSelfTestError, + assertRemoteNodeRuntimePromoted +} from './orcad-remote-node-runtime-report' +import { remoteNodeRuntimePresentCommand } from './orcad-remote-node-runtime' +import { isAnsweredHostFailure } from './ssh-relay-host-answered-failure' +import { classifyPinnedRuntimeFailure } from './ssh-relay-runtime-self-test' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { findSystemSsh } from './system-ssh-binary' +import { uploadDirectoryViaSystemSsh } from './system-ssh-file-transfer' +import { runSftpBatch } from './system-ssh-sftp-transfer' + +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal>()), + runProcess: vi.fn() +})) +vi.mock('./system-ssh-args', () => ({ buildSshArgs: () => ['--', 'host'] })) +vi.mock('./system-ssh-binary', () => ({ findSystemSsh: vi.fn(() => '/usr/bin/ssh') })) + +const posix = process.platform !== 'win32' +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh args are mocked, so the target is never read. +const target = {} as SshTarget +let scratch: string + +beforeEach(async () => { + scratch = await mkdtemp(join(tmpdir(), 'orca-answered-producers-')) +}) +afterEach(async () => { + vi.unstubAllEnvs() + await rm(scratch, { recursive: true, force: true }) +}) + +async function script(name: string, body: string): Promise { + const path = join(scratch, name) + await writeFile(path, `#!/bin/sh\n${body}\n`) + await chmod(path, 0o755) + return path +} + +async function payload(): Promise { + const dir = join(scratch, 'payload') + await mkdir(dir) + await writeFile(join(dir, 'runtime.tar.gz'), 'fixture') + return dir +} + +describe.runIf(posix)('system-ssh directory upload', () => { + it('reports a remote tar ENOSPC as the host answering', async () => { + vi.mocked(findSystemSsh).mockReturnValue( + await script( + 'ssh', + 'cat >/dev/null\necho "tar: Cannot write: No space left on device" >&2\nexit 2' + ) + ) + const error = await uploadDirectoryViaSystemSsh(target, await payload(), '/remote').catch( + (e: unknown) => e + ) + expect(String(error)).toContain('No space left on device') + expect(isAnsweredHostFailure(error)).toBe(true) + }) + + it('keeps the remote ENOSPC when the far end closes mid-stream and local tar then fails to write', async () => { + // Why 8 MiB of noise: tar is still writing when ssh exits, so it fails downstream (BSD tar: + // exit 1 "Write error", no signal) instead of finishing first. + const dir = join(scratch, 'large') + await mkdir(dir) + await writeFile(join(dir, 'runtime.bin'), randomBytes(8 * 1024 * 1024)) + vi.mocked(findSystemSsh).mockReturnValue( + await script( + 'ssh', + 'head -c 1024 >/dev/null\necho "tar: Cannot write: No space left on device" >&2\nexit 2' + ) + ) + for (let attempt = 0; attempt < 3; attempt++) { + const error = await uploadDirectoryViaSystemSsh(target, dir, '/remote').catch( + (e: unknown) => e + ) + expect(String(error)).toContain('No space left on device') + expect(isAnsweredHostFailure(error)).toBe(true) + } + }) + + it("keeps ssh's own exit 255 (contact lost) retryable", async () => { + vi.mocked(findSystemSsh).mockReturnValue( + await script('ssh', 'cat >/dev/null\necho "Connection reset by peer" >&2\nexit 255') + ) + const error = await uploadDirectoryViaSystemSsh(target, await payload(), '/remote').catch( + (e: unknown) => e + ) + expect(isAnsweredHostFailure(error)).toBe(false) + }) + + it('blames a local tar failure, not the remote tar that saw a truncated stream', async () => { + vi.mocked(findSystemSsh).mockReturnValue( + await script('ssh', 'cat >/dev/null\necho "tar: Unexpected EOF in archive" >&2\nexit 2') + ) + const error = await uploadDirectoryViaSystemSsh( + target, + join(scratch, 'missing'), + '/remote' + ).catch((e: unknown) => e) + expect(String(error)).toContain('local tar relay upload') + expect(isAnsweredHostFailure(error)).toBe(false) + }) +}) + +describe('system sftp batch', () => { + beforeEach(() => { + vi.stubEnv('ORCA_SYSTEM_SFTP_PATH', '/fixture/sftp') + }) + + it('reports a Windows file permission refusal as the host answering', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: 1, + signal: null, + stdout: '', + stderr: 'dest open "C:/Users/me/.orca-remote/node.zip": Permission denied', + timedOut: false + }) + const error = await runSftpBatch(target, ['put node.zip']).catch((e: unknown) => e) + expect(String(error)).toContain('sftp batch failed (exit 1)') + expect(isAnsweredHostFailure(error)).toBe(true) + }) + + it.each([ + [255, 'Connection closed'], + [1, 'Connection reset by peer'] + ])('keeps a lost connection (exit %i: %s) retryable', async (code, stderr) => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code, + signal: null, + stdout: '', + stderr, + timedOut: false + }) + const error = await runSftpBatch(target, ['put node.zip']).catch((e: unknown) => e) + expect(isAnsweredHostFailure(error)).toBe(false) + }) +}) + +describe.runIf(posix)('warm runtime check on a cached install', () => { + const host = getRemoteHostPlatform('linux-x64') + + async function cachedRuntime(nodeBody: string): Promise { + const runtimeDir = join(scratch, 'runtime') + await mkdir(join(runtimeDir, 'bin'), { recursive: true }) + await writeFile(join(runtimeDir, REMOTE_NODE_RUNTIME_VERIFIED_MARKER), '') + const node = join(runtimeDir, 'bin', 'node') + await writeFile(node, `#!/bin/sh\n${nodeBody}\n`) + await chmod(node, 0o755) + return runtimeDir + } + + function runCheck(runtimeDir: string, run: boolean): string { + return runProcessSync({ + program: '/bin/sh', + args: ['-c', remoteNodeRuntimePresentCommand(host, runtimeDir, run)] + }).stdout + } + + it('stays READY while the cached runtime still runs', async () => { + const runtimeDir = await cachedRuntime(`echo v${NODE_RUNTIME_PIN.version}`) + expect(runCheck(runtimeDir, true).trim()).toBe(REMOTE_NODE_RUNTIME_READY) + }) + + it.each([ + [ + 'a shared library removed since the install', + 'echo "node: error while loading shared libraries: libatomic.so.1: cannot open shared object file" >&2\nexit 127', + 'missing_lib' + ], + [ + 'an exec policy that now denies uploaded binaries', + 'echo "sh: node: Permission denied" >&2\nexit 126', + 'noexec' + ] + ])('reports %s as a classified refusal before launch', async (_label, body, refusal) => { + const runtimeDir = await cachedRuntime(body) + // Without running it, presence alone still says READY: the gap this check closes. + expect(runCheck(runtimeDir, false).trim()).toBe(REMOTE_NODE_RUNTIME_READY) + const output = runCheck(runtimeDir, true) + let thrown: unknown + try { + assertRemoteNodeRuntimePromoted(output) + } catch (error) { + thrown = error + } + if (!(thrown instanceof RemoteNodeRuntimeSelfTestError)) { + throw new Error(`expected a self-test refusal, got ${String(thrown)}`) + } + expect(classifyPinnedRuntimeFailure(thrown.exitStatus, thrown.output)).toBe(refusal) + expect(isAnsweredHostFailure(thrown)).toBe(true) + }) +}) diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index f519dbab4fb..18624eb170d 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -8,7 +8,7 @@ // the original "stale daemon serves new client" bug. import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ @@ -100,8 +100,11 @@ function makeMockConnection(): SshConnection { } describe('cross-version isolation', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') }) it('a v2 deploy never references the v1 install dir or v1 socket path', async () => { diff --git a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts index 4826f3c4dcc..7c5676f572d 100644 --- a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts +++ b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-opencode-runtime', () => ({ ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') @@ -123,8 +123,11 @@ function launchedDaemon(conn: SshConnection): boolean { * probe exists to prevent: the fresh daemon loses the bind by luck, not by design. */ describe('deployAndLaunchRelay honours the incumbent verdict', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(sweepSupersededRelayEndpoints).mockReset().mockResolvedValue([]) vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset() diff --git a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts index a8b0be31048..18f4bca2a35 100644 --- a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts +++ b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + isSshRelayOnHostNodeRuntime, + recordSshRelayRuntimeStep +} from './ssh-host-node-runtime-mode' import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' +import type * as OrcadRemoteNodeRuntimeModule from './orcad-remote-node-runtime' +import type * as PinnedNodeInstallModule from './ssh-relay-pinned-node-install' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } @@ -50,7 +56,8 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') })) -vi.mock('./ssh-remote-node-resolution', () => ({ +vi.mock('./ssh-remote-node-resolution', async (importOriginal) => ({ + ...(await importOriginal>()), resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) @@ -112,6 +119,14 @@ vi.mock('./ssh-relay-pinned-node-install', async (importOriginal) => ({ verifyPinnedRelayInstall: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./orcad-remote-node-runtime', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ensureRemoteOrcadNodeRuntime: vi.fn(actual.ensureRemoteOrcadNodeRuntime) + } +}) + vi.mock('./ssh-relay-host-node-addons', async (importOriginal) => ({ ...(await importOriginal>()), planHostNodeAddonRelay: vi.fn() @@ -127,7 +142,7 @@ vi.mock('./remote-node-runtime-store-gc', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' import { execCommand } from './ssh-relay-deploy-helpers' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { RemoteNodeNotFoundError, resolveRemoteNodePath } from './ssh-remote-node-resolution' import { finalizeInstall, gcOldRelayVersions, @@ -139,8 +154,16 @@ import { resolvePinnedRelayTargetFacts, type PinnedRelayPlan } from './ssh-relay-pinned-node' +import { + MUTABLE_PINNED_REFUSAL_REPLAY_MS, + resetPinnedRuntimeRefusalsForTests +} from './ssh-relay-pinned-refusal-cache' import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' import { planHostNodeAddonRelay, type HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' +import { + ensureRemoteOrcadNodeRuntime, + RemoteNodeRuntimeSelfTestError +} from './orcad-remote-node-runtime' import { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' import { resetSshRemoteRuntimeTelemetryForTests } from './ssh-remote-runtime-telemetry' import { getSshTargetRegistryStore } from './ssh-target-registry' @@ -214,12 +237,9 @@ function queueInstalledPinnedLaunch(): void { .mockResolvedValueOnce('READY') } -function queueInstalledLegacyLaunch(options: { platformProbed?: boolean } = {}): void { - // A later ladder step reuses the platform the first step detected. - if (!options.platformProbed) { - vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } +function queueInstalledLegacyLaunch(): void { vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') .mockResolvedValueOnce('/home/user') .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') .mockResolvedValueOnce('') // launch namespace marker @@ -227,6 +247,21 @@ function queueInstalledLegacyLaunch(options: { platformProbed?: boolean } = {}): .mockResolvedValueOnce('READY') } +/** A Linux host whose host-Node relay is installed and launches, answered by command text. */ +function answerHostNodeLaunchByCommand(): void { + const answers: [string, string][] = [ + ['uname', '__ORCA_REMOTE_PLATFORM__ Linux x86_64'], + ['process.stdout.write("READY")', 'READY'], + ['ORCA-NATIVE-DEPS-OK', 'ORCA-NATIVE-DEPS-OK'], + ['test -S', 'DEAD'] + ] + vi.mocked(execCommand).mockImplementation(async (_conn, command) => + command === 'echo $HOME' + ? '/home/user' + : (answers.find(([needle]) => command.includes(needle))?.[1] ?? '') + ) +} + describe('deployAndLaunchRelay on the pinned Node runtime', () => { beforeEach(() => { vi.clearAllMocks() @@ -243,10 +278,25 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { .mockRejectedValue(new PinnedRelayFallbackError('libc_floor', 'addons refused')) vi.mocked(getSshTargetRegistryStore).mockReset().mockReturnValue(null) resetSshRemoteRuntimeTelemetryForTests() + resetPinnedRuntimeRefusalsForTests() }) - it('keeps the legacy host-Node path when the host has no runtime setting', async () => { + it('runs the pinned ladder when the host has no runtime setting and nothing recorded', async () => { const conn = makeConnection() + queueInstalledPinnedLaunch() + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(result.nodePath).toBe(PINNED_NODE) + expect(result.serverBuildId).toBe(PINNED_VERSION) + expect( + vi.mocked(execCommand).mock.calls.some(([, cmd]) => String(cmd).includes('NATIVE-DEPS')) + ).toBe(false) + }) + + it('keeps the host-npm path for a host that opts into Host Node', async () => { + const conn = makeConnection('legacy') queueInstalledLegacyLaunch() const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') @@ -254,6 +304,16 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { expect(planPinnedNodeRelay).not.toHaveBeenCalled() expect(result.nodePath).toBe('/usr/bin/node') expect(result.serverBuildId).toBe('0.1.0+abcdef012345') + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) + }) + + it('clears the Host Node marker once a later connect launches on the ladder', async () => { + recordSshRelayRuntimeStep('target-1', true) + queueInstalledPinnedLaunch() + + await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') + + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(false) }) it('launches from the runtime-folded version dir with the pinned Node and no host Node probe', async () => { @@ -293,7 +353,7 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { }) it('leaves the runtime store alone on the legacy host-Node path', async () => { - const conn = makeConnection() + const conn = makeConnection('legacy') queueInstalledLegacyLaunch() await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') @@ -303,33 +363,210 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { expect(gcRemoteNodeRuntimeStore).not.toHaveBeenCalled() }) - it('steps down the ladder to the host-npm relay on classified refusals', async () => { - const conn = makeConnection('pinned-node') + it('falls back to the host-Node relay, as before the ladder, once A, B and C all refuse', async () => { + const conn = makeConnection() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( new PinnedRelayFallbackError('missing_lib', 'libstdc++.so.6: cannot open') ) vi.mocked(planPinnedNodeRelay) .mockResolvedValueOnce(pinnedPlan()) - .mockResolvedValueOnce({ kind: 'host-node', fallbackReason: 'artifacts_unavailable' }) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - queueInstalledLegacyLaunch({ platformProbed: true }) + .mockResolvedValueOnce({ kind: 'host-node', fallbackReason: 'missing_lib' }) + answerHostNodeLaunchByCommand() const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') expect(result.nodePath).toBe('/usr/bin/node') - expect(result.serverBuildId).toBe('0.1.0+abcdef012345') - expect(detachedLaunchCommand(conn)).toContain("'/usr/bin/node' relay.js --detached") + // The fallback's strict probe found Node once; the launch reuses it. + expect(resolveRemoteNodePath).toHaveBeenCalledOnce() + expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung A unavailable (missing_lib)')) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('rung B unavailable (artifacts_unavailable)') - ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung B unavailable (missing_lib)')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung C unavailable (libc_floor)')) warn.mockRestore() }) + it('keeps the host-Node relay, never rung D or a persisted decision, when this build lacks the orcad template', async () => { + const registry = { + getTarget: vi.fn(() => ({ id: 'target-1' })), + updateTarget: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. + vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const missing = 'The packaged orcad deployment template is missing' + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'artifacts_unavailable' + }) + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockRejectedValueOnce(new PinnedRelayFallbackError('artifacts_unavailable', missing)) + const conn = makeConnection() + queueInstalledLegacyLaunch() + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(result.nodePath).toBe('/usr/bin/node') + expect(detachedLaunchCommand(conn)).toContain("'/usr/bin/node' relay.js --detached") + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) + expect(registry.updateTarget).not.toHaveBeenCalled() + expect(track).toHaveBeenCalledWith( + 'ssh_remote_runtime_resolved', + expect.objectContaining({ rung: 'legacy', first_refusal: 'artifacts_unavailable' }) + ) + }) + + it('settles rung D after a client gap only once the host-Node fallback proves no Node', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'artifacts_unavailable' + }) + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockRejectedValueOnce(new PinnedRelayFallbackError('host_node_missing', 'no Node 18+')) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) + vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + + const failure = await deployAndLaunchRelay( + makeConnection(), + undefined, + undefined, + 'target-1' + ).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) + expect(String(failure)).toContain('could not prepare its bundled Node.js') + expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ reason: 'no_runtime' }) + expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) + }) + + function queueWindowsPlatformProbe(): SshConnection { + const conn = makeConnection() + Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) + vi.mocked(execCommand) + .mockRejectedValueOnce(new Error('uname not found')) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + return conn + } + + it('settles rung D with Windows wording when a Windows host has no Node to fall back to', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'security_software' + }) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) + + const failure = await deployAndLaunchRelay( + queueWindowsPlatformProbe(), + undefined, + 300, + 'target-1' + ).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) + expect(String(failure)).toContain('Windows host') + expect(String(failure)).toContain('install Node.js 18+') + expect(String(failure)).not.toContain('mounted noexec') + expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ + reason: 'no_runtime', + host: { platform: 'win32' } + }) + }) + + it('keeps an unanswered host-Node probe a retryable failure, not rung D', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'security_software' + }) + const lost = new Error('channel closed') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(lost) + + await expect( + deployAndLaunchRelay(queueWindowsPlatformProbe(), undefined, 300, 'target-1') + ).rejects.toBe(lost) + }) + + it('replays a Windows security-software refusal for a day, then re-proves rung A', async () => { + const winSha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 + const stored: Partial = {} + const registry = { + getTarget: vi.fn(() => ({ id: 'target-1', ...stored })), + updateTarget: vi.fn((_id: string, updates: Partial) => { + Object.assign(stored, updates) + return null + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. + vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) + vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ target: 'win32-x64', glibc: null }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'security_software' + }) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) + + await deployAndLaunchRelay(queueWindowsPlatformProbe(), undefined, 300, 'target-1').catch( + () => undefined + ) + + // Persisted with its proof time, so a restart doesn't re-upload to a host that still blocks it. + expect(stored.remoteRuntimeResolution).toMatchObject({ + pinnedRefusal: 'security_software', + runtimeSha256: winSha, + refusedAt: expect.any(Number) + }) + const persisted = vi.mocked(planPinnedNodeRelay).mock.calls[0]![0].persistedRefusal + const winFacts = { target: 'win32-x64' as const, glibc: null } + expect(persisted?.(winFacts)).toBe('security_software') + stored.remoteRuntimeResolution = { + ...stored.remoteRuntimeResolution!, + refusedAt: Date.now() - MUTABLE_PINNED_REFUSAL_REPLAY_MS + } + expect(persisted?.(winFacts)).toBeNull() + }) + + it.each(['missing_lib', 'security_software', 'noexec', 'artifacts_unavailable'] as const)( + 'keeps a Windows host on the host-Node relay after rung A refuses for %s', + async (reason) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const conn = makeConnection() + Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: reason + }) + vi.mocked(execCommand) + .mockRejectedValueOnce(new Error('uname not found')) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + .mockResolvedValueOnce('C:\\Users\\me') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('') // no persisted active pipe + .mockResolvedValueOnce('WAITING') // named pipe probe + .mockResolvedValueOnce('') // WMI relay launch + .mockResolvedValueOnce('READY') // named pipe poll + .mockResolvedValueOnce('') // persist active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-1') + + expect(resolveRemoteNodePath).toHaveBeenCalled() + expect(result.serverBuildId).toBe('0.1.0+abcdef012345') + expect(planHostNodeAddonRelay).not.toHaveBeenCalled() + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) + } + ) + it('runs rung B on the glibc 2.17 compat runtime when rung A is below its glibc floor', async () => { const conn = makeConnection('pinned-node') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -358,35 +595,6 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { warn.mockRestore() }) - it('runs the ladder to rung B where this connect just recorded that managed orcad cannot run', async () => { - const registry = { - getTarget: vi.fn(() => ({ - id: 'target-1', - managedServerUnavailable: { reason: 'runtime_self_test', appVersion: '1.0.0' } - })), - updateTarget: vi.fn(() => null) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. - vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const oldGlibc = { major: 2, minor: 17 } - vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ - target: 'linux-x64-glibc', - glibc: oldGlibc - }) - vi.mocked(planPinnedNodeRelay) - .mockResolvedValueOnce({ kind: 'host-node', fallbackReason: 'libc_floor' }) - .mockResolvedValueOnce({ ...pinnedPlan(), target: 'linux-x64-glibc217', glibc: oldGlibc }) - queueInstalledPinnedLaunch() - - // The connection's own target predates the record and carries no runtime setting. - const result = await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(result.nodePath).toBe(COMPAT_NODE) - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - }) - it('skips rung B on a current glibc when rung A refused for a reason B cannot answer', async () => { const conn = makeConnection('pinned-node') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -394,9 +602,14 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { kind: 'host-node', fallbackReason: 'illegal_instruction' }) - queueInstalledLegacyLaunch() + vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + await expect( + deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + ).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) expect(planPinnedNodeRelay).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith( @@ -508,12 +721,64 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { ) }) - it('goes straight to rung D on noexec, with the classified reason and no launch', async () => { + it("steps past rung A to the host's Node when NixOS's stub loader refuses the pinned Node", async () => { + const conn = makeConnection() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const actualInstall = await vi.importActual( + './ssh-relay-pinned-node-install' + ) + vi.mocked(ensurePinnedRelayRuntime).mockImplementationOnce( + actualInstall.ensurePinnedRelayRuntime + ) + vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( + new RemoteNodeRuntimeSelfTestError( + 127, + `Could not start dynamically linked executable: ${PINNED_NODE}\n` + + 'NixOS cannot run dynamically linked executables intended for generic\n' + + 'linux environments out of the box. For more information, see:\n' + + 'https://nix.dev/permalink/stub-ld' + ) + ) + const nixNode = '/run/current-system/sw/bin/node' + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockResolvedValueOnce({ ...hostNodePlan(), nodePath: nixNode }) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('uname')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('process.stdout.write("READY")')) { + return 'READY' + } + return command.includes('test -S') ? 'DEAD' : '' + }) + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce() + expect(result.nodePath).toBe(nixNode) + expect(detachedLaunchCommand(conn)).toContain(`'${nixNode}' relay.js --detached`) + expect( + vi.mocked(execCommand).mock.calls.some(([, cmd]) => /NATIVE-DEPS|npm /.test(String(cmd))) + ).toBe(false) + expect(track).toHaveBeenCalledWith( + 'ssh_remote_runtime_resolved', + expect.objectContaining({ rung: 'c', first_refusal: 'wrong_libc' }) + ) + }) + + it('tries the host-Node fallback after a proved noexec, reaching D only on its answer', async () => { const conn = makeConnection('pinned-node') vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( new PinnedRelayFallbackError('noexec', 'exit 126: Permission denied') ) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) vi.mocked(execCommand) .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') .mockResolvedValueOnce('/home/user') @@ -529,8 +794,9 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { reason: 'home_noexec', repairable: false }) + // B and C load addons from the same tree, so only the fallback can disprove the noexec. expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) expect(detachedLaunchCommand(conn)).toBeUndefined() expect(track).toHaveBeenCalledWith( 'ssh_remote_runtime_resolved', @@ -538,6 +804,38 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { ) }) + it('steps down on an answered but unclassified install failure (ENOSPC), not abort', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const enospc = Object.assign( + new Error('Command "tar -xzf" failed (exit 2): tar: write error: No space left on device'), + { exitCode: 2, stdout: '' } + ) + vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(enospc) + answerHostNodeLaunchByCommand() + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockRejectedValueOnce(new PinnedRelayFallbackError('host_node_missing', 'no Node 18+')) + + const result = await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') + + expect(result.nodePath).toBe('/usr/bin/node') + expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) + }) + + it('keeps an unanswered install failure retryable rather than stepping down', async () => { + const lost = Object.assign(new Error('channel lost'), { sshChannelCloseConfirmed: false }) + vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(lost) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + + await expect( + deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') + ).rejects.toBe(lost) + expect(planHostNodeAddonRelay).not.toHaveBeenCalled() + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + }) + it('persists the rung A refusal under the host key and skips A on the next connect', async () => { const stored: Partial = {} const registry = { @@ -556,12 +854,16 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { vi.mocked(execCommand) .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') .mockResolvedValueOnce('/home/user') - queueInstalledLegacyLaunch({ platformProbed: true }) + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( + new RemoteNodeNotFoundError('Node.js not found on remote host.') + ) - await deployAndLaunchRelay(makeConnection('pinned-node'), undefined, undefined, 'target-1') + await expect( + deployAndLaunchRelay(makeConnection('pinned-node'), undefined, undefined, 'target-1') + ).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) expect(stored.remoteRuntimeResolution).toMatchObject({ - rung: 'legacy', + rung: 'D', pinnedRefusal: 'illegal_instruction', glibc: '2.31', runtimeSha256: RUNTIME_SHA diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts index 26ba5a2d990..063414ea8b4 100644 --- a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' vi.mock('electron', () => ({ @@ -137,8 +137,11 @@ function stageCommandResponse(command: string): string | undefined { } describe('deployAndLaunchRelay staged uploads', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ write: vi.fn(), diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts index 1a49ec11590..1036385fac1 100644 --- a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts +++ b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts @@ -1,6 +1,6 @@ // Windows named-pipe endpoint deploys, split out of ssh-relay-deploy.test.ts: that file sits at // the max-lines cap, and these four share only the deploy harness with the rest of it. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' vi.mock('electron', () => ({ @@ -146,8 +146,11 @@ function makeMockConnection(): SshConnection { } describe('deployAndLaunchRelay on Windows remotes', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ write: vi.fn(), diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 27a70f60fdb..db38b195145 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' vi.mock('electron', () => ({ @@ -165,8 +165,11 @@ function detachedLaunchCommand(conn: SshConnection): string | undefined { } describe('deployAndLaunchRelay', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(ensureRemoteOpenCodeRuntime).mockReset().mockResolvedValue('ready') vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index b615cd8053c..e5f94701c47 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -2,7 +2,9 @@ import { existsSync } from 'node:fs' import { app } from 'electron' import { relayBundleCandidates } from './relay-bundle-paths' -import { PinnedRelayFallbackError, resolveConnectRemoteRuntime } from './ssh-relay-pinned-node' +import { PinnedRelayFallbackError, resolveSshRemoteRuntime } from './ssh-relay-pinned-node' +import { recordSshRelayRuntimeStep } from './ssh-host-node-runtime-mode' +import { isAnsweredHostFailure } from './ssh-relay-host-answered-failure' import { ensurePinnedRelayRuntime, prebuiltRelayNodePath, @@ -10,7 +12,7 @@ import { } from './ssh-relay-pinned-node-install' import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' import { - nextRelayRuntimeStep, + relayRuntimeStepAfterRefusal, relayRuntimeLadder, relayRuntimeStorePins, type RelayRuntimeStep @@ -429,21 +431,24 @@ async function deployAndLaunchRelayInner( ): Promise { const target = typeof conn.getTarget === 'function' ? conn.getTarget() : undefined const registry = getSshTargetRegistryStore() - const ladder = relayRuntimeLadder( - resolveConnectRemoteRuntime(target, target && registry?.getTarget(target.id)) - ) + const ladder = relayRuntimeLadder(resolveSshRemoteRuntime(target)) const run = new RelayRuntimeLadderRun( target?.id ?? relayInstanceId ?? '', - target && registry ? sshTargetRelayRuntimeDecisionStore(registry) : null + target && registry ? sshTargetRelayRuntimeDecisionStore(registry) : null, + ladder.length > 1 ) let step = ladder[0] ?? 'legacy' while (true) { deploySignal?.throwIfAborted() try { if (step === 'D') { + if (target) { + recordSshRelayRuntimeStep(target.id, false) + } run.settle('D') throw remoteRuntimeUnavailableError(run) } + run.launchStarted = false const result = await deployAndLaunchRelayAttempt( conn, onProgress, @@ -453,22 +458,26 @@ async function deployAndLaunchRelayInner( { step, run } ) // Why only a laddered pass: a plain host-npm connect has no rung decision to record. - if (ladder.length > 1) { + if (run.laddered) { run.settle(step) } + if (target) { + recordSshRelayRuntimeStep(target.id, step === 'legacy') + } return result } catch (err) { - if (err instanceof PinnedRelayFallbackError && step !== 'legacy' && step !== 'D') { + const refusal = ladderRefusal(err, step, run) + if (refusal) { console.warn( - `[ssh-relay] Relay runtime rung ${step} unavailable (${err.reason}): ${err.detail}` + `[ssh-relay] Relay runtime rung ${step} unavailable (${refusal.reason}): ${refusal.detail}` ) - run.refused(step, err.reason, err.remembered) - step = nextRelayRuntimeStep(ladder, step, err.reason, err.remembered) + run.refused(step, refusal.reason, refusal.remembered) + step = relayRuntimeStepAfterRefusal(ladder, step, refusal.reason, refusal.remembered) run.enter(step) continue } if (!(err instanceof RelayDirectoryGcConflictError)) { - if (ladder.length > 1 && !deploySignal?.aborted) { + if (run.laddered && !deploySignal?.aborted) { run.unresolved(step) } throw err @@ -479,6 +488,32 @@ async function deployAndLaunchRelayInner( } } +/** + * The refusal a failed ladder step steps past, or null when it must propagate. Any failure the + * host answered steps down, classified or not; opt-in legacy is the user's choice, with no D below. + */ +function ladderRefusal( + err: unknown, + step: RelayRuntimeStep, + run: RelayRuntimeLadderRun +): PinnedRelayFallbackError | null { + if (step === 'D' || (step === 'legacy' && !run.laddered)) { + return null + } + if (err instanceof PinnedRelayFallbackError) { + return err + } + // Why not after launch: a relay may be running, and a second rung would launch beside it. + if ( + run.launchStarted || + err instanceof RelayDirectoryGcConflictError || + !isAnsweredHostFailure(err) + ) { + return null + } + return new PinnedRelayFallbackError('install_failed', err instanceof Error ? err.message : '') +} + type RelayRuntimeRequest = { step: RelayRuntimeStep; run: RelayRuntimeLadderRun } async function deployAndLaunchRelayAttempt( @@ -489,7 +524,7 @@ async function deployAndLaunchRelayAttempt( deploySignal?: AbortSignal, runtimeRequest: RelayRuntimeRequest = { step: 'legacy', - run: new RelayRuntimeLadderRun(relayInstanceId ?? '', null) + run: new RelayRuntimeLadderRun(relayInstanceId ?? '', null, false) } ): Promise { onProgress?.('Detecting remote platform...') @@ -577,7 +612,15 @@ async function deployAndLaunchRelayOnRuntime({ prebuilt, deploySignal ) - : await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) + : run.hostNodePath + ? // Why: the fallback's strict probe already found this Node; don't resolve it twice. + { + ...(await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { + signal: deploySignal + })), + nodePath: run.hostNodePath + } + : await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) const pinnedContext = prebuilt @@ -791,6 +834,7 @@ async function deployAndLaunchRelayOnRuntime({ onProgress?.('Starting relay...') console.log('[ssh-relay] Launching relay...') // A failed launch retains its fences until stale recovery can establish liveness. + run.launchStarted = true const launched = await launchRelay( conn, remoteRelayDir, diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts index 18d99a926a9..9c65ab76fc6 100644 --- a/src/main/ssh/ssh-relay-gc-retry.test.ts +++ b/src/main/ssh/ssh-relay-gc-retry.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) vi.mock('fs', () => ({ @@ -78,8 +78,11 @@ function makeConnection(): SshConnection { } describe('relay GC deploy retry', () => { + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') }) it('recomputes install state when GC wins before a healthy relay launch', async () => { diff --git a/src/main/ssh/ssh-relay-host-answered-failure.test.ts b/src/main/ssh/ssh-relay-host-answered-failure.test.ts new file mode 100644 index 00000000000..f2622ee944a --- /dev/null +++ b/src/main/ssh/ssh-relay-host-answered-failure.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + RemoteNodeRuntimeSecurityModifiedError, + RemoteNodeRuntimeSelfTestError +} from './orcad-remote-node-runtime-report' +import { isAnsweredHostFailure, RelayHostAnsweredError } from './ssh-relay-host-answered-failure' +import { SSH_EXEC_TIMEOUT_CODE } from './ssh-relay-exec-command' +import { SystemSshCommandExitError } from './system-ssh-operation-lifecycle' + +const exitError = (code: number, message: string): Error => + Object.assign(new Error(message), { exitCode: code, stdout: '' }) + +describe('isAnsweredHostFailure', () => { + it.each([ + ['tar ENOSPC', exitError(2, 'tar: write error: No space left on device')], + ['extract marker', exitError(1, 'ORCA_NODE_RUNTIME_EXTRACT_FAILED')], + ['hash mismatch marker', exitError(1, 'ORCA_NODE_RUNTIME_HASH_MISMATCH')], + ['SFTP write failure', Object.assign(new Error('Failure'), { code: 4 })], + ['SFTP permission denied', Object.assign(new Error('Permission denied'), { code: 3 })], + ['unverified promote', new RelayHostAnsweredError('The host did not verify the runtime')], + ['unclassified self-test', new RemoteNodeRuntimeSelfTestError(1, 'boom')], + ['security modified', new RemoteNodeRuntimeSecurityModifiedError('node.exe')], + ['system ssh exit', new SystemSshCommandExitError('promote', 1, 'Access denied')] + ])('steps down on an answered %s', (_label, error) => { + expect(isAnsweredHostFailure(error)).toBe(true) + }) + + it.each([ + ['abort', Object.assign(new Error('cancelled'), { name: 'AbortError' })], + [ + 'channel open failure', + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 }) + ], + ['session limit', Object.assign(new Error('open failed'), { reason: 4 })], + ['timeout', Object.assign(new Error('timed out'), { code: SSH_EXEC_TIMEOUT_CODE })], + [ + 'unconfirmed termination', + Object.assign(exitError(1, 'lost'), { sshChannelCloseConfirmed: false }) + ], + ['SFTP connection lost', Object.assign(new Error('Connection lost'), { code: 7 })], + ['system ssh transport', new SystemSshCommandExitError('promote', 255, 'Connection reset')], + ['system ssh signal', new SystemSshCommandExitError('promote', null, '')], + ['plain error', new Error('something else')] + ])('stays retryable on %s', (_label, error) => { + expect(isAnsweredHostFailure(error)).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-relay-host-answered-failure.ts b/src/main/ssh/ssh-relay-host-answered-failure.ts new file mode 100644 index 00000000000..1bfd575a69f --- /dev/null +++ b/src/main/ssh/ssh-relay-host-answered-failure.ts @@ -0,0 +1,43 @@ +/** + * Whether a failed relay-runtime attempt got an answer from the host. An answered failure is a + * verdict on that rung, so the ladder steps down; an unanswered one says nothing about the host, + * so the connect fails retryably and never launches a second relay beside one that may be running. + */ +import { isSshCommandExitError } from './ssh-relay-exec-command' +import { isSshSessionLimitError } from './ssh-session-limit-error' +import { + isHostAnsweredSystemSshExit, + SystemSshCommandExitError +} from './system-ssh-operation-lifecycle' + +/** SFTP statuses the server sent about the request itself; NO_CONNECTION/CONNECTION_LOST are transport. */ +const ANSWERED_SFTP_STATUSES: ReadonlySet = new Set([1, 2, 3, 4, 5, 8]) + +/** A host-side step that answered with something other than success, carrying what it said. */ +export class RelayHostAnsweredError extends Error { + constructor(message: string) { + super(message) + this.name = 'RelayHostAnsweredError' + } +} + +function isSftpStatusError(err: Error): boolean { + const code = 'code' in err ? err.code : undefined + return typeof code === 'number' && ANSWERED_SFTP_STATUSES.has(code) +} + +export function isAnsweredHostFailure(err: unknown): boolean { + if (!(err instanceof Error) || err.name === 'AbortError' || isSshSessionLimitError(err)) { + return false + } + // Why the close check: a channel whose close was never confirmed may still be running. + if (/channel open failure/i.test(err.message) || 'sshChannelCloseConfirmed' in err) { + return false + } + if (err instanceof SystemSshCommandExitError) { + return isHostAnsweredSystemSshExit(err) + } + return ( + err instanceof RelayHostAnsweredError || isSshCommandExitError(err) || isSftpStatusError(err) + ) +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts index 8d7a1988a97..10b1ed620f3 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts @@ -103,8 +103,11 @@ describe('relay native-deps cache on the deploy path', () => { let warnSpy: ReturnType const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 diff --git a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts index e8616e7c230..3a21573fbd3 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ @@ -93,8 +93,11 @@ describe('installNativeDeps staged uploads', () => { execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index d48fa1c1e80..f6d16eaed8e 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -115,8 +115,11 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') // mockReset because clearAllMocks keeps queued mockResolvedValueOnce entries, so a leaked response would bleed into the next test. vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts index 69c7ae1d2a5..43ac50dbf52 100644 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -2,7 +2,7 @@ // channel rm -rf'd node-pty on a healthy relay and forced a node-gyp rebuild. Verdicts are // ok / blocked / unverifiable — see docs/reference/ssh-execution-boundary.md. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ @@ -104,8 +104,11 @@ describe('native-deps repair probe verdicts', () => { const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } let warnSpy: ReturnType + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts index 7eda53e684d..e43516fe0c5 100644 --- a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts +++ b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts @@ -152,8 +152,11 @@ describe('spawn-time node-pty repair through the locked deploy path', () => { execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') sftpCapture.paths.length = 0 for (const key of Object.keys(sftpCapture.contents)) { diff --git a/src/main/ssh/ssh-relay-pinned-node-install.test.ts b/src/main/ssh/ssh-relay-pinned-node-install.test.ts index 2b1cee1a989..748052b41c1 100644 --- a/src/main/ssh/ssh-relay-pinned-node-install.test.ts +++ b/src/main/ssh/ssh-relay-pinned-node-install.test.ts @@ -8,13 +8,17 @@ import { RemoteNodeRuntimeSecurityModifiedError, RemoteNodeRuntimeSelfTestError } from './orcad-remote-node-runtime' +import { + REMOTE_NODE_RUNTIME_EXIT_PREFIX, + REMOTE_NODE_RUNTIME_SELFTEST_FAILED +} from './orcad-remote-node-runtime-report' import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' import { PinnedRelayFallbackError, planPinnedNodeRelay, - resetPinnedRuntimeRefusalsForTests, type PinnedRelayPlan } from './ssh-relay-pinned-node' +import { resetPinnedRuntimeRefusalsForTests } from './ssh-relay-pinned-refusal-cache' import { runPinnedRuntimeSelfTest } from './ssh-relay-runtime-self-test' import type { HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' import { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' @@ -73,6 +77,27 @@ describe('ensurePinnedRelayRuntime', () => { expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() }) + it.each([ + [ + 'a library removed since the install', + 127, + 'libatomic.so.1: cannot open shared object file', + 'missing_lib' + ], + ['an exec policy that now denies it', 126, 'sh: node: Permission denied', 'noexec'] + ] as const)( + 'refuses a cached runtime that no longer runs (%s) before any launch', + async (_label, status, output, reason) => { + vi.mocked(execCommand).mockResolvedValueOnce( + `${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}\n${REMOTE_NODE_RUNTIME_EXIT_PREFIX}${status}\n${output}\n` + ) + const failure = await ensurePinnedRelayRuntime(context, true).catch((e: unknown) => e) + expect(failure).toMatchObject({ reason }) + expect(String(vi.mocked(execCommand).mock.calls[0]?.[1])).toContain('--version') + expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() + } + ) + it('reinstalls a runtime that disappeared from under an installed relay', async () => { vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) await ensurePinnedRelayRuntime(context, true) @@ -95,6 +120,21 @@ describe('ensurePinnedRelayRuntime', () => { ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'noexec', remembered: true }) }) + it("steps down when NixOS's stub loader refuses the generic Linux runtime", async () => { + vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( + new RemoteNodeRuntimeSelfTestError( + 127, + 'Could not start dynamically linked executable: /home/u/.orca-remote/runtimes/node-x/bin/node\n' + + 'NixOS cannot run dynamically linked executables intended for generic\n' + + 'linux environments out of the box. For more information, see:\n' + + 'https://nix.dev/permalink/stub-ld' + ) + ) + const failure = await ensurePinnedRelayRuntime(context, false).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(PinnedRelayFallbackError) + expect(failure).toMatchObject({ reason: 'wrong_libc' }) + }) + it('keeps an unclassified runtime failure as an error, not a step down', async () => { const failure = new RemoteNodeRuntimeSelfTestError(1, 'something unexpected') vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce(failure) @@ -152,7 +192,7 @@ describe('verifyPinnedRelayInstall', () => { it('self-tests rung C on the host Node without the pinned version check or a cached refusal', async () => { vi.mocked(withRuntimeStoreLock).mockClear() vi.mocked(execCommand).mockResolvedValue('') - const run = new RelayRuntimeLadderRun('target-1', null) + const run = new RelayRuntimeLadderRun('target-1', null, true) const hostPlan: HostNodeAddonRelayPlan = { kind: 'host-node-addons', target: 'linux-x64-glibc', diff --git a/src/main/ssh/ssh-relay-pinned-node-install.ts b/src/main/ssh/ssh-relay-pinned-node-install.ts index e2a241fd23c..bc0c400218a 100644 --- a/src/main/ssh/ssh-relay-pinned-node-install.ts +++ b/src/main/ssh/ssh-relay-pinned-node-install.ts @@ -8,18 +8,23 @@ import { RemoteNodeRuntimeSelfTestError, REMOTE_NODE_RUNTIME_READY } from './orcad-remote-node-runtime' +import { + assertRemoteNodeRuntimePromoted, + REMOTE_NODE_RUNTIME_SELFTEST_FAILED +} from './orcad-remote-node-runtime-report' import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' import { execCommand } from './ssh-relay-deploy-helpers' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { RelayHostAnsweredError } from './ssh-relay-host-answered-failure' import { isPinnedRuntimeRefusal, PinnedRelayFallbackError, pinnedRelayNodePath, - recordPinnedRuntimeRefusal, type PinnedRelayPlan } from './ssh-relay-pinned-node' +import { recordPinnedRuntimeRefusal } from './ssh-relay-pinned-refusal-cache' import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' import { @@ -62,28 +67,34 @@ export function prebuiltRelayNodePath(context: { } /** - * The warm path only checks the verified marker; hashing ~120 MiB on every reconnect would - * spend the warm-reconnect budget (design D10 G2.6). A cold install verifies in full. + * The warm path checks the verified marker and runs `--version` in the same command; hashing + * ~120 MiB on every reconnect would spend the warm-reconnect budget (design D10 G2.6). A cold + * install verifies in full. */ export async function ensurePinnedRelayRuntime( context: PinnedInstallContext & { plan: PinnedRelayPlan }, relayAlreadyInstalled: boolean ): Promise { const { conn, host, remoteRelayDir, plan, signal, run } = context - if (relayAlreadyInstalled) { - const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) - const present = await execCommand(conn, remoteNodeRuntimePresentCommand(host, runtimeDir), { - signal, - wrapCommand: !isWindowsRemoteHost(host) - }) - if (present.trim() === REMOTE_NODE_RUNTIME_READY) { - if (run && run.runtimeTransfer === 'none') { - run.runtimeTransfer = 'cached' - } - return - } - } try { + if (relayAlreadyInstalled) { + const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) + // Why run it here: exec policy or a library can change after the install was verified. + const present = await execCommand( + conn, + remoteNodeRuntimePresentCommand(host, runtimeDir, true), + { signal, wrapCommand: !isWindowsRemoteHost(host) } + ) + if (present.trim() === REMOTE_NODE_RUNTIME_READY) { + if (run && run.runtimeTransfer === 'none') { + run.runtimeTransfer = 'cached' + } + return + } + if (present.includes(REMOTE_NODE_RUNTIME_SELFTEST_FAILED)) { + assertRemoteNodeRuntimePromoted(present) + } + } const { transfer } = await ensureRemoteOrcadNodeRuntime({ conn, host, @@ -189,6 +200,8 @@ export async function verifyPinnedRelayInstall(context: PinnedInstallContext): P `The relay runtime self-test at ${remoteRelayDir} is unverifiable; retrying on the next connect: ${verdict.detail}` ) case 'failed': - throw new Error(`The relay runtime self-test at ${remoteRelayDir} failed: ${verdict.detail}`) + throw new RelayHostAnsweredError( + `The relay runtime self-test at ${remoteRelayDir} failed: ${verdict.detail}` + ) } } diff --git a/src/main/ssh/ssh-relay-pinned-node.test.ts b/src/main/ssh/ssh-relay-pinned-node.test.ts index 17cca644fe8..d7367578ef2 100644 --- a/src/main/ssh/ssh-relay-pinned-node.test.ts +++ b/src/main/ssh/ssh-relay-pinned-node.test.ts @@ -20,13 +20,16 @@ import { pinnedRelayAddonFiles, pinnedRelayNodePath, planPinnedNodeRelay, - recordPinnedRuntimeRefusal, RELAY_RUNTIME_REF_PREFIX, - resetPinnedRuntimeRefusalsForTests, resolveSshRemoteRuntime, SSH_REMOTE_RUNTIME_ENV, stagePinnedRelayAddons } from './ssh-relay-pinned-node' +import { + MUTABLE_PINNED_REFUSAL_REPLAY_MS, + recordPinnedRuntimeRefusal, + resetPinnedRuntimeRefusalsForTests +} from './ssh-relay-pinned-refusal-cache' import { getRemoteHostPlatform } from './ssh-remote-platform' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) @@ -65,37 +68,23 @@ afterEach(() => { }) describe('remote runtime setting', () => { - it('defaults to legacy, lets the host setting win, and accepts only known env values', () => { - expect(resolveSshRemoteRuntime(undefined, {})).toBe('legacy') - expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'pinned-node' })).toBe( - 'pinned-node' - ) - expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'bun' })).toBe('legacy') + it('defaults to the pinned ladder, lets the host setting win, and accepts only known env values', () => { + expect(resolveSshRemoteRuntime(undefined, {})).toBe('pinned-node') + expect(resolveSshRemoteRuntime({}, {})).toBe('pinned-node') + expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'legacy' })).toBe('legacy') + expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'bun' })).toBe('pinned-node') expect( resolveSshRemoteRuntime( { remoteRuntime: 'legacy' }, { [SSH_REMOTE_RUNTIME_ENV]: 'pinned-node' } ) ).toBe('legacy') - }) - - it('runs the pinned ladder where managed orcad was found unable to run, unless the host opts out', () => { - const unavailable = { reason: 'runtime_self_test', appVersion: '1.0.0' } - expect(resolveSshRemoteRuntime({ managedServerUnavailable: unavailable }, {})).toBe( - 'pinned-node' - ) expect( resolveSshRemoteRuntime( - { managedServerUnavailable: unavailable, remoteRuntime: 'legacy' }, - {} - ) - ).toBe('legacy') - expect( - resolveSshRemoteRuntime( - { managedServerUnavailable: unavailable }, + { remoteRuntime: 'pinned-node' }, { [SSH_REMOTE_RUNTIME_ENV]: 'legacy' } ) - ).toBe('legacy') + ).toBe('pinned-node') }) }) @@ -307,6 +296,46 @@ describe('planPinnedNodeRelay', () => { expect(materializeOrcad).not.toHaveBeenCalled() }) + it('re-proves a repaired Windows host once a security-software refusal has aged out', async () => { + vi.useFakeTimers() + try { + recordPinnedRuntimeRefusal('t', 'win32-x64', 'security_software') + const plan = (): ReturnType => + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('win32-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad: async (target) => fakeOrcadSlot(target) + }) + await expect(plan()).resolves.toMatchObject({ remembered: true }) + vi.advanceTimersByTime(MUTABLE_PINNED_REFUSAL_REPLAY_MS) + await expect(plan()).resolves.toMatchObject({ kind: 'pinned-node', target: 'win32-x64' }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps replaying an immutable refusal past the mutable window', async () => { + vi.useFakeTimers() + try { + recordPinnedRuntimeRefusal('t', 'linux-x64-glibc', 'illegal_instruction') + vi.advanceTimersByTime(MUTABLE_PINNED_REFUSAL_REPLAY_MS * 10) + vi.mocked(execCommand).mockResolvedValue('ldd (GNU libc) 2.31') + await expect( + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('linux-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad: vi.fn() + }) + ).resolves.toMatchObject({ fallbackReason: 'illegal_instruction', remembered: true }) + } finally { + vi.useRealTimers() + } + }) + it('plans a pinned relay with a runtime-folded version for a supported host', async () => { vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.28') const plan = await planPinnedNodeRelay({ diff --git a/src/main/ssh/ssh-relay-pinned-node.ts b/src/main/ssh/ssh-relay-pinned-node.ts index def3d8b36fa..a7d7095b339 100644 --- a/src/main/ssh/ssh-relay-pinned-node.ts +++ b/src/main/ssh/ssh-relay-pinned-node.ts @@ -2,9 +2,9 @@ * Plan a relay launch on Orca's pinned Node with the orcad slot's prebuilt addons, instead * of the host's Node plus a host-side npm install (design D5, D6 rung A, D8.1). * - * Opt-in per host (`SshTarget.remoteRuntime`), and on by default where managed orcad can't run. + * The default for every relay connect; a host opts out with `SshTarget.remoteRuntime: 'legacy'`. * Anything this module cannot establish on the client, and every classified refusal from the - * host, falls back to the legacy host-Node path with a logged reason. + * host, steps down the ladder with a logged reason. */ import { createHash } from 'node:crypto' import { chmod, copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' @@ -42,6 +42,10 @@ import { remoteNodeRuntimeDir } from './orcad-remote-node-runtime' import { fileSha256, materializeNodeRuntimeArchive } from './pinned-runtime-materializer' import type { SshConnection } from './ssh-connection' import { PINNED_RUNTIME_REFUSALS, type PinnedRuntimeRefusal } from './ssh-relay-runtime-self-test' +import { + recordPinnedRuntimeRefusal, + rememberedPinnedRuntimeRefusal +} from './ssh-relay-pinned-refusal-cache' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' /** Content-keyed like ripgrep's refs, so runtime GC can find holders without a new concept (D5). */ @@ -52,25 +56,14 @@ export const PINNED_NODE_GLIBC_FLOOR: GlibcVersion = { major: 2, minor: 28 } export const SSH_REMOTE_RUNTIME_ENV = 'ORCA_SSH_REMOTE_RUNTIME' export function resolveSshRemoteRuntime( - target: Pick | undefined, + target: Pick | undefined, env: NodeJS.ProcessEnv = process.env ): SshRemoteRuntime { if (target?.remoteRuntime) { return target.remoteRuntime } const fromEnv = SSH_REMOTE_RUNTIMES.find((runtime) => runtime === env[SSH_REMOTE_RUNTIME_ENV]) - // Why: where managed orcad can't run the relay is the host's server; the ladder still ends at legacy. - return fromEnv ?? (target?.managedServerUnavailable ? 'pinned-node' : DEFAULT_SSH_REMOTE_RUNTIME) -} - -/** Why `recorded`: this connect's host-server decision may have just recorded orcad unavailable. */ -export function resolveConnectRemoteRuntime( - target: SshTarget | undefined, - recorded: SshTarget | undefined -): SshRemoteRuntime { - const managedServerUnavailable = - recorded?.managedServerUnavailable ?? target?.managedServerUnavailable - return resolveSshRemoteRuntime(target && { ...target, managedServerUnavailable }) + return fromEnv ?? DEFAULT_SSH_REMOTE_RUNTIME } export function isGlibcBelow(version: GlibcVersion, floor: GlibcVersion): boolean { @@ -192,6 +185,8 @@ export type RelayRuntimeFallbackReason = | 'runtime_unavailable' /** Rung C found no host Node >= 18 with the addons' N-API level. */ | 'host_node_missing' + /** The host answered a rung's install, self-test or launch with an unclassified failure. */ + | 'install_failed' export type HostNodeRelayPlan = { kind: 'host-node' @@ -217,35 +212,11 @@ export function isPinnedRuntimeRefusal(reason: string): reason is PinnedRuntimeR return PINNED_RUNTIME_REFUSALS.some((refusal) => refusal === reason) } -// Why also in memory: the persisted decision is written only once the ladder settles. -const refusals = new Map() - -function refusalKey(targetId: string, target: NodeRuntimeTarget): string { - return `${targetId}\0${pinnedNodeRuntimeAsset(target).executableSha256}` -} - -export function recordPinnedRuntimeRefusal( - targetId: string, - target: NodeRuntimeTarget, - refusal: PinnedRuntimeRefusal -): void { - refusals.set(refusalKey(targetId, target), refusal) -} - -/** Forgets a refusal a later rung has disproved, so the next connect retries rung A. */ -export function forgetPinnedRuntimeRefusal(targetId: string, target: NodeRuntimeTarget): void { - refusals.delete(refusalKey(targetId, target)) -} - -export function resetPinnedRuntimeRefusalsForTests(): void { - refusals.clear() -} - export function logPinnedRelayFallback( reason: RelayRuntimeFallbackReason, detail: string ): HostNodeRelayPlan { - console.warn(`[ssh-relay] Pinned Node relay unavailable (${reason}): ${detail}; using host Node`) + console.warn(`[ssh-relay] Pinned Node relay unavailable (${reason}): ${detail}`) return { kind: 'host-node', fallbackReason: reason } } @@ -293,7 +264,7 @@ export async function planPinnedNodeRelay(options: { const { glibc } = facts const { compat } = options const target: NodeRuntimeTarget = compat?.target ?? facts.target - const cached = refusals.get(refusalKey(options.targetId, target)) + const cached = rememberedPinnedRuntimeRefusal(options.targetId, target) if (cached) { return { ...logPinnedRelayFallback(cached, 'refused earlier this session'), remembered: true } } diff --git a/src/main/ssh/ssh-relay-pinned-refusal-cache.ts b/src/main/ssh/ssh-relay-pinned-refusal-cache.ts new file mode 100644 index 00000000000..b4d8840a7a7 --- /dev/null +++ b/src/main/ssh/ssh-relay-pinned-refusal-cache.ts @@ -0,0 +1,78 @@ +/** Pinned-runtime refusals remembered for this session, keyed by host and runtime, and their expiry. */ +import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin' +import type { PinnedRuntimeRefusal } from './ssh-relay-runtime-self-test' + +/** + * Refusals the host can lose without Orca changing: a library installed, AV or application + * control relaxed. They replay for a day, persisted too, so a blocked host isn't re-uploaded to + * on every connect yet a repaired one gets rung A back. POSIX noexec is left out: rung C re-proves + * it and D forgets a replayed one; Windows has neither, so there it expires too. + */ +const MUTABLE_PINNED_RUNTIME_REFUSALS: ReadonlySet = new Set([ + 'missing_lib', + 'security_software' +]) +export const MUTABLE_PINNED_REFUSAL_REPLAY_MS = 24 * 60 * 60_000 + +export function isMutablePinnedRuntimeRefusal( + refusal: PinnedRuntimeRefusal, + target: NodeRuntimeTarget +): boolean { + return ( + MUTABLE_PINNED_RUNTIME_REFUSALS.has(refusal) || + (refusal === 'noexec' && target.startsWith('win32-')) + ) +} + +/** A mutable refusal with no proof time (an older record) is stale, so the host is re-proved. */ +export function isPinnedRefusalExpired( + refusal: PinnedRuntimeRefusal, + target: NodeRuntimeTarget, + provedAt: number | undefined, + now = Date.now() +): boolean { + return ( + isMutablePinnedRuntimeRefusal(refusal, target) && + (provedAt === undefined || now - provedAt >= MUTABLE_PINNED_REFUSAL_REPLAY_MS) + ) +} + +// Why also in memory: the persisted decision is written only once the ladder settles. +const refusals = new Map() + +function refusalKey(targetId: string, target: NodeRuntimeTarget): string { + return `${targetId}\0${pinnedNodeRuntimeAsset(target).executableSha256}` +} + +export function recordPinnedRuntimeRefusal( + targetId: string, + target: NodeRuntimeTarget, + refusal: PinnedRuntimeRefusal +): void { + refusals.set(refusalKey(targetId, target), { refusal, at: Date.now() }) +} + +export function rememberedPinnedRuntimeRefusal( + targetId: string, + target: NodeRuntimeTarget +): PinnedRuntimeRefusal | null { + const key = refusalKey(targetId, target) + const entry = refusals.get(key) + if (!entry) { + return null + } + if (isPinnedRefusalExpired(entry.refusal, target, entry.at)) { + refusals.delete(key) + return null + } + return entry.refusal +} + +/** Forgets a refusal a later rung has disproved, so the next connect retries rung A. */ +export function forgetPinnedRuntimeRefusal(targetId: string, target: NodeRuntimeTarget): void { + refusals.delete(refusalKey(targetId, target)) +} + +export function resetPinnedRuntimeRefusalsForTests(): void { + refusals.clear() +} diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts index f7fd4d65794..7e2387e0850 100644 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('./ssh-relay-opencode-runtime', () => ({ @@ -109,8 +109,11 @@ describe('relay pty fd-leak patch on the install path', () => { execCallCountAtWrite: {} } + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') sftpCapture.paths.length = 0 vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') diff --git a/src/main/ssh/ssh-relay-runtime-ladder.test.ts b/src/main/ssh/ssh-relay-runtime-ladder.test.ts index 4415786c0e8..23fe1d6b15b 100644 --- a/src/main/ssh/ssh-relay-runtime-ladder.test.ts +++ b/src/main/ssh/ssh-relay-runtime-ladder.test.ts @@ -2,44 +2,66 @@ import { describe, expect, it } from 'vitest' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS } from '../../shared/node-runtime-pin' import { compatRelayRuntimeFor, - nextRelayRuntimeStep, pinnedRuntimeTargetForHost, relayRuntimeLadder, + relayRuntimeStepAfterRefusal, relayRuntimeStorePins, - remoteRuntimeUnavailableMessage, - remoteRuntimeUnavailableReason, + remoteRuntimeUnavailable, + type RemoteRuntimeUnavailableState, rungBCompatRuntimeFor } from './ssh-relay-runtime-ladder' +import { resolveSshRemoteRuntime } from './ssh-relay-pinned-node' const COMPAT_SHA = NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256 describe('relay runtime ladder (design D6)', () => { - it('keeps the host-npm path alone for Auto and Host Node, and the full ladder for Orca-managed Node', () => { + it('keeps the host-npm path alone for Host Node, and before D in the Auto ladder', () => { + const auto = ['A', 'B', 'C', 'legacy', 'D'] expect(relayRuntimeLadder('legacy')).toEqual(['legacy']) - expect(relayRuntimeLadder('pinned-node')).toEqual(['A', 'B', 'C', 'legacy', 'D']) + expect(relayRuntimeLadder('pinned-node')).toEqual(auto) + expect(relayRuntimeLadder(resolveSshRemoteRuntime(undefined, {}))).toEqual(auto) }) it('steps to the next rung on an ordinary refusal', () => { const ladder = relayRuntimeLadder('pinned-node') - expect(nextRelayRuntimeStep(ladder, 'A', 'libc_floor')).toBe('B') - expect(nextRelayRuntimeStep(ladder, 'B', 'runtime_unavailable')).toBe('C') - expect(nextRelayRuntimeStep(ladder, 'C', 'missing_lib')).toBe('legacy') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'libc_floor', false)).toBe('B') + expect(relayRuntimeStepAfterRefusal(ladder, 'B', 'runtime_unavailable', false)).toBe('C') }) - it('goes straight to D on noexec, which defeats every rung in the same tree', () => { + it('walks a Windows host from a refused A through B and C, which refuse there, to host Node', () => { const ladder = relayRuntimeLadder('pinned-node') - expect(nextRelayRuntimeStep(ladder, 'A', 'noexec')).toBe('D') - expect(nextRelayRuntimeStep(ladder, 'C', 'noexec')).toBe('D') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'security_software', false)).toBe('B') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'missing_lib', true)).toBe('B') + expect(relayRuntimeStepAfterRefusal(ladder, 'B', 'runtime_unavailable', false)).toBe('C') + expect(relayRuntimeStepAfterRefusal(ladder, 'C', 'windows_host_unsupported', false)).toBe( + 'legacy' + ) }) it('lets a remembered noexec skip only its own rung, so a remounted home is re-proved', () => { const ladder = relayRuntimeLadder('pinned-node') - expect(nextRelayRuntimeStep(ladder, 'A', 'noexec', true)).toBe('B') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'noexec', true)).toBe('B') }) - it('skips the npm path when rung C found no host Node, since npm needs one too', () => { + it('falls back past a refused ladder to host Node, and to D only on proof', () => { const ladder = relayRuntimeLadder('pinned-node') - expect(nextRelayRuntimeStep(ladder, 'C', 'host_node_missing')).toBe('D') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'artifacts_unavailable', false)).toBe('B') + for (const reason of [ + 'artifacts_unavailable', + 'host_node_missing', + 'libc_floor', + 'target_unresolved' + ] as const) { + expect(relayRuntimeStepAfterRefusal(ladder, 'C', reason, false)).toBe('legacy') + } + // A noexec skips B and C (same tree) but still gets the fallback: exec denial can be per binary. + expect(relayRuntimeStepAfterRefusal(ladder, 'C', 'noexec', false)).toBe('legacy') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'noexec', false)).toBe('legacy') + expect(relayRuntimeStepAfterRefusal(ladder, 'A', 'install_failed', false)).toBe('B') + // Only the fallback itself proving no host Node lands on D. + for (const reason of ['host_node_missing', 'install_failed', 'noexec'] as const) { + expect(relayRuntimeStepAfterRefusal(ladder, 'legacy', reason, false)).toBe('D') + } }) it('chooses rung B only when a listed compat runtime serves the host', () => { @@ -113,19 +135,73 @@ describe('relay runtime ladder (design D6)', () => { ) }) + const linuxD: RemoteRuntimeUnavailableState = { + firstRefusal: 'missing_lib', + hostNodeRefusal: 'host_node_missing', + noexec: null, + hostOs: 'linux' + } + it('names the rung D reason in words the user can act on', () => { - expect(remoteRuntimeUnavailableReason('noexec')).toBe('home_noexec') - expect(remoteRuntimeUnavailableReason('host_node_missing')).toBe('no_runtime') - expect(remoteRuntimeUnavailableMessage('home_noexec', 'noexec')).toContain('mounted noexec') - expect(remoteRuntimeUnavailableMessage('no_runtime', 'host_node_missing')).toContain( - 'Install Node.js 18+' + expect(remoteRuntimeUnavailable(linuxD)).toEqual({ + reason: 'no_runtime', + message: expect.stringContaining( + "Install Node.js 18+ and npm on the host, then reconnect. (Orca's Node: missing_lib)" + ) + }) + const noexec = remoteRuntimeUnavailable({ ...linuxD, firstRefusal: 'noexec', noexec: 'proved' }) + expect(noexec.reason).toBe('home_noexec') + expect(noexec.message).toContain('mounted noexec') + expect(noexec.message).not.toContain("Orca's Node:") + expect(remoteRuntimeUnavailable({ ...linuxD, firstRefusal: null }).message).not.toContain( + "Orca's Node:" ) }) it('never advises installing Node when a remembered noexec defeated the tree', () => { - expect(remoteRuntimeUnavailableReason('host_node_missing', true)).toBe('home_noexec') - const message = remoteRuntimeUnavailableMessage('home_noexec', 'noexec', true) + const { reason, message } = remoteRuntimeUnavailable({ ...linuxD, noexec: 'remembered' }) + expect(reason).toBe('home_noexec') expect(message).toContain('earlier connect found the home directory') expect(message).not.toContain('Install Node.js') }) + + it('blames this copy of Orca when its artifacts were missing and the host has no Node', () => { + const { message } = remoteRuntimeUnavailable({ + ...linuxD, + firstRefusal: 'artifacts_unavailable' + }) + expect(message).toContain('this copy of Orca could not prepare its bundled') + expect(message).toContain("(Orca's Node: artifacts_unavailable)") + }) + + it('reports a failed host-Node fallback install unless noexec explains it', () => { + const failed = { ...linuxD, hostNodeRefusal: 'install_failed' as const } + expect(remoteRuntimeUnavailable(failed).message).toContain( + "also failed to install. Check the host's disk space" + ) + expect(remoteRuntimeUnavailable({ ...failed, firstRefusal: null }).message).toContain( + "(Orca's Node: none)" + ) + expect(remoteRuntimeUnavailable({ ...failed, noexec: 'proved' }).message).toContain( + 'mounted noexec' + ) + const windows = remoteRuntimeUnavailable({ ...failed, hostOs: 'win32', noexec: 'proved' }) + expect(windows).toMatchObject({ reason: 'no_runtime' }) + expect(windows.message).toContain('also failed to install') + }) + + it('words a Windows rung D for Windows, never as a noexec mount', () => { + const { reason, message } = remoteRuntimeUnavailable({ + ...linuxD, + firstRefusal: 'noexec', + noexec: 'proved', + hostOs: 'win32' + }) + expect(reason).toBe('no_runtime') + expect(message).toContain('Windows host') + expect(message).toContain("Orca's Node.js through security software") + expect(message).toContain("(Orca's Node: noexec)") + expect(message).not.toContain('mounted') + expect(message).not.toContain('~/.orca-remote') + }) }) diff --git a/src/main/ssh/ssh-relay-runtime-ladder.ts b/src/main/ssh/ssh-relay-runtime-ladder.ts index 0a6ae2deab2..c75b7a078f9 100644 --- a/src/main/ssh/ssh-relay-runtime-ladder.ts +++ b/src/main/ssh/ssh-relay-runtime-ladder.ts @@ -1,14 +1,15 @@ /** * The design D6 fallback ladder for the relay runtime, as data plus a pure step function: * - * A Orca's pinned Node + slot prebuilds - * B a compat pinned Node + compat addons (chosen only when a compat runtime exists) - * C the host's Node >= 18 + Orca's N-API prebuilds, no npm - * legacy the host's Node + npm install (kept until the default flips) - * D nothing runs: plain SSH terminals and SFTP, recording the classified reason + * A Orca's pinned Node + slot prebuilds + * B a compat pinned Node + compat addons (chosen only when a compat runtime exists) + * C the host's Node >= 18 + Orca's N-API prebuilds, no npm + * legacy the host's Node + npm install, unsupported; the only rung when the user opts in + * D nothing runs: plain SSH terminals and SFTP, recording the classified reason * - * The ladder steps down only on a classified refusal (a `PinnedRelayFallbackError`); an - * unverifiable probe or self-test throws and the next connect retries the same rung. + * The ladder steps down on a classified refusal (a `PinnedRelayFallbackError`), including an + * answered host failure the deploy wraps as `install_failed`; an unverifiable probe or self-test + * throws and the next connect retries the same rung. */ import { COMPAT_SERVER_TARGET_BASES, @@ -20,6 +21,7 @@ import { } from '../../shared/node-runtime-pin' import type { SshRemoteRuntime, SshRemoteRuntimeRung } from '../../shared/ssh-types' import type { GlibcVersion } from './orcad-deployment-target' +import type { RemoteOperatingSystem } from './ssh-remote-platform' import { isGlibcBelow, PINNED_NODE_GLIBC_FLOOR, @@ -49,6 +51,7 @@ export const COMPAT_RELAY_RUNTIMES: readonly CompatRelayRuntime[] = [ ] export function relayRuntimeLadder(runtime: SshRemoteRuntime): readonly RelayRuntimeStep[] { + // Why legacy before D: the unsupported host-npm fallback keeps the never-worse invariant. return runtime === 'pinned-node' ? ['A', 'B', 'C', 'legacy', 'D'] : ['legacy'] } @@ -120,35 +123,30 @@ export function relayRuntimeStorePins( export type RelayRuntimeStepReason = RelayRuntimeFallbackReason /** - * noexec defeats every rung, because each loads addons from the same `~/.orca-remote` tree; - * no host Node rules out the npm path too, since it needs a host Node as well. A remembered - * refusal only skips its own rung: the mount may have changed since it was proved. + * The invariant: no host does worse than the pre-ladder default. A, B and C are tried first + * (no host compile where Orca's runtime works); any refusal past them falls back to exactly + * that default, the host-Node relay (`legacy`, marked unsupported). D is reached only when that + * fallback itself answered with a failure, so D is a superset of the default's outcome. Windows + * reaches legacy because B and C refuse there before any host I/O. */ -export function nextRelayRuntimeStep( +export function relayRuntimeStepAfterRefusal( ladder: readonly RelayRuntimeStep[], current: RelayRuntimeStep, reason: RelayRuntimeStepReason, - remembered = false + remembered: boolean ): RelayRuntimeStep { - if ((reason === 'noexec' && !remembered) || (current === 'C' && reason === 'host_node_missing')) { - return 'D' + // Why noexec skips B and C: they load addons from the same tree; only the fallback can disprove it. + // A remembered noexec only skips its own rung: the mount may have changed since it was proved. + if (reason === 'noexec' && !remembered && current !== 'legacy') { + return 'legacy' } - const index = ladder.indexOf(current) - return ladder[index + 1] ?? 'D' + return ladder[ladder.indexOf(current) + 1] ?? 'D' } /** The machine-readable part of a rung D failure; the message is what the user reads. */ export const REMOTE_RUNTIME_UNAVAILABLE_REASONS = ['home_noexec', 'no_runtime'] as const export type RemoteRuntimeUnavailableReason = (typeof REMOTE_RUNTIME_UNAVAILABLE_REASONS)[number] -/** A noexec seen anywhere in the pass, remembered or proved, rules out advising a host Node. */ -export function remoteRuntimeUnavailableReason( - lastReason: RelayRuntimeStepReason | null, - noexecSeen = false -): RemoteRuntimeUnavailableReason { - return lastReason === 'noexec' || noexecSeen ? 'home_noexec' : 'no_runtime' -} - const REMOTE_RUNTIME_UNAVAILABLE_MESSAGES: Record = { home_noexec: "Orca can't run its remote runtime on this host: the home directory is mounted noexec, so " + @@ -156,7 +154,8 @@ const REMOTE_RUNTIME_UNAVAILABLE_MESSAGES: Record = { relay: 2, plain_ssh: 1, failed: 0 } + +/** Before the ladder: every connect ran the host-Node relay, and any failure failed the connect. */ +function baseOutcome(host: HostCase): Outcome { + return host.hostNode === 'launch' ? { kind: 'relay', rung: 'legacy' } : { kind: 'failed' } +} + +/** The deploy loop: answered refusals step on; a launch failure propagates. */ +function ladderOutcome(host: HostCase): Outcome { + const ladder = relayRuntimeLadder('pinned-node') + let step: RelayRuntimeStep = ladder[0]! + for (let guard = 0; guard < 10; guard++) { + let answer: RungAnswer + switch (step) { + case 'D': + return { kind: 'plain_ssh' } + case 'legacy': + if (host.hostNode === 'launch') { + return { kind: 'relay', rung: 'legacy' } + } + if (host.hostNode === 'launch_fails') { + return { kind: 'failed' } + } + answer = host.hostNode === 'no_node' ? 'host_node_missing' : 'install_failed' + break + case 'A': + answer = host.a + break + case 'B': + answer = host.os === 'win32' ? 'runtime_unavailable' : host.b + break + case 'C': + answer = host.os === 'win32' ? 'windows_host_unsupported' : host.c + break + } + if (answer === 'launch') { + return { kind: 'relay', rung: step } + } + const remembered = step === 'A' ? host.rememberedA : step === 'B' ? host.rememberedB : false + step = relayRuntimeStepAfterRefusal(ladder, step, answer, remembered) + } + throw new Error(`ladder did not settle for ${host.id}`) +} + +const A_ANSWERS: readonly RungAnswer[] = [ + 'launch', + 'noexec', + 'missing_lib', + 'libc_floor', + 'illegal_instruction', + 'wrong_libc', + 'security_software', + 'target_unresolved', + 'artifacts_unavailable', + 'install_failed' +] +const B_ANSWERS: readonly RungAnswer[] = [ + 'launch', + 'noexec', + 'runtime_unavailable', + 'install_failed' +] +const C_ANSWERS: readonly RungAnswer[] = [ + 'launch', + 'host_node_missing', + 'noexec', + 'libc_floor', + 'artifacts_unavailable', + 'target_unresolved', + 'install_failed' +] +const HOST_NODE_ANSWERS: readonly HostNodeAnswer[] = [ + 'launch', + 'no_node', + 'install_fails', + 'launch_fails' +] +const OSES: readonly RemoteOperatingSystem[] = ['linux', 'win32'] +const REMEMBERED = [false, true] as const +const replayTag = (remembered: boolean): string => (remembered ? '(remembered)' : '') + +const CROSS_PRODUCT: HostCase[] = OSES.flatMap((os) => + A_ANSWERS.flatMap((a) => + B_ANSWERS.flatMap((b) => + C_ANSWERS.flatMap((c) => + HOST_NODE_ANSWERS.flatMap((hostNode) => + REMEMBERED.flatMap((rememberedA) => + REMEMBERED.map((rememberedB) => ({ + id: `${os} A:${a}${replayTag(rememberedA)} B:${b}${replayTag(rememberedB)} C:${c} host:${hostNode}`, + os, + a, + b, + c, + hostNode, + rememberedA, + rememberedB + })) + ) + ) + ) + ) + ) +) + +function violations(host: HostCase): string[] { + const base = baseOutcome(host) + const ladder = ladderOutcome(host) + const found: string[] = [] + if (RANK[ladder.kind] < RANK[base.kind]) { + found.push(`${host.id}: ${ladder.kind} is worse than base ${base.kind}`) + } + if (host.a === 'launch' && ladder.kind === 'relay' && ladder.rung !== 'A') { + found.push(`${host.id}: compiled on the host though rung A runs`) + } + // D only when the host-Node default itself answered with a failure. + if (ladder.kind === 'plain_ssh' && !['no_node', 'install_fails'].includes(host.hostNode)) { + found.push(`${host.id}: plain SSH without the default failing`) + } + return found +} + +describe('relay runtime ladder vs the pre-ladder host-Node default', () => { + it(`is never worse across all ${CROSS_PRODUCT.length} host combinations`, () => { + expect(CROSS_PRODUCT.flatMap(violations)).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-resolution.test.ts b/src/main/ssh/ssh-relay-runtime-resolution.test.ts index 98e225306e0..ac04575ea95 100644 --- a/src/main/ssh/ssh-relay-runtime-resolution.test.ts +++ b/src/main/ssh/ssh-relay-runtime-resolution.test.ts @@ -27,7 +27,7 @@ function memoryStore(): RelayRuntimeDecisionStore & { value?: SshRemoteRuntimeRe } function rememberedNoexecRun(store: RelayRuntimeDecisionStore): RelayRuntimeLadderRun { - const run = new RelayRuntimeLadderRun('ssh-1', store) + const run = new RelayRuntimeLadderRun('ssh-1', store, true) run.host = getRemoteHostPlatform('linux-x64') run.facts = facts run.refused('A', 'noexec') @@ -40,7 +40,7 @@ describe('RelayRuntimeLadderRun', () => { }) it('reports a rung whose self-test was unverifiable or failed, and nothing else', () => { - const run = new RelayRuntimeLadderRun('ssh-1', null) + const run = new RelayRuntimeLadderRun('ssh-1', null, true) run.host = getRemoteHostPlatform('linux-x64') run.facts = facts run.unresolved('A') @@ -63,7 +63,7 @@ describe('RelayRuntimeLadderRun', () => { const store = memoryStore() rememberedNoexecRun(store).settle('D') expect(store.value).toMatchObject({ rung: 'D', pinnedRefusal: 'noexec', glibc: '2.31' }) - const replay = new RelayRuntimeLadderRun('ssh-1', store) + const replay = new RelayRuntimeLadderRun('ssh-1', store, true) expect(replay.persistedPinnedRefusal(facts)).toBe('noexec') expect(replay.persistedPinnedRefusal({ ...facts, glibc: { major: 2, minor: 35 } })).toBeNull() }) @@ -85,7 +85,7 @@ describe('RelayRuntimeLadderRun', () => { it('drops a replayed noexec at rung D, so a remounted home is re-proved next connect', () => { const store = memoryStore() - const run = new RelayRuntimeLadderRun('ssh-1', store) + const run = new RelayRuntimeLadderRun('ssh-1', store, true) run.host = getRemoteHostPlatform('linux-x64') run.facts = facts run.refused('A', 'noexec', true) @@ -97,7 +97,7 @@ describe('RelayRuntimeLadderRun', () => { }) it('reports a remembered noexec at rung D without advising a host Node install', () => { - const run = new RelayRuntimeLadderRun('ssh-1', null) + const run = new RelayRuntimeLadderRun('ssh-1', null, true) run.host = getRemoteHostPlatform('linux-x64') run.facts = facts run.refused('A', 'noexec', true) @@ -109,8 +109,16 @@ describe('RelayRuntimeLadderRun', () => { expect(error).toMatchObject({ data: { reason: 'home_noexec' } }) }) + it('keeps a replayed noexec remembered when a later rung proves noexec again', () => { + const run = new RelayRuntimeLadderRun('ssh-1', null, true) + run.refused('A', 'noexec', true) + run.refused('legacy', 'noexec') + expect(run.noexec).toBe('remembered') + expect(remoteRuntimeUnavailableError(run).message).toContain('earlier connect found') + }) + it('still advises a host Node when rung A refused for another reason', () => { - const run = new RelayRuntimeLadderRun('ssh-1', null) + const run = new RelayRuntimeLadderRun('ssh-1', null, true) run.refused('A', 'libc_floor') run.refused('C', 'host_node_missing') expect(remoteRuntimeUnavailableError(run).message).toContain('Install Node.js 18+') diff --git a/src/main/ssh/ssh-relay-runtime-resolution.ts b/src/main/ssh/ssh-relay-runtime-resolution.ts index 19fcf28c4a9..b469531ef4c 100644 --- a/src/main/ssh/ssh-relay-runtime-resolution.ts +++ b/src/main/ssh/ssh-relay-runtime-resolution.ts @@ -11,14 +11,13 @@ import type { } from '../../shared/ssh-types' import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' import type { OrcadDeploymentTargetFacts } from './orcad-deployment-target' +import { isPinnedRuntimeRefusal, type RelayRuntimeFallbackReason } from './ssh-relay-pinned-node' import { forgetPinnedRuntimeRefusal, - isPinnedRuntimeRefusal, - type RelayRuntimeFallbackReason -} from './ssh-relay-pinned-node' + isPinnedRefusalExpired +} from './ssh-relay-pinned-refusal-cache' import { - remoteRuntimeUnavailableMessage, - remoteRuntimeUnavailableReason, + remoteRuntimeUnavailable, type RelayRuntimeStep, type RemoteRuntimeUnavailableReason } from './ssh-relay-runtime-ladder' @@ -71,7 +70,11 @@ export function persistedPinnedRefusal( if (!decision?.pinnedRefusal || !sameKey(decision, relayRuntimeDecisionKey(facts))) { return null } - return isPinnedRuntimeRefusal(decision.pinnedRefusal) ? decision.pinnedRefusal : null + const refusal = decision.pinnedRefusal + return isPinnedRuntimeRefusal(refusal) && + !isPinnedRefusalExpired(refusal, facts.target, decision.refusedAt) + ? refusal + : null } export class RelayRuntimeLadderRun { @@ -81,15 +84,25 @@ export class RelayRuntimeLadderRun { firstRefusal: RelayRuntimeFallbackReason | null = null lastRefusal: RelayRuntimeFallbackReason | null = null pinnedRefusal: PinnedRuntimeRefusal | null = null - /** A noexec this pass replayed from a cache rather than proved. */ - noexecRemembered = false + /** The pinned refusal was replayed from a cache rather than proved on this pass. */ + pinnedRefusalRemembered = false + /** A noexec this pass replayed from a cache, else proved; a replay is never downgraded. */ + noexec: 'remembered' | 'proved' | null = null + /** A rung refused because this client lacked Orca's artifacts; nothing it proves is the host's. */ + clientArtifactGap = false + /** The Node the fallback's strict probe found, reused by the launch. */ + hostNodePath: string | null = null + /** This step issued its relay launch; failures after it never step down. */ + launchStarted = false selfTest: RelayRuntimeSelfTestOutcome = 'not_run' runtimeTransfer: RelayRuntimeTransfer = 'none' hostNode: HostNodeVersion | null = null constructor( readonly targetId: string, - private readonly store: RelayRuntimeDecisionStore | null + private readonly store: RelayRuntimeDecisionStore | null, + /** A laddered legacy step is the host-Node fallback, so a host with no Node lands on D. */ + readonly laddered: boolean ) {} persistedPinnedRefusal(facts: OrcadDeploymentTargetFacts): PinnedRuntimeRefusal | null { @@ -99,11 +112,15 @@ export class RelayRuntimeLadderRun { refused(step: RelayRuntimeStep, reason: RelayRuntimeFallbackReason, remembered = false): void { this.firstRefusal ??= reason this.lastRefusal = reason - if (reason === 'noexec' && remembered) { - this.noexecRemembered = true + if (reason === 'artifacts_unavailable') { + this.clientArtifactGap = true + } + if (reason === 'noexec') { + this.noexec = remembered ? 'remembered' : (this.noexec ?? 'proved') } if (step === 'A' && isPinnedRuntimeRefusal(reason)) { this.pinnedRefusal = reason + this.pinnedRefusalRemembered = remembered } } @@ -119,14 +136,17 @@ export class RelayRuntimeLadderRun { // Why also at D on a replayed noexec: nothing connects there, so the next connect re-proves A // instead of the message's "allow exec" advice being unfixable. const disproved = rung === 'C' && this.selfTest === 'passed' - const replayedAtD = rung === 'D' && this.noexecRemembered + const replayedAtD = rung === 'D' && this.noexec === 'remembered' if ((disproved || replayedAtD) && this.pinnedRefusal === 'noexec') { this.pinnedRefusal = null if (this.facts) { forgetPinnedRuntimeRefusal(this.targetId, this.facts.target) } } - this.persist(rung) + // Why: a pass shaped by this client's missing artifacts is no decision about the host. + if (!this.clientArtifactGap) { + this.persist(rung) + } this.track(rung, 'resolved') } @@ -162,12 +182,18 @@ export class RelayRuntimeLadderRun { if (!this.store || !this.facts) { return } + const previous = this.store.read(this.targetId) + // Why keep the earlier time on a replay: a replayed refusal must still expire on schedule. + const refusedAt = + this.pinnedRefusalRemembered && previous?.pinnedRefusal === this.pinnedRefusal + ? (previous.refusedAt ?? Date.now()) + : Date.now() const next: SshRemoteRuntimeResolution = { rung, ...(this.pinnedRefusal ? { pinnedRefusal: this.pinnedRefusal } : {}), + ...(this.pinnedRefusal ? { refusedAt } : {}), ...relayRuntimeDecisionKey(this.facts) } - const previous = this.store.read(this.targetId) if (JSON.stringify(previous) !== JSON.stringify(next)) { try { this.store.write(this.targetId, next) @@ -181,14 +207,19 @@ export class RelayRuntimeLadderRun { /** Rung D: no runtime runs on this host. Carries the classified cause for structured readers. */ export class RemoteRuntimeUnavailableError extends Error { + readonly reason: RemoteRuntimeUnavailableReason readonly data: TerminalUnavailableCause - constructor( - readonly reason: RemoteRuntimeUnavailableReason, - run: RelayRuntimeLadderRun - ) { - super(remoteRuntimeUnavailableMessage(reason, run.firstRefusal, run.noexecRemembered)) + constructor(run: RelayRuntimeLadderRun) { + const { reason, message } = remoteRuntimeUnavailable({ + firstRefusal: run.firstRefusal, + hostNodeRefusal: run.lastRefusal, + noexec: run.noexec, + hostOs: run.host?.os ?? null + }) + super(message) this.name = 'RemoteRuntimeUnavailableError' + this.reason = reason const glibc = run.facts?.glibc this.data = { status: 'blocked', @@ -212,10 +243,7 @@ export class RemoteRuntimeUnavailableError extends Error { } export function remoteRuntimeUnavailableError(run: RelayRuntimeLadderRun): Error { - return new RemoteRuntimeUnavailableError( - remoteRuntimeUnavailableReason(run.lastRefusal, run.noexecRemembered), - run - ) + return new RemoteRuntimeUnavailableError(run) } export function sshTargetRelayRuntimeDecisionStore(registry: { diff --git a/src/main/ssh/ssh-relay-runtime-self-test.test.ts b/src/main/ssh/ssh-relay-runtime-self-test.test.ts index 475c38f4d3e..aa59dfbb825 100644 --- a/src/main/ssh/ssh-relay-runtime-self-test.test.ts +++ b/src/main/ssh/ssh-relay-runtime-self-test.test.ts @@ -47,6 +47,13 @@ function report(nonce: string, fields: Record): string { })}` } +const NIXOS_STUB_LD_OUTPUT = [ + 'Could not start dynamically linked executable: /home/u/.orca-remote/runtimes/node-x/bin/node', + 'NixOS cannot run dynamically linked executables intended for generic', + 'linux environments out of the box. For more information, see:', + 'https://nix.dev/permalink/stub-ld' +].join('\n') + describe('pinned runtime refusal classification', () => { it.each([ [132, '', 'illegal_instruction'], @@ -109,6 +116,13 @@ describe('pinned runtime refusal classification', () => { it('leaves an unrecognized failure unclassified', () => { expect(classifyPinnedRuntimeFailure(1, 'TypeError: something else')).toBeNull() }) + + it("classifies NixOS's stub loader refusing a generic Linux binary as wrong_libc", () => { + expect(classifyPinnedRuntimeFailure(127, NIXOS_STUB_LD_OUTPUT)).toBe('wrong_libc') + // Only the loader's own exit; an unrelated 127 stays unclassified. + expect(classifyPinnedRuntimeFailure(1, NIXOS_STUB_LD_OUTPUT)).toBeNull() + expect(classifyPinnedRuntimeFailure(127, 'relay.js exited')).toBeNull() + }) }) describe('self-test commands on a real shell', () => { diff --git a/src/main/ssh/ssh-relay-runtime-self-test.ts b/src/main/ssh/ssh-relay-runtime-self-test.ts index 90deca60915..371cee5f704 100644 --- a/src/main/ssh/ssh-relay-runtime-self-test.ts +++ b/src/main/ssh/ssh-relay-runtime-self-test.ts @@ -73,6 +73,10 @@ export function classifyPinnedRuntimeFailure( if (/\b(?:GLIBC|GLIBCXX|CXXABI)_[0-9.]+'? not found/.test(output)) { return 'libc_floor' } + // NixOS's stub loader answers in place of the generic ELF interpreter it does not ship. + if (exitStatus === 127 && /Could not start dynamically linked executable/.test(output)) { + return 'wrong_libc' + } // A glibc binary on musl (or the reverse) fails in the ELF interpreter or on relocation. if (/ld-linux[\w.-]*\.so|ld-musl[\w.-]*\.so/i.test(output)) { return 'wrong_libc' diff --git a/src/main/ssh/ssh-relay-runtime-step-plan.ts b/src/main/ssh/ssh-relay-runtime-step-plan.ts index 3495520f683..b9ae50026f4 100644 --- a/src/main/ssh/ssh-relay-runtime-step-plan.ts +++ b/src/main/ssh/ssh-relay-runtime-step-plan.ts @@ -9,6 +9,7 @@ import { import { rungBCompatRuntimeFor, type RelayRuntimeStep } from './ssh-relay-runtime-ladder' import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' import type { RemoteHostPlatform } from './ssh-remote-platform' +import { RemoteNodeNotFoundError, resolveRemoteNodePath } from './ssh-remote-node-resolution' import type { OrcadDeploymentTargetFacts } from './orcad-deployment-target' type StepPlanOptions = { @@ -37,6 +38,22 @@ async function ladderTargetFacts(options: StepPlanOptions): Promise { + try { + return await resolveRemoteNodePath(conn, host, { signal, strict: true }) + } catch (error) { + if (error instanceof RemoteNodeNotFoundError) { + throw new PinnedRelayFallbackError('host_node_missing', 'no host Node.js 18+ with npm') + } + throw error + } +} + /** Undefined for the host-npm path; throws `PinnedRelayFallbackError` when a rung cannot run. */ export async function planRelayRuntimeStep( options: StepPlanOptions @@ -45,6 +62,9 @@ export async function planRelayRuntimeStep( run.host = host switch (step) { case 'legacy': + if (run.laddered) { + run.hostNodePath = await proveHostNodeForFallback(conn, host, signal) + } return undefined case 'A': { const facts = await ladderTargetFacts(options) diff --git a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts b/src/main/ssh/ssh-relay-session-plain-ssh.test.ts index c76209cd621..d5077a2fbf4 100644 --- a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts +++ b/src/main/ssh/ssh-relay-session-plain-ssh.test.ts @@ -62,11 +62,11 @@ class FakeShellChannel extends EventEmitter { } function runtimeUnavailable(): RemoteRuntimeUnavailableError { - const run = new RelayRuntimeLadderRun('target-1', null) + const run = new RelayRuntimeLadderRun('target-1', null, true) run.host = getRemoteHostPlatform('linux-x64') run.refused('A', 'libc_floor') run.refused('C', 'host_node_missing') - return new RemoteRuntimeUnavailableError('no_runtime', run) + return new RemoteRuntimeUnavailableError(run) } function createConnection(systemSsh = false) { diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index c186e922e93..0e9e0c03579 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -26,6 +26,7 @@ export function createMockDeps(): SshRelaySessionTestDeps { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The relay fixture implements the Store methods exercised by session establishment and teardown. const mockStore = { getRepos: vi.fn().mockReturnValue([]), + getSshTarget: vi.fn().mockReturnValue(undefined), getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index e7f7c60a15c..b53000f8352 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -114,6 +114,7 @@ import { SshOwnerAdmissionBlockedError } from './ssh-owner-admission-blocked-error' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' import { acknowledgeRemoteOrcaCliPostOutput, parseRemoteOrcaCliPostOutput @@ -1581,6 +1582,15 @@ export class SshRelaySession { } } + // Why: read per invocation so turning the per-host opt-in off takes effect on the next command. + private remoteCliCallerScope(): SshBridgeCallerScope { + return { + kind: 'ssh-bridge', + targetId: this.targetId, + remoteCliControl: this.store.getSshTarget(this.targetId)?.allowRemoteCliControl === true + } + } + private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer, connectionIncarnation: string): void { mux.onRequest('orca.cli', async (params) => { if (!this.runtime) { @@ -1614,7 +1624,8 @@ export class SshRelaySession { env, ...(stdin !== undefined ? { stdin } : {}), ...(artifactInput ? { artifactInput } : {}), - runtimeAuthority + runtimeAuthority, + callerScope: this.remoteCliCallerScope() }) } finally { this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId) @@ -1644,7 +1655,8 @@ export class SshRelaySession { await acknowledgeRemoteOrcaCliPostOutput(this.runtime, { postOutput: parseRemoteOrcaCliPostOutput(params.postOutput), env, - runtimeAuthority + runtimeAuthority, + callerScope: this.remoteCliCallerScope() }) return { acknowledged: true } } finally { diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 5bf0cc3ab53..c950176dc70 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -323,8 +323,11 @@ describe('relay install writes on a split SFTP namespace', () => { let capture: Capture let warnSpy: ReturnType + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockImplementation((_sftp, _local, remote: string) => { capture.uploadTargets.push(remote) @@ -585,8 +588,11 @@ describe('relay repair writes on a split SFTP namespace', () => { let capture: Capture let warnSpy: ReturnType + afterEach(() => vi.unstubAllEnvs()) beforeEach(() => { vi.clearAllMocks() + // The host-npm path is opt-in; these cases cover it. + vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') diff --git a/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts b/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts new file mode 100644 index 00000000000..a8105f141e8 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts @@ -0,0 +1,170 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + +import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' +import { SshBridgeCredentialRegistry } from '../runtime/rpc/ssh-bridge-credentials' +import { runHostOrcaCliPassthrough } from './ssh-remote-cli-host-passthrough' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { HOST_BOUND_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' + +class FakeChild extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + readonly stdin = { end: vi.fn(), on: vi.fn() } + readonly kill = vi.fn() +} + +const RUNTIME_AUTHORITY = { + kind: 'ssh', + targetId: 'box-1', + connectionIncarnation: 'incarnation-1', + attachmentId: 'attachment-1' +} as const + +describe('the SSH bridge CLI child', () => { + it('holds a credential scoped to its SSH target, never the owner token, for its lifetime only', async () => { + const credentials = new SshBridgeCredentialRegistry() + const child = new FakeChild() + let spawnedEnv: NodeJS.ProcessEnv = {} + const spawn = vi.fn((_exec: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + spawnedEnv = options.env + return child + }) + + const pending = runHostOrcaCliPassthrough( + { + argv: ['terminal', 'list', '--json'], + cwd: '/home/alice/wt', + env: { ORCA_ENVIRONMENT: 'prod' }, + runtimeAuthority: RUNTIME_AUTHORITY, + callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE + }, + { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true, + hostEnv: { + PATH: '/usr/bin', + ORCA_ENVIRONMENT: 'owner-paired-server', + ORCA_PAIRING_CODE: 'owner-pairing-code', + [ORCA_SSH_BRIDGE_CREDENTIAL_ENV]: 'stale-or-forged' + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every ChildProcess member the passthrough touches. + spawn: spawn as never, + credentials + } + ) + + const token = spawnedEnv[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] + expect(token).toMatch(/^sshb_[0-9a-f]{48}$/) + expect(credentials.resolve(token ?? '')).toEqual(HOST_BOUND_SSH_BRIDGE_SCOPE) + // Why: this machine's paired-server selection would route the guest's command with owner credentials. + expect(spawnedEnv.ORCA_ENVIRONMENT).toBeUndefined() + expect(spawnedEnv.ORCA_PAIRING_CODE).toBeUndefined() + + child.emit('close', 0) + await pending + expect(credentials.resolve(token ?? '')).toBeNull() + }) + + it('revokes the credential when the child cannot be launched', async () => { + const credentials = new SshBridgeCredentialRegistry() + const child = new FakeChild() + let token = '' + const spawn = vi.fn((_exec: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + token = options.env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] ?? '' + return child + }) + const pending = runHostOrcaCliPassthrough( + { argv: ['status'], cwd: '/', env: {}, callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE }, + { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true, + hostEnv: {}, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every ChildProcess member the passthrough touches. + spawn: spawn as never, + credentials + } + ) + child.emit('error', new Error('ENOENT')) + await expect(pending).rejects.toThrow('Failed to launch') + expect(credentials.resolve(token)).toBeNull() + }) +}) + +describe('the in-process fallback bridge', () => { + const LEGACY_FALLBACK_OPTIONS = { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => false + } + + it('applies the host-bound scope to orchestration it would otherwise relay as owner', async () => { + const runtime = new OrcaRuntimeService() + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'send', '--to', 'term_local', '--subject', 'hi', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_remote' }, + callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE + }, + LEGACY_FALLBACK_OPTIONS + ) + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout).error.code).toBe('forbidden') + }) + + it("filters the fallback terminal list to the bridged host's terminals", async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [ + { + handle: 'term_local', + ptyId: 'pty-1', + worktreeId: 'repo::/Users/me/secret', + worktreePath: '/Users/me/secret', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'owner shell', + connected: true, + writable: true, + lastOutputAt: null, + preview: 'owner secrets', + executionHostId: 'local' + } + ], + totalCount: 1, + truncated: false + }) + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['terminal', 'list', '--json'], + cwd: '/home/alice/repo', + env: {}, + callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE + }, + LEGACY_FALLBACK_OPTIONS + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).not.toContain('owner secrets') + expect(JSON.parse(result.stdout).result.terminals).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts index cd0503348d0..b54a855596d 100644 --- a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts @@ -13,6 +13,7 @@ vi.mock('../persistence', () => ({ import { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' // Why: the SSH bridge captures the host CLI child's stdout and exit code without reparsing; this // pins that a typed refusal envelope and its nonzero exit reach the remote agent unchanged. @@ -42,6 +43,7 @@ it('relays typed dispatch refusal codes from the host CLI unchanged', async () = const resultPromise = runRemoteOrcaCli( new OrcaRuntimeService(), { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_w', '--json'], cwd: '/home/alice/repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index 811917cf715..fe0c8a4260d 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -29,6 +29,7 @@ import { ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV } from '../../shared/orchestration-compatibility-evidence' import { REMOTE_ARTIFACT_INPUT_ENV } from '../../shared/artifact-cli-bridge' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' type FakeChild = EventEmitter & { stdout: EventEmitter @@ -67,6 +68,7 @@ describe('resolveHostCliEntryPath', () => { describe('buildHostCliEnv', () => { it('forwards only Orca terminal-context vars from the remote env', () => { const env = buildHostCliEnv({ + bridgeCredential: 'sshb_test', hostEnv: { PATH: '/host/bin', NODE_OPTIONS: '--inspect' }, remoteEnv: { ORCA_TERMINAL_HANDLE: 'term_remote', @@ -120,6 +122,7 @@ describe('buildHostCliEnv', () => { ['Windows host', { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }] ])('pins %s recovery to the remote shim', (_name, hostEnv) => { const env = buildHostCliEnv({ + bridgeCredential: 'sshb_test', hostEnv, remoteEnv: { ORCA_CLI_COMMAND: 'untrusted-remote-command' }, userDataPath: '/host/user-data', @@ -133,6 +136,7 @@ describe('buildHostCliEnv', () => { // The host's env carries a session id when Orca was launched inside a structured session; the // remote shell's own is from another machine. Session identity is same-host only. const env = buildHostCliEnv({ + bridgeCredential: 'sshb_test', hostEnv: { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', ORCA_STRUCTURED_SESSION: '1' @@ -159,6 +163,7 @@ describe('buildHostCliEnv', () => { } const build = (targetId: string) => buildHostCliEnv({ + bridgeCredential: 'sshb_test', hostEnv: {}, remoteEnv: {}, userDataPath: '/host/user-data', @@ -289,6 +294,7 @@ describe('runHostOrcaCliPassthrough', () => { const resultPromise = runHostOrcaCliPassthrough( { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'task-create', '--spec', 'do the thing', '--json'], cwd: '/home/alice/wt', env: { ORCA_TERMINAL_HANDLE: 'term_remote' } @@ -333,6 +339,7 @@ describe('runHostOrcaCliPassthrough', () => { const resultPromise = runHostOrcaCliPassthrough( { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['linear', 'comment', 'add', 'ENG-1', '--body-file', '-'], cwd: '/home/alice/wt', env: {}, @@ -353,7 +360,12 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn(() => child) const resultPromise = runHostOrcaCliPassthrough( - { argv: ['worktree', 'show'], cwd: '/', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['worktree', 'show'], + cwd: '/', + env: {} + }, { ...BASE_OPTIONS, spawn: spawn as never } ) @@ -368,7 +380,7 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn() await expect( runHostOrcaCliPassthrough( - { argv: ['status'], cwd: '/', env: {} }, + { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, { ...BASE_OPTIONS, entryExists: () => false, spawn: spawn as never } ) ).rejects.toBeInstanceOf(HostCliUnavailableError) @@ -379,7 +391,7 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn() await expect( runHostOrcaCliPassthrough( - { argv: ['status'], cwd: '/', env: {} }, + { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 2_147_483_648 } ) ).rejects.toBeInstanceOf(RangeError) @@ -391,7 +403,7 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn(() => child) const resultPromise = runHostOrcaCliPassthrough( - { argv: ['status'], cwd: '/', env: {} }, + { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, { ...BASE_OPTIONS, spawn: spawn as never } ) @@ -408,7 +420,12 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn(() => child) const resultPromise = runHostOrcaCliPassthrough( - { argv: ['terminal', 'wait', '--for', 'exit'], cwd: '/', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['terminal', 'wait', '--for', 'exit'], + cwd: '/', + env: {} + }, { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 1000 } ) @@ -427,7 +444,12 @@ describe('runHostOrcaCliPassthrough', () => { const spawn = vi.fn(() => child) const resultPromise = runHostOrcaCliPassthrough( - { argv: ['terminal', 'read'], cwd: '/', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['terminal', 'read'], + cwd: '/', + env: {} + }, { ...BASE_OPTIONS, spawn: spawn as never } ) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 4490343504f..9c15287d166 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -15,6 +15,12 @@ import { } from '../../shared/orchestration-compatibility-evidence' import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' +import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' +import { + sshBridgeCredentials, + type SshBridgeCallerScope, + type SshBridgeCredentialRegistry +} from '../runtime/rpc/ssh-bridge-credentials' import { REMOTE_ARTIFACT_INPUT_ENV, sshArtifactSourceKey, @@ -35,6 +41,8 @@ export type RemoteOrcaCliRequest = { stdin?: string artifactInput?: RemoteArtifactInput runtimeAuthority?: SshCliRuntimeAuthority + // Why: required so no bridged invocation can run with owner authority over this Orca. + callerScope: SshBridgeCallerScope } export type RemoteOrcaCliResult = { @@ -66,6 +74,7 @@ export type HostCliPassthroughOptions = { spawn?: typeof nodeSpawn entryExists?: (path: string) => boolean killTimeoutMs?: number + credentials?: SshBridgeCredentialRegistry } /** Thrown when the host CLI entry cannot be launched at all; callers fall back @@ -103,6 +112,7 @@ export function buildHostCliEnv(args: { remoteEnv: Record userDataPath: string remoteCwd: string + bridgeCredential: string runtimeAuthority?: SshCliRuntimeAuthority artifactInput?: RemoteArtifactInput }): NodeJS.ProcessEnv { @@ -137,6 +147,11 @@ export function buildHostCliEnv(args: { // when Orca was launched inside a session, and identity by session id is same-host only. delete env[ORCA_AGENT_SESSION_ID_ENV] delete env[ORCA_STRUCTURED_SESSION_ENV] + // Why: this machine's paired-server selection would route the remote command with its credentials. + delete env.ORCA_PAIRING_CODE + delete env.ORCA_REMOTE_PAIRING + delete env.ORCA_ENVIRONMENT + env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] = args.bridgeCredential if (args.runtimeAuthority) { env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh' env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId @@ -196,16 +211,33 @@ export async function runHostOrcaCliPassthrough( throw new HostCliUnavailableError(`Orca CLI entry not found at ${cliEntryPath}`) } + const credential = (options.credentials ?? sshBridgeCredentials).mint(request.callerScope) const env = buildHostCliEnv({ hostEnv, remoteEnv: request.env, userDataPath, remoteCwd: request.cwd, + bridgeCredential: credential.token, runtimeAuthority: request.runtimeAuthority, artifactInput: request.artifactInput }) - return await new Promise((resolve, reject) => { + try { + return await spawnHostCli(spawn, execPath, cliEntryPath, request, env, killTimeoutMs) + } finally { + credential.revoke() + } +} + +function spawnHostCli( + spawn: typeof nodeSpawn, + execPath: string, + cliEntryPath: string, + request: RemoteOrcaCliRequest, + env: NodeJS.ProcessEnv, + killTimeoutMs: number +): Promise { + return new Promise((resolve, reject) => { let settled = false const child = spawn(execPath, [cliEntryPath, ...request.argv], { env, diff --git a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts index 1e3a9718239..44804c80b8a 100644 --- a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts +++ b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts @@ -13,6 +13,7 @@ vi.mock('../persistence', () => ({ import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' // Why: a missing CLI entry forces the legacy in-process bridge, the transport // an SSH-hosted agent actually reaches `terminal list` through. @@ -51,7 +52,12 @@ describe('remote CLI bridge terminal list', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['terminal', 'list', '--json'], cwd: '/home/alice/repo', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['terminal', 'list', '--json'], + cwd: '/home/alice/repo', + env: {} + }, LEGACY_FALLBACK_OPTIONS ) diff --git a/src/main/ssh/ssh-remote-linear-cli.test.ts b/src/main/ssh/ssh-remote-linear-cli.test.ts index 0b2495a5195..4efe8927cbd 100644 --- a/src/main/ssh/ssh-remote-linear-cli.test.ts +++ b/src/main/ssh/ssh-remote-linear-cli.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { isLinearProjectListResult } from './ssh-remote-linear-result-guards' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { runControlGrantedRemoteOrcaCli as runRemoteOrcaCli } from './ssh-bridge-caller-scope.test-fixture' function createRuntime() { const runtime = { diff --git a/src/main/ssh/ssh-remote-linear-save-issue.test.ts b/src/main/ssh/ssh-remote-linear-save-issue.test.ts index 9d54ea7aaa7..25bc3f337b2 100644 --- a/src/main/ssh/ssh-remote-linear-save-issue.test.ts +++ b/src/main/ssh/ssh-remote-linear-save-issue.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' function createRuntime() { const linearSaveIssue = vi.fn(async (request: unknown) => ({ @@ -29,6 +30,7 @@ describe('SSH remote Linear save issue', () => { it('forwards update fields, clears, stdin, and SSH context without losing types', async () => { const { runtime, linearSaveIssue } = createRuntime() const result = await runRemoteOrcaCli(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'linear', 'save-issue', @@ -83,6 +85,7 @@ describe('SSH remote Linear save issue', () => { it('forwards the team and title required for create mode', async () => { const { runtime, linearSaveIssue } = createRuntime() const result = await runRemoteOrcaCli(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['linear', 'save-issue', '--team', 'ENG', '--title', 'New issue', '--json'], cwd: '/home/alice/remote-repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } @@ -97,6 +100,7 @@ describe('SSH remote Linear save issue', () => { it('rejects remote body paths instead of reading from the wrong filesystem', async () => { const { runtime, linearSaveIssue } = createRuntime() const result = await runRemoteOrcaCli(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['linear', 'save-issue', 'ENG-123', '--body-file', 'body.md', '--json'], cwd: '/home/alice/remote-repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } diff --git a/src/main/ssh/ssh-remote-node-addon-resolution.test.ts b/src/main/ssh/ssh-remote-node-addon-resolution.test.ts index e721cc8096e..19f98afe05d 100644 --- a/src/main/ssh/ssh-remote-node-addon-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-addon-resolution.test.ts @@ -47,7 +47,9 @@ describe('resolveRemoteHostNodeForAddons (rung C)', () => { execCommandMock .mockResolvedValueOnce('') // path probe: nothing installed .mockResolvedValueOnce('/bin/bash\n') // $SHELL - .mockRejectedValueOnce(new Error('Command failed (exit 1): ')) // command -v node + .mockRejectedValueOnce( + Object.assign(new Error('Command failed (exit 1): '), { exitCode: 1, stdout: '' }) + ) // command -v node await expect(resolveRemoteHostNodeForAddons(conn, 8)).resolves.toBeNull() }) diff --git a/src/main/ssh/ssh-remote-node-addon-resolution.ts b/src/main/ssh/ssh-remote-node-addon-resolution.ts index a482610dfe3..7071d1074a0 100644 --- a/src/main/ssh/ssh-remote-node-addon-resolution.ts +++ b/src/main/ssh/ssh-remote-node-addon-resolution.ts @@ -7,6 +7,7 @@ import type { RemoteNodeResolutionOptions } from './ssh-remote-node-install-guid import { execCommand } from './ssh-relay-deploy-helpers' import { commandOptions, + memoizeCandidateCheck, throwIfAborted, tryResolveViaKnownPaths, tryResolveViaLoginShell, @@ -33,10 +34,12 @@ export async function resolveRemoteHostNodeForAddons( options?: RemoteNodeResolutionOptions ): Promise { const strict: ProbeOptions = { ...options, strict: true } - const addonCheck: CandidateCheck = async (candidate) => { - const facts = await probeHostNodeAddonFacts(conn, candidate, strict) - return hostNodeMeetsAddonRequirements(facts, requiredNapi) ? facts : null - } + const addonCheck: CandidateCheck = memoizeCandidateCheck( + async (candidate) => { + const facts = await probeHostNodeAddonFacts(conn, candidate, strict) + return hostNodeMeetsAddonRequirements(facts, requiredNapi) ? facts : null + } + ) const found = (await tryResolveViaKnownPaths(conn, addonCheck, strict)) ?? (await tryResolveViaLoginShell(conn, addonCheck, strict)) diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index fa259a5d620..86c1f0399df 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -14,7 +14,8 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ // Why: await import() is required so vi.mock() above registers before the // module under test is evaluated. Static import would bypass the mock. -const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution') +const { RemoteNodeNotFoundError, resolveRemoteNodePath } = + await import('./ssh-remote-node-resolution') const conn = {} as SshConnection @@ -684,4 +685,75 @@ describe('resolveRemoteNodePath', () => { const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string) expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/) }) + + describe('strict resolution, which settles rung D only on an answered "no Node"', () => { + const lostChannel = (): Error => + Object.assign(new Error('channel closed'), { sshChannelCloseConfirmed: false }) + + it('proves a Windows host has no Node when the discovery script answers none', async () => { + execCommandMock.mockRejectedValueOnce( + Object.assign(new Error('Node.js not found (exit 1)'), { exitCode: 1, stdout: '' }) + ) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { strict: true }) + ).rejects.toBeInstanceOf(RemoteNodeNotFoundError) + }) + + it('rethrows a refused channel (MaxSessions) instead of calling it "no Node"', async () => { + const refused = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 2 + }) + execCommandMock.mockRejectedValueOnce(refused) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { strict: true }) + ).rejects.toBe(refused) + }) + + it('rethrows a Windows probe the host never answered', async () => { + const lost = lostChannel() + execCommandMock.mockRejectedValueOnce(lost) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { strict: true }) + ).rejects.toBe(lost) + }) + + it('rethrows a lost POSIX version check rather than calling the candidate unusable', async () => { + const lost = lostChannel() + execCommandMock.mockResolvedValueOnce('/usr/local/bin/node\n').mockRejectedValueOnce(lost) + + await expect(resolveRemoteNodePath(conn, undefined, { strict: true })).rejects.toBe(lost) + }) + + it('probes a Node the login shell names again only once', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') // path probe + .mockResolvedValueOnce('__node__\nv20.0.0\n') // toolchain probe: npm missing + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('/usr/local/bin/node\n') // login shell: the same Node + .mockResolvedValueOnce('') // package manager hint probe + + await expect(resolveRemoteNodePath(conn, undefined, { strict: true })).rejects.toBeInstanceOf( + RemoteNodeNotFoundError + ) + const npmProbes = execCommandMock.mock.calls.filter(([, command]) => + String(command).includes('npm --version') + ) + expect(npmProbes).toHaveLength(1) + }) + + it('proves a POSIX host has no Node when every probe answered', async () => { + execCommandMock + .mockResolvedValueOnce('') // path probe: nothing + .mockResolvedValueOnce('/bin/bash') // $SHELL + .mockResolvedValueOnce('') // login shell: nothing + .mockResolvedValueOnce('') // package manager hint probe + + await expect(resolveRemoteNodePath(conn, undefined, { strict: true })).rejects.toBeInstanceOf( + RemoteNodeNotFoundError + ) + }) + }) }) diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index a9cf8a7cd47..4c758f77871 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -8,7 +8,7 @@ import { type RemoteNodeResolutionOptions } from './ssh-remote-node-install-guidance' import { execCommand } from './ssh-relay-deploy-helpers' -import { isSshExecTimeout } from './ssh-relay-exec-command' +import { isSshCommandExitError } from './ssh-relay-exec-command' import { buildPosixNodeToolchainProbe, buildWindowsNodeToolchainProbe, @@ -23,10 +23,19 @@ import { REMOTE_NODE_PATH_PROBE_SCRIPT } from './ssh-remote-node-probe-script' // hang a login shell, so keep this short. const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 +/** The probes answered and no usable Node.js + npm exists; only `strict` resolution proves it. */ +export class RemoteNodeNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = 'RemoteNodeNotFoundError' + } +} + +/** `strict` rethrows probes the host never answered, so a not-found is proof, not a lost channel. */ export async function resolveRemoteNodePath( conn: SshConnection, host?: RemoteHostPlatform, - options?: RemoteNodeResolutionOptions + options?: ProbeOptions ): Promise { if (host && isWindowsRemoteHost(host)) { return resolveRemoteWindowsNodePath(conn, options) @@ -37,8 +46,10 @@ export async function resolveRemoteNodePath( // This doesn't depend on shell startup-file semantics — bash -lc skips // .bashrc and zsh -lc skips .zshrc, but those are exactly the files where // nvm/mise/asdf hooks live. Probing directories directly is deterministic. - const npmCheck: CandidateCheck = async (candidate) => - (await nodeToolchainMeetsRequirements(conn, candidate, options)) || null + // Why memoized: the login shell often names the Node the path probe already rejected. + const npmCheck = memoizeCandidateCheck( + async (candidate) => (await nodeToolchainMeetsRequirements(conn, candidate, options)) || null + ) const probed = await tryResolveViaKnownPaths(conn, npmCheck, options) if (probed) { return probed.nodePath @@ -55,12 +66,35 @@ export async function resolveRemoteNodePath( } export type CandidateCheck = (candidate: string) => Promise + +/** Probes each candidate path once per resolution, however many strategies name it. */ +export function memoizeCandidateCheck(check: CandidateCheck): CandidateCheck { + const results = new Map>() + return (candidate) => { + let result = results.get(candidate) + if (!result) { + result = check(candidate) + results.set(candidate, result) + } + return result + } +} type ResolvedCandidate = { nodePath: string; result: T } /** `strict` rethrows unanswered probes rather than reading them as "no Node here". */ export type ProbeOptions = RemoteNodeResolutionOptions & { strict?: boolean } -function isUnansweredExec(err: unknown): boolean { - return isSshExecTimeout(err) || (err instanceof Error && 'sshChannelCloseConfirmed' in err) +/** + * Rethrows what is not a host-answered miss: an opted-in session limit, an abort, or (under + * `strict`) an exec that never exited. Only a command that ran and exited answered. + */ +function rethrowUnlessAnsweredMiss(err: unknown, options?: ProbeOptions): void { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + throwIfAborted(options) + if (options?.strict && !isSshCommandExitError(err)) { + throw err + } } // Probe the on-disk install directories of every common Node version manager @@ -142,14 +176,8 @@ export async function tryResolveViaLoginShell( return { nodePath: candidate, result } } } catch (err) { - if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { - throw err - } - throwIfAborted(options) - // Why only these: `command -v node` exits non-zero when the shell answered "none". - if (options?.strict && isUnansweredExec(err)) { - throw err - } + // Why: `command -v node` exits non-zero when the shell answered "none". + rethrowUnlessAnsweredMiss(err, options) // Fall through. } return null @@ -157,12 +185,10 @@ export async function tryResolveViaLoginShell( // Validates the same PATH-prepend + bare npm contract used during deployment. // This rejects missing npm (#8450) without requiring colocation (#9165). -// Caches nothing — this runs at most a few times per resolution (one per -// candidate), and the exec round-trip dominates. async function nodeToolchainMeetsRequirements( conn: SshConnection, nodePath: string, - options?: RemoteNodeResolutionOptions + options?: ProbeOptions ): Promise { try { const versionOutput = await execCommand( @@ -174,10 +200,7 @@ async function nodeToolchainMeetsRequirements( ) return nodeToolchainVersionsMeetRequirements(versionOutput) } catch (err) { - if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { - throw err - } - throwIfAborted(options) + rethrowUnlessAnsweredMiss(err, options) // Binary missing or fails to run — not usable. return false } @@ -185,7 +208,7 @@ async function nodeToolchainMeetsRequirements( async function resolveRemoteWindowsNodePath( conn: SshConnection, - options?: RemoteNodeResolutionOptions + options?: ProbeOptions ): Promise { const script = [ '$paths = @()', @@ -224,10 +247,8 @@ async function resolveRemoteWindowsNodePath( } } } catch (err) { - if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { - throw err - } - throwIfAborted(options) + // Why: the script exits 1 when it finds nothing, so only an unanswered probe is unknown. + rethrowUnlessAnsweredMiss(err, options) // Fall through to the shared error below. } @@ -237,7 +258,7 @@ async function resolveRemoteWindowsNodePath( async function windowsNodeToolchainMeetsRequirements( conn: SshConnection, nodePath: string, - options?: RemoteNodeResolutionOptions + options?: ProbeOptions ): Promise { try { const versionOutput = await execCommand( @@ -247,10 +268,7 @@ async function windowsNodeToolchainMeetsRequirements( ) return nodeToolchainVersionsMeetRequirements(versionOutput) } catch (err) { - if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { - throw err - } - throwIfAborted(options) + rethrowUnlessAnsweredMiss(err, options) return false } } @@ -262,12 +280,12 @@ async function throwNodeNotFound( throwIfAborted(options) const guidance = await buildPosixNodeInstallGuidance(conn, options) throwIfAborted(options) - throw new Error(guidance) + throw new RemoteNodeNotFoundError(guidance) } function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never { throwIfAborted(options) - throw new Error( + throw new RemoteNodeNotFoundError( [ 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.', '', diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 90e7830099d..32eddcc1c0b 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -16,6 +16,7 @@ import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { createRootDispatch } from '../runtime/orchestration/db/root-dispatch-test-fixture' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' // Why: pointing the passthrough at a missing CLI entry forces the legacy // in-process fallback, which is what these dispatch tests exercise. @@ -168,7 +169,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv, cwd: '/home/alice/repo', env: {} }, + { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv, cwd: '/home/alice/repo', env: {} }, LEGACY_FALLBACK_OPTIONS ) @@ -197,7 +198,12 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['status', '--json'], cwd: '/home/alice/repo', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['status', '--json'], + cwd: '/home/alice/repo', + env: {} + }, LEGACY_FALLBACK_OPTIONS ) @@ -217,6 +223,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], cwd: '/home/alice/repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } @@ -245,6 +252,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], cwd: '/home/alice/repo', env: { @@ -280,6 +288,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -338,6 +347,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -411,6 +421,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -451,6 +462,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -482,6 +494,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -517,6 +530,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -551,6 +565,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--all', '--json'], cwd: '/home/alice/repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } @@ -574,6 +589,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--all', '--json'], cwd: '/home/alice/repo', env: { @@ -600,6 +616,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--terminal', 'term_legacy_worker', '--all', '--json'], cwd: '/home/alice/repo', env: { @@ -623,6 +640,7 @@ describe('runRemoteOrcaCli', () => { const resultPromise = runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['worktree', 'create', '--repo', 'orca', '--branch', 'fix/x', '--json'], cwd: '/home/alice/repo', env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } @@ -661,7 +679,12 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['serve'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['serve'], + cwd: '/home/alice', + env: {} + }, { ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never } ) @@ -677,7 +700,12 @@ describe('runRemoteOrcaCli', () => { const addResult = await runRemoteOrcaCli( runtime, - { argv: ['account', 'add'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['account', 'add'], + cwd: '/home/alice', + env: {} + }, { ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never } ) @@ -689,7 +717,12 @@ describe('runRemoteOrcaCli', () => { spawn.mockReturnValueOnce(child) const listPromise = runRemoteOrcaCli( runtime, - { argv: ['account', 'list'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['account', 'list'], + cwd: '/home/alice', + env: {} + }, { ...LEGACY_FALLBACK_OPTIONS, entryExists: () => true, @@ -715,7 +748,12 @@ describe('runRemoteOrcaCli', () => { const resultPromise = runRemoteOrcaCli( runtime, - { argv: ['account', 'add', '--help'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['account', 'add', '--help'], + cwd: '/home/alice', + env: {} + }, { ...LEGACY_FALLBACK_OPTIONS, entryExists: () => true, @@ -739,7 +777,12 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['serve', '--json'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['serve', '--json'], + cwd: '/home/alice', + env: {} + }, LEGACY_FALLBACK_OPTIONS ) @@ -757,7 +800,12 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, - { argv: ['worktree', 'list'], cwd: '/home/alice', env: {} }, + { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, + argv: ['worktree', 'list'], + cwd: '/home/alice', + env: {} + }, LEGACY_FALLBACK_OPTIONS ) @@ -772,6 +820,7 @@ describe('runRemoteOrcaCli', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['emulator', 'launch', 'com.acme.app', '--activity', '.MainActivity'], cwd: '/home/alice', env: {} diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 3913a65cb9f..d6a9ed0e042 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -104,7 +104,11 @@ async function runLegacyRemoteOrcaCli( json: boolean, passthroughFailure: HostCliUnavailableError ): Promise { - const dispatcher = new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: ALL_RPC_METHODS, + callerScope: request.callerScope + }) const help = getRemoteLinearHelp(parsed) if (help) { return { stdout: `${help}\n`, stderr: '', exitCode: 0 } diff --git a/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts b/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts index b321dfba841..c1f379d8120 100644 --- a/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts +++ b/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts @@ -17,6 +17,7 @@ import type Database from '../sqlite/sync-database' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { acknowledgeRemoteOrcaCliPostOutput } from './ssh-remote-orchestration-post-output' +import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' import { createRootDispatch } from '../runtime/orchestration/db/root-dispatch-test-fixture' const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { @@ -137,6 +138,7 @@ describe('legacy SSH orchestration fallback', () => { subject: 'retained SSH mail' }) const request = { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--unread', '--inject', '--json'], cwd: '/home/alice/repo', env: WORKER_ENV, @@ -164,6 +166,7 @@ describe('legacy SSH orchestration fallback', () => { expect(db.getMessageById(message.id)?.read).toBe(0) await acknowledgeRemoteOrcaCliPostOutput(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, postOutput: first.postOutput!, env: WORKER_ENV, runtimeAuthority: RUNTIME_AUTHORITY @@ -192,6 +195,7 @@ describe('legacy SSH orchestration fallback', () => { const peek = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'check', '--peek', '--format', '--json'], cwd: '/home/alice/repo', env: WORKER_ENV, @@ -246,6 +250,7 @@ describe('legacy SSH orchestration fallback', () => { const checked = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, ...baseRequest, argv: ['orchestration', 'check', '--run', run.id] }, @@ -258,6 +263,7 @@ describe('legacy SSH orchestration fallback', () => { const checkedJson = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, ...baseRequest, argv: ['orchestration', 'check', '--run', run.id, '--json'] }, @@ -271,6 +277,7 @@ describe('legacy SSH orchestration fallback', () => { const acknowledged = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, ...baseRequest, argv: [ 'orchestration', @@ -310,6 +317,7 @@ describe('legacy SSH orchestration fallback', () => { '--json' ] const request = { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv, cwd: '/home/alice/repo', env: WORKER_ENV, @@ -366,6 +374,7 @@ describe('legacy SSH orchestration fallback', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['orchestration', 'ask', '--resume', pending.question.message_id, '--json'], cwd: '/home/alice/repo', env: WORKER_ENV, @@ -389,6 +398,7 @@ describe('legacy SSH orchestration fallback', () => { expect(db.getMessageById(answer.message.id)?.read).toBe(0) await acknowledgeRemoteOrcaCliPostOutput(runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, postOutput: result.postOutput!, env: WORKER_ENV, runtimeAuthority: RUNTIME_AUTHORITY @@ -418,6 +428,7 @@ describe('legacy SSH orchestration fallback', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [ 'orchestration', 'send', @@ -463,6 +474,7 @@ describe('legacy SSH orchestration fallback', () => { const result = await runRemoteOrcaCli( runtime, { + callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: [...commandArgv, '--retry-request', '--json'], cwd: '/home/alice/repo', env: WORKER_ENV, diff --git a/src/main/ssh/ssh-remote-orchestration-post-output.ts b/src/main/ssh/ssh-remote-orchestration-post-output.ts index 3778ea2524d..05d39282df8 100644 --- a/src/main/ssh/ssh-remote-orchestration-post-output.ts +++ b/src/main/ssh/ssh-remote-orchestration-post-output.ts @@ -18,6 +18,7 @@ export async function acknowledgeRemoteOrcaCliPostOutput( postOutput: RemoteOrcaCliPostOutput env: Record runtimeAuthority?: RemoteOrcaCliRequest['runtimeAuthority'] + callerScope: RemoteOrcaCliRequest['callerScope'] } ): Promise { const inheritedEvidence = readOrchestrationCompatibilityEvidence(args.env) @@ -40,7 +41,11 @@ export async function acknowledgeRemoteOrcaCliPostOutput( answerMessageId: args.postOutput.answerMessageId }) } - const response = await new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatch({ + const response = await new RpcDispatcher({ + runtime, + methods: ALL_RPC_METHODS, + callerScope: args.callerScope + }).dispatch({ id: `remote-cli-post-output-${randomUUID()}`, authToken: 'remote-cli', method: 'orchestration.check', diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index ac3ff4f0308..509a93b22dd 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -22,7 +22,9 @@ function makeTarget(): SshTarget { configHost: 'fdpass-host', host: 'ignored.example.com', port: 22, - username: '' + username: '', + // The fixture pre-installs a host-Node relay tree. + remoteRuntime: 'legacy' } } diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts index 352386c3fb5..1abf3036f97 100644 --- a/src/main/ssh/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -1,5 +1,5 @@ import { DirectoryTransferBudget } from './ssh-directory-transfer-budget' -import { spawn } from 'node:child_process' +import { spawn, type ChildProcess } from 'node:child_process' import { lstat, opendir } from 'node:fs/promises' import { join as pathJoin } from 'node:path' import { pipeline } from 'node:stream/promises' @@ -20,6 +20,7 @@ import { throwIfAborted, waitForChannelClose, waitForProcess, + isHostAnsweredSystemSshExit, type ProcessResult } from './system-ssh-operation-lifecycle' import { @@ -80,11 +81,12 @@ export async function uploadDirectoryViaSystemSsh( killProcess(tarCreate) killProcess(sshExtract) }, - Promise.all([ + settleUploadPipeline( + tarCreate, waitForProcess(tarCreate, 'local tar relay upload'), - waitForProcess(sshExtract, 'system ssh relay upload'), + waitForProcess(sshExtract, 'system ssh relay upload', 'remote'), pipeline(tarCreate.stdout!, sshExtract.stdin!) - ]).then(([tar, ssh]) => [tar, ssh] as const) + ) ) } catch (err) { killProcess(tarCreate) @@ -100,6 +102,49 @@ export async function uploadDirectoryViaSystemSsh( } } +/** What local tar says when its output, not its input, failed: the far end went away. */ +const DOWNSTREAM_WRITE_FAILURE = /write error|cannot write|broken pipe|EPIPE/i + +/** + * Waits for all three, then names the cause; a failed ssh wins over a tar that succeeded. A remote + * exit the host answered wins over a local tar failure that only followed it: tar was signalled, + * failed after ssh closed, or reported a write/pipe error (BSD tar exits 1 with "Write error", no + * signal). A local tar that failed on its own input first (missing directory, unreadable file) is + * the client's fault. + */ +async function settleUploadPipeline( + tarProcess: ChildProcess, + local: Promise, + remote: Promise, + pipe: Promise +): Promise { + let settled = 0 + let localAt = 0 + let remoteAt = 0 + const stamp = (promise: Promise, record: (at: number) => void): Promise => + promise.finally(() => record(++settled)) + const [tar, ssh, piped] = await Promise.allSettled([ + stamp(local, (at) => (localAt = at)), + stamp(remote, (at) => (remoteAt = at)), + pipe + ]) + const remoteAnswered = ssh.status === 'rejected' && isHostAnsweredSystemSshExit(ssh.reason) + if (tar.status === 'rejected') { + const localFollowedRemote = + tarProcess.signalCode !== null || + remoteAt < localAt || + (tar.reason instanceof Error && DOWNSTREAM_WRITE_FAILURE.test(tar.reason.message)) + throw remoteAnswered && localFollowedRemote ? ssh.reason : tar.reason + } + if (ssh.status === 'rejected') { + throw ssh.reason + } + if (piped.status === 'rejected') { + throw piped.reason + } + return [tar.value, ssh.value] +} + export async function writeFileViaSystemSsh( target: SshTarget, remotePath: string, diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 148ccad8ff6..a3ecb27859d 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -3,6 +3,9 @@ import type { SystemSshCommandChannel } from './system-ssh-command' export type ProcessResult = { label: string; stderr: string } +/** OpenSSH's own exit status for a connection-level failure, never the remote command's. */ +const SYSTEM_SSH_TRANSPORT_EXIT_CODE = 255 + export class SystemSshCommandExitError extends Error { constructor( label: string, @@ -15,6 +18,15 @@ export class SystemSshCommandExitError extends Error { } } +/** The remote command, not OpenSSH, produced this exit. */ +export function isHostAnsweredSystemSshExit(err: unknown): err is SystemSshCommandExitError { + return ( + err instanceof SystemSshCommandExitError && + err.exitCode !== null && + err.exitCode !== SYSTEM_SSH_TRANSPORT_EXIT_CODE + ) +} + /** * `timeoutMs` bounds a remote consumer that never returns. Windows PowerShell 5.1 cannot drain a * large redirected stdin over a non-pty ssh exec (#16432): the remote process stays alive at idle @@ -76,7 +88,12 @@ export function waitForChannelClose( }) } -export function waitForProcess(proc: ChildProcess, label: string): Promise { +/** `remote`: the process is ssh running a host command, so its exit status is the host's answer. */ +export function waitForProcess( + proc: ChildProcess, + label: string, + origin: 'local' | 'remote' = 'local' +): Promise { return new Promise((resolve, reject) => { let stderr = '' const cleanup = (): void => { @@ -94,9 +111,14 @@ export function waitForProcess(proc: ChildProcess, label: string): Promise { settle(reject, err) } - const onClose = (code: number | null): void => { + const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { - settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) + settle( + reject, + origin === 'remote' + ? new SystemSshCommandExitError(label, code, stderr, signal) + : new Error(`${label} failed (exit ${code}): ${stderr.trim()}`) + ) return } settle(resolve, { label, stderr }) diff --git a/src/main/ssh/system-ssh-sftp-transfer.ts b/src/main/ssh/system-ssh-sftp-transfer.ts index c50375fa8eb..0c319a6e28c 100644 --- a/src/main/ssh/system-ssh-sftp-transfer.ts +++ b/src/main/ssh/system-ssh-sftp-transfer.ts @@ -13,7 +13,7 @@ import { toSftpRemotePath, UnsupportedSftpPathError } from './system-ssh-sftp-path' -import { throwIfAborted } from './system-ssh-operation-lifecycle' +import { SystemSshCommandExitError, throwIfAborted } from './system-ssh-operation-lifecycle' import { runProcess } from '../../shared/child-process/run-process' /** The host answered, but not with an sftp subsystem. The caller must fall back, not fail. */ @@ -167,9 +167,16 @@ export async function runSftpBatch( if (SUBSYSTEM_REFUSED_PATTERN.test(detail)) { throw new SftpSubsystemUnavailableError(detail) } - throw new Error(`sftp batch failed (exit ${result.code}): ${detail}`) + // Why typed only off-transport: the host answered this script; a dropped link said nothing. + if (CONTACT_LOST_PATTERN.test(detail)) { + throw new Error(`sftp batch failed (exit ${result.code}): ${detail}`) + } + throw new SystemSshCommandExitError('sftp batch', result.code, detail, result.signal) } +const CONTACT_LOST_PATTERN = + /connection (?:closed|reset|lost|timed out|refused)|broken pipe|timeout, server .* not responding/i + /** * Creates remote directories, parents first. * diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index d89ffcd8a0d..e7f3385ef04 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,4 +1,5 @@ import { app } from 'electron' +import { quitProcess } from './process-quit-request' import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' @@ -6,6 +7,7 @@ import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' import { DISABLED_CHROMIUM_FEATURES } from './disabled-chromium-features' import { readHttp1CompatibilityMarker } from './http1-compatibility-marker' +import { checkServeUserDataPath } from './serve-user-data-path-guard' import { hasMissingProfileStateDatabaseWithRetainedAuthority, readActiveProfileId, @@ -100,7 +102,7 @@ function getProcessPathDelimiter(): string { function requestDevParentShutdown(): void { devParentShutdownRequested = true - app.quit() + quitProcess() const forceExitTimer = setTimeout(() => { // Why: app.quit() may stall on macOS quit handlers or window-close guards, so force-exit after a grace period to avoid a hung dev app. @@ -230,8 +232,17 @@ function areSameE2EHomePath(left: string, right: string): boolean { } export function configureOrcaUserDataPathEnv(): void { + const userDataPath = app.getPath('userData') + // Why here: userData is final now and the instance lock is still ahead; preflight's catch exits serve. + const serveProfileRefusal = checkServeUserDataPath({ + isServeMode: process.argv.includes('--serve'), + userDataPath + }) + if (serveProfileRefusal) { + throw new Error(serveProfileRefusal) + } // Why: relaunches can inherit a stale ORCA_USER_DATA_PATH; canonicalize before CLI-shared modules build runtime-home paths. - process.env.ORCA_USER_DATA_PATH = app.getPath('userData') + process.env.ORCA_USER_DATA_PATH = userDataPath } export function shouldInstallManagedHooks(isDev: boolean): boolean { diff --git a/src/main/startup/headless-serve-ssh-registration.test.ts b/src/main/startup/headless-serve-ssh-registration.test.ts new file mode 100644 index 00000000000..42d92832bd2 --- /dev/null +++ b/src/main/startup/headless-serve-ssh-registration.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = await vi.hoisted(async () => { + const { createSshIpcMocks } = await import('../ipc/ssh-ipc-module-mocks') + return createSshIpcMocks() +}) + +vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) +vi.mock('electron', () => mocks.electron) +vi.mock('../ipc/ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) +vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) +vi.mock('../ipc/ssh-host-server-connect', () => mocks.hostServerConnect) +vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) +vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) +vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) +vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) +vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) +vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) +vi.mock('../ipc/pty', () => mocks.pty) +vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) +vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) +vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) +vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) +vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) + +import { resetSshHandlerStateForTests } from '../ipc/ssh' +import { connectRegisteredSshTarget, listRegisteredSshTargets } from '../ssh/ssh-target-registry' +import { createSshIpcHarness } from '../ipc/ssh-ipc-test-harness' +import type { SshTarget } from '../../shared/ssh-types' +import { mainProcessState } from './main-process-state' +import { registerHeadlessServeSshHandlers } from './headless-serve-ssh-registration' + +const { mockSshStore, mockConnectionManager } = mocks +const target: SshTarget = { + id: 'ssh-1', + label: 'build box', + host: 'build.example', + port: 22, + username: 'deploy' +} + +describe('SSH registration on a headless `orca serve`', () => { + const harness = createSshIpcHarness(mocks) + + beforeEach(async () => { + await harness.reset() + // Why: a headless serve never attached a window, so nothing registered the SSH layer. + await resetSshHandlerStateForTests() + mainProcessState.mainWindow = null + }) + + it('reproduces the unregistered host before bootstrap (#25886, #8489)', async () => { + expect(listRegisteredSshTargets()).toEqual([]) + await expect(connectRegisteredSshTarget('ssh-1')).rejects.toThrow('ssh_handlers_not_registered') + }) + + it('lists and connects persisted SSH targets with no window', async () => { + mockSshStore.listTargets.mockReturnValue([target]) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SSH harness store implements every method registerSshHandlers reads. + registerHeadlessServeSshHandlers(harness.mockStore as never, {} as never) + + expect(listRegisteredSshTargets()).toEqual([target]) + await expect(connectRegisteredSshTarget('ssh-1')).resolves.toMatchObject({ + targetId: 'ssh-1', + status: 'connected' + }) + expect(harness.mockWindow.webContents.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/headless-serve-ssh-registration.ts b/src/main/startup/headless-serve-ssh-registration.ts new file mode 100644 index 00000000000..d3bb7c32fe2 --- /dev/null +++ b/src/main/startup/headless-serve-ssh-registration.ts @@ -0,0 +1,14 @@ +import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { registerSshHandlers } from '../ipc/ssh' +import { mainProcessState as state } from './main-process-state' + +/** + * Loads the profile's SSH targets on an `orca serve` host before any window exists. + * Why: only window attach registered the SSH layer, so a headless serve listed no targets and + * every connect threw ssh_handlers_not_registered (#25886, #8489). A later window attach + * re-registers idempotently; until then broadcasts are skipped because there is no window. + */ +export function registerHeadlessServeSshHandlers(store: Store, runtime: OrcaRuntimeService): void { + registerSshHandlers(store, () => state.mainWindow ?? null, runtime) +} diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts index 0de7780d9db..acde65f4e65 100644 --- a/src/main/startup/main-process-i18n-menu.ts +++ b/src/main/startup/main-process-i18n-menu.ts @@ -10,6 +10,7 @@ import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store import { mainProcessState as state } from './main-process-state' import { openSettingsFromSystemMenu, + quitFromUserCommand, runUserInitiatedUpdateCheck, sendOpenCrashReport, sendOpenFeatureTour, @@ -28,6 +29,7 @@ export async function initializeMainProcessI18nAndMenu(): Promise { logStartupMilestone('i18n-ready') registerAppMenu({ appMenuLabel: state.devInstanceIdentity?.name ?? app.name, + ...(state.isServeMode ? { onQuit: quitFromUserCommand } : {}), onCheckForUpdates: (options) => { ensureAutoUpdaterConfigured() runUserInitiatedUpdateCheck(options) diff --git a/src/main/startup/main-process-runtime-launch-activation.test.ts b/src/main/startup/main-process-runtime-launch-activation.test.ts index 6fd2ec6ec57..36f8aa69f04 100644 --- a/src/main/startup/main-process-runtime-launch-activation.test.ts +++ b/src/main/startup/main-process-runtime-launch-activation.test.ts @@ -23,12 +23,21 @@ vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/tmp/orca-user-data', migrateMobilePairingDataToCanonicalUserDataPath: vi.fn() })) +const launchOrder = vi.hoisted((): string[] => []) vi.mock('../runtime/runtime-rpc', () => ({ OrcaRuntimeRpcServer: class { - start = vi.fn(async () => {}) + start = vi.fn(async () => { + launchOrder.push('rpc-start') + }) setOnUnpairedDeviceAuthFailure = vi.fn() } })) +vi.mock('../runtime/headless-runtime-graph', () => ({ publishHeadlessRuntimeGraph: vi.fn() })) +vi.mock('./headless-serve-ssh-registration', () => ({ + registerHeadlessServeSshHandlers: vi.fn(() => { + launchOrder.push('ssh-registered') + }) +})) vi.mock('../ipc/mobile', () => ({ registerMobileHandlers: vi.fn() })) vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: vi.fn(), @@ -66,6 +75,7 @@ vi.mock('./windows-install-dir-acl-recovery', () => ({ }) })) vi.mock('./serve-signal-handlers', () => ({ registerServeSignalHandlers: vi.fn() })) +vi.mock('./serve-native-quit-guard', () => ({ installServeNativeQuitGuard: vi.fn() })) vi.mock('../runtime/runtime-rpc-startup-failure', () => ({ recordRuntimeRpcStartFailure: vi.fn(), showRuntimeRpcStartupFailureDialog: vi.fn() @@ -104,6 +114,11 @@ const { mainProcessState: state } = await import('./main-process-state') const { createServeDesktopActivationGate } = await import('./serve-desktop-activation') const { focusExistingMainWindow } = await import('../window/focus-existing-window') const { AGENT_LAUNCH_RECORD_WARMUP_DELAY_MS } = await import('./agent-launch-record-warmup') +const { getServeOptions } = await import('./main-process-serve') +const { registerServeSignalHandlers } = await import('./serve-signal-handlers') +const { installServeNativeQuitGuard } = await import('./serve-native-quit-guard') +const { quitProcess } = await import('./process-quit-request') +const { quitFromUserCommand } = await import('./main-window-actions') type FakeWindow = { id: number @@ -256,4 +271,40 @@ describe('desktop startup activation', () => { expect(windows).toHaveLength(0) expect(state.desktopActivationGate).toBeNull() }) + + it('loads SSH targets on a windowless serve before paired clients can connect (#25886)', async () => { + launchOrder.length = 0 + state.isServeMode = true + vi.mocked(getServeOptions).mockReturnValueOnce({ + json: false, + pairingAddress: null, + noPairing: true, + mobilePairing: false, + grantDesktopControl: false, + recipeJson: false, + projectRoot: null + }) + Object.assign(state.runtime!, { + refreshRestoredOrchestrationAuthority: vi.fn(async () => {}), + reconcileLegacyWorkerTerminals: vi.fn(async () => {}) + }) + + await initializeMainProcessReady({ + openMainWindow: () => { + throw new Error('serve must not open a window') + }, + handleMacAppActivation: vi.fn() + }) + + expect(windows).toHaveLength(0) + expect(launchOrder).toEqual(['ssh-registered', 'rpc-start']) + // Signals mark their quit, so the macOS native-quit guard lets them through (#15537). + expect(registerServeSignalHandlers).toHaveBeenCalledWith(process, quitProcess) + expect(installServeNativeQuitGuard).toHaveBeenCalledWith( + expect.objectContaining({ + platform: process.platform, + closeDesktopWindows: quitFromUserCommand + }) + ) + }) }) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index f78a86f297a..6204ca50dc7 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -26,7 +26,8 @@ import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' import { repairKnownPoisonedInstallDirBeforeWindow } from './windows-install-dir-acl-recovery' -import { registerServeSignalHandlers } from './serve-signal-handlers' +import { installServeQuitHandling } from './serve-quit-handling' +import { registerHeadlessServeSshHandlers } from './headless-serve-ssh-registration' import { settleServeDesktopActivation } from './serve-desktop-activation' import { recordRuntimeRpcStartFailure, @@ -158,6 +159,8 @@ async function launchServeMode( ) } publishHeadlessRuntimeGraph(runtime) + // Why before RPC binds: the first paired client must already see this host's SSH targets. + registerHeadlessServeSshHandlers(state.store!, runtime) await runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start headless RPC transport:', error) throw error @@ -167,8 +170,7 @@ async function launchServeMode( // it simply never receives a push, because nothing dispatches notifications here. startDesktopPushService(runtimeRpc) settleDesktopActivation() - // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. - registerServeSignalHandlers(process, () => app.quit()) + installServeQuitHandling() // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). if (process.platform === 'darwin' || process.platform === 'linux') { try { diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts index f582acfb421..5bcfc74cdbc 100644 --- a/src/main/startup/main-process-serve.ts +++ b/src/main/startup/main-process-serve.ts @@ -44,7 +44,8 @@ export async function printServeReady(options: ServeOptions): Promise { : runtimeRpc.createPairingOffer({ address: options.pairingAddress, name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, - scope: options.mobilePairing ? 'mobile' : 'runtime' + scope: options.mobilePairing ? 'mobile' : 'runtime', + grants: options.grantDesktopControl ? ['desktop-control'] : [] }) const pairingQr = pairing.available && options.mobilePairing diff --git a/src/main/startup/main-window-actions-user-quit.test.ts b/src/main/startup/main-window-actions-user-quit.test.ts new file mode 100644 index 00000000000..8cb20db1bdb --- /dev/null +++ b/src/main/startup/main-window-actions-user-quit.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electron = vi.hoisted(() => ({ quit: vi.fn() })) +type FakeWindow = { isDestroyed: () => boolean; close: () => void } +const popout = vi.hoisted((): { window: FakeWindow | null } => ({ window: null })) + +vi.mock('electron', () => ({ app: { quit: electron.quit } })) +vi.mock('../updater', () => ({ checkForUpdatesFromMenu: vi.fn(), isQuittingForUpdate: vi.fn() })) +vi.mock('../tray/system-tray', () => ({ + createSystemTray: vi.fn(), + setMacMenuBarIconVisible: vi.fn() +})) +vi.mock('../window/attach-main-window-services', () => ({ ensureAutoUpdaterConfigured: vi.fn() })) +vi.mock('../window/focus-existing-window', () => ({ + focusExistingMainWindow: vi.fn(), + safelyRevealWindow: vi.fn() +})) +vi.mock('../window/dashboard-popout-window', () => ({ + getDashboardPopoutWindow: () => popout.window +})) +vi.mock('../window/createMainWindow', () => ({ loadMainWindow: vi.fn() })) +vi.mock('./windows-install-dir-acl-recovery', () => ({ + describeInstallDirAclPoison: vi.fn(), + isBlockingInstallDirAclRepairInFlight: vi.fn() +})) +vi.mock('../window/renderer-recovery-prompt', () => ({ presentRendererRecoveryPrompt: vi.fn() })) +vi.mock('../window/renderer-launch-failure-probe', () => ({ probeRendererLaunchCapacity: vi.fn() })) + +import { quitFromUserCommand } from './main-window-actions' +import { mainProcessState as state } from './main-process-state' +import { consumeUserQuitWindowClose } from '../window/user-quit-window-close' + +function fakeWindow() { + return { isDestroyed: () => false, close: vi.fn<() => void>() } +} + +describe('quitFromUserCommand', () => { + beforeEach(() => { + electron.quit.mockReset() + state.isQuitting = false + popout.window = null + }) + + it('only closes the desktop windows of an `orca serve` host (#15537)', () => { + const main = fakeWindow() + const dashboard = fakeWindow() + popout.window = dashboard + state.isServeMode = true + Object.assign(state, { mainWindow: main }) + + quitFromUserCommand() + + expect(electron.quit).not.toHaveBeenCalled() + expect(state.isQuitting).toBe(false) + expect(main.close).toHaveBeenCalledOnce() + expect(dashboard.close).toHaveBeenCalledOnce() + // The main window's close carries the user-Quit intent that arms the frozen-renderer deadline. + expect(consumeUserQuitWindowClose(main)).toBe(true) + }) + + it('quits a desktop app as before', () => { + state.isServeMode = false + Object.assign(state, { mainWindow: fakeWindow() }) + + quitFromUserCommand() + + expect(electron.quit).toHaveBeenCalledOnce() + expect(state.isQuitting).toBe(true) + }) +}) diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index c4303b79986..eb9a3babf57 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -11,6 +11,8 @@ import { import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' import { focusExistingMainWindow, safelyRevealWindow } from '../window/focus-existing-window' import { mainProcessState as state } from './main-process-state' +import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' +import { markUserQuitWindowClose } from '../window/user-quit-window-close' import { loadMainWindow } from '../window/createMainWindow' import { describeInstallDirAclPoison, @@ -63,12 +65,33 @@ export function openSettingsFromSystemMenu(): void { state.pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) } +/** + * A user Quit (menu, tray, renderer-recovery prompt). Under `orca serve` the process is the + * server its paired clients use, so a user Quit only closes the desktop windows (#15537); + * signals, supervisor stops and update installs still quit through app.quit(). + */ +export function quitFromUserCommand(): void { + if (state.isServeMode) { + // Why not getAllWindows(): offscreen browser-automation windows belong to the runtime. + for (const window of [state.mainWindow, getDashboardPopoutWindow()]) { + if (window && !window.isDestroyed()) { + if (window === state.mainWindow) { + markUserQuitWindowClose(window) + } + window.close() + } + } + return + } + state.isQuitting = true + app.quit() +} + export function quitFromSystemTray(): void { if (state.mainWindow && !state.mainWindow.isDestroyed()) { showMainWindowFromTray() } - state.isQuitting = true - app.quit() + quitFromUserCommand() } export function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { @@ -184,9 +207,6 @@ export async function showRendererRecoveryPrompt( } loadMainWindow(state.mainWindow) }, - quit: () => { - state.isQuitting = true - app.quit() - } + quit: quitFromUserCommand }) } diff --git a/src/main/startup/process-quit-request.ts b/src/main/startup/process-quit-request.ts new file mode 100644 index 00000000000..4377ab85302 --- /dev/null +++ b/src/main/startup/process-quit-request.ts @@ -0,0 +1,25 @@ +import { app } from 'electron' + +// Sticky: will-quit's second pass and a deferred updater quit re-enter before-quit. +let processQuitRequested = false + +/** + * Ends the process for a reason other than a user Quit: a signal, a supervisor, a relaunch. + * Why not bare app.quit(): a serve host turns an unmarked macOS quit into closing its windows. + */ +export function quitProcess(): void { + processQuitRequested = true + app.quit() +} + +export function markProcessQuitRequested(): void { + processQuitRequested = true +} + +export function isProcessQuitRequested(): boolean { + return processQuitRequested +} + +export function resetProcessQuitRequestForTest(): void { + processQuitRequested = false +} diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts index 8441f116805..b42fad932ea 100644 --- a/src/main/startup/serve-mode-argv.ts +++ b/src/main/startup/serve-mode-argv.ts @@ -12,6 +12,7 @@ const CLI_TO_SERVE_FLAG = new Map([ ['--json', '--serve-json'], ['--no-pairing', '--serve-no-pairing'], ['--mobile-pairing', '--serve-mobile-pairing'], + ['--grant-desktop-control', '--serve-grant-desktop-control'], ['--recipe-json', '--serve-recipe-json'] ]) diff --git a/src/main/startup/serve-native-quit-guard.test.ts b/src/main/startup/serve-native-quit-guard.test.ts new file mode 100644 index 00000000000..8f9b6f996e5 --- /dev/null +++ b/src/main/startup/serve-native-quit-guard.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type Listener = (...args: any[]) => void +const electron = vi.hoisted(() => { + const beforeQuit: Listener[] = [] + const powerListeners: Record = {} + return { beforeQuit, powerListeners, quit: vi.fn() } +}) + +vi.mock('electron', () => ({ + app: { + quit: electron.quit, + prependListener: (event: string, listener: Listener) => { + if (event === 'before-quit') { + electron.beforeQuit.unshift(listener) + } + } + }, + powerMonitor: { + on: (event: string, listener: Listener) => { + electron.powerListeners[event] = listener + } + } +})) + +import { installServeNativeQuitGuard } from './serve-native-quit-guard' +import { quitProcess, resetProcessQuitRequestForTest } from './process-quit-request' + +function emitBeforeQuit(): { preventDefault: ReturnType } { + const event = { preventDefault: vi.fn() } + for (const listener of electron.beforeQuit) { + listener(event) + } + return event +} + +describe('installServeNativeQuitGuard', () => { + const closeDesktopWindows = vi.fn() + const isQuittingForUpdate = vi.fn(() => false) + + beforeEach(() => { + electron.beforeQuit.length = 0 + for (const key of Object.keys(electron.powerListeners)) { + delete electron.powerListeners[key] + } + electron.quit.mockReset() + closeDesktopWindows.mockReset() + isQuittingForUpdate.mockReturnValue(false) + resetProcessQuitRequestForTest() + }) + + it('turns a macOS Dock or app-switcher Quit into closing the desktop windows (#15537)', () => { + installServeNativeQuitGuard({ platform: 'darwin', isQuittingForUpdate, closeDesktopWindows }) + + const event = emitBeforeQuit() + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(closeDesktopWindows).toHaveBeenCalledOnce() + }) + + it('lets a signal, supervisor or relaunch quit through, and keeps it allowed on re-entry', () => { + installServeNativeQuitGuard({ platform: 'darwin', isQuittingForUpdate, closeDesktopWindows }) + + quitProcess() + expect(electron.quit).toHaveBeenCalledOnce() + // will-quit's second pass calls app.quit() again. + expect(emitBeforeQuit().preventDefault).not.toHaveBeenCalled() + expect(emitBeforeQuit().preventDefault).not.toHaveBeenCalled() + expect(closeDesktopWindows).not.toHaveBeenCalled() + }) + + it('lets an update install quit through', () => { + isQuittingForUpdate.mockReturnValue(true) + installServeNativeQuitGuard({ platform: 'darwin', isQuittingForUpdate, closeDesktopWindows }) + + expect(emitBeforeQuit().preventDefault).not.toHaveBeenCalled() + }) + + it('does not cancel a macOS logout or shutdown', () => { + installServeNativeQuitGuard({ platform: 'darwin', isQuittingForUpdate, closeDesktopWindows }) + + electron.powerListeners.shutdown?.() + + expect(emitBeforeQuit().preventDefault).not.toHaveBeenCalled() + expect(closeDesktopWindows).not.toHaveBeenCalled() + }) + + it.each(['linux', 'win32'] as const)('installs nothing on %s', (platform) => { + installServeNativeQuitGuard({ platform, isQuittingForUpdate, closeDesktopWindows }) + + expect(electron.beforeQuit).toHaveLength(0) + expect(electron.powerListeners).toEqual({}) + }) +}) diff --git a/src/main/startup/serve-native-quit-guard.ts b/src/main/startup/serve-native-quit-guard.ts new file mode 100644 index 00000000000..d096c3cf8b0 --- /dev/null +++ b/src/main/startup/serve-native-quit-guard.ts @@ -0,0 +1,29 @@ +import { app, powerMonitor, type Event } from 'electron' +import { isProcessQuitRequested, markProcessQuitRequested } from './process-quit-request' + +/** + * On macOS, Dock > Quit, Cmd-Q from the app switcher and Activity Monitor's Quit reach before-quit + * as a native terminate. On a serve host that would drop every paired client (#15537), so an + * unmarked quit only closes the desktop windows. Signals, supervisors, relaunches and update + * installs mark their quit first; logout and shutdown mark it through powerMonitor. + */ +export function installServeNativeQuitGuard(deps: { + platform: NodeJS.Platform + isQuittingForUpdate: () => boolean + closeDesktopWindows: () => void +}): void { + // Why macOS only: elsewhere every before-quit comes from an app.quit() call this code owns. + if (deps.platform !== 'darwin') { + return + } + // Why: macOS sends logout/shutdown as the same terminate; vetoing it would cancel the logout. + powerMonitor.on('shutdown', markProcessQuitRequested) + // Why prepend: veto before the updater guard and before startup listeners begin teardown. + app.prependListener('before-quit', (event: Event) => { + if (isProcessQuitRequested() || deps.isQuittingForUpdate()) { + return + } + event.preventDefault() + deps.closeDesktopWindows() + }) +} diff --git a/src/main/startup/serve-options.test.ts b/src/main/startup/serve-options.test.ts index 9e2bb06919d..12625e574cd 100644 --- a/src/main/startup/serve-options.test.ts +++ b/src/main/startup/serve-options.test.ts @@ -12,6 +12,7 @@ describe('getServeOptions', () => { pairingAddress: null, noPairing: true, mobilePairing: false, + grantDesktopControl: false, recipeJson: false, projectRoot: null }) @@ -142,6 +143,7 @@ describe('getServeOptions', () => { pairingAddress: null, noPairing: false, mobilePairing: false, + grantDesktopControl: false, recipeJson: false, projectRoot: null }) diff --git a/src/main/startup/serve-options.ts b/src/main/startup/serve-options.ts index c0398123797..c121abd3c49 100644 --- a/src/main/startup/serve-options.ts +++ b/src/main/startup/serve-options.ts @@ -9,6 +9,7 @@ export type ServeOptions = { pairingAddress: string | null noPairing: boolean mobilePairing: boolean + grantDesktopControl: boolean recipeJson: boolean projectRoot: string | null } @@ -118,6 +119,10 @@ export function getServeOptions(argv: readonly string[]): ServeOptions { ), noPairing: lastBooleanValue(optionsArgv, ['--serve-no-pairing', '--no-pairing']), mobilePairing: lastBooleanValue(optionsArgv, ['--serve-mobile-pairing', '--mobile-pairing']), + grantDesktopControl: lastBooleanValue(optionsArgv, [ + '--serve-grant-desktop-control', + '--grant-desktop-control' + ]), recipeJson: lastBooleanValue(optionsArgv, ['--serve-recipe-json', '--recipe-json']), projectRoot: valueAfter( optionsArgv, diff --git a/src/main/startup/serve-quit-handling.ts b/src/main/startup/serve-quit-handling.ts new file mode 100644 index 00000000000..66cb6694ba3 --- /dev/null +++ b/src/main/startup/serve-quit-handling.ts @@ -0,0 +1,20 @@ +import { isQuittingForUpdate } from '../updater' +import { quitFromUserCommand } from './main-window-actions' +import { quitProcess } from './process-quit-request' +import { installServeNativeQuitGuard } from './serve-native-quit-guard' +import { registerServeSignalHandlers } from './serve-signal-handlers' + +/** + * Signals end a serve host; a user or OS-native Quit only closes its desktop windows (#15537). + * Why after app ready: these Node listeners then preempt Electron's native SIGTERM/SIGINT quit, + * so the native-quit guard only ever sees user and OS terminates. + */ +export function installServeQuitHandling(): void { + // Why every attempt reaches app.quit(): a page beforeunload can veto an earlier signal. + registerServeSignalHandlers(process, quitProcess) + installServeNativeQuitGuard({ + platform: process.platform, + isQuittingForUpdate, + closeDesktopWindows: quitFromUserCommand + }) +} diff --git a/src/main/startup/serve-signal-handlers.test.ts b/src/main/startup/serve-signal-handlers.test.ts index 35c70ef87de..1ec9da580ad 100644 --- a/src/main/startup/serve-signal-handlers.test.ts +++ b/src/main/startup/serve-signal-handlers.test.ts @@ -1,13 +1,26 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' -import { registerServeSignalHandlers } from './serve-signal-handlers' +import { registerServeSignalHandlers, type ServeSignalSource } from './serve-signal-handlers' + +function signalSourceFor(emitter: EventEmitter): ServeSignalSource { + return { + on: (event, listener) => emitter.on(event, listener), + removeListener: (event, listener) => emitter.removeListener(event, listener), + listeners: (event) => + emitter + .listeners(event) + .filter( + (listener): listener is (signal: NodeJS.Signals) => void => typeof listener === 'function' + ) + } +} describe('registerServeSignalHandlers', () => { it('retries a vetoed quit on every delivered signal', () => { const signalSource = new EventEmitter() const quitApplication = vi.fn() - registerServeSignalHandlers(signalSource, quitApplication) + registerServeSignalHandlers(signalSourceFor(signalSource), quitApplication) signalSource.emit('SIGINT') signalSource.emit('SIGINT') signalSource.emit('SIGTERM') @@ -18,4 +31,26 @@ describe('registerServeSignalHandlers', () => { expect(signalSource.listenerCount('SIGTERM')).toBe(1) expect(signalSource.listenerCount('SIGHUP')).toBe(1) }) + + it('drops every earlier listener before re-adding it, so Node reinstalls its signal handler', () => { + const signalSource = new EventEmitter() + const earlier = vi.fn() + signalSource.on('SIGTERM', earlier) + const counts: number[] = [] + signalSource.on('removeListener', (event) => { + if (event === 'SIGTERM') { + counts.push(signalSource.listenerCount('SIGTERM')) + } + }) + const quitApplication = vi.fn() + + registerServeSignalHandlers(signalSourceFor(signalSource), quitApplication) + signalSource.emit('SIGTERM') + + // Node closes a signal's native handle only when its last listener is removed. + expect(counts).toEqual([0]) + expect(signalSource.listeners('SIGTERM')).toEqual([earlier, quitApplication]) + expect(earlier).toHaveBeenCalledOnce() + expect(quitApplication).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/startup/serve-signal-handlers.ts b/src/main/startup/serve-signal-handlers.ts index 0df48d5ea35..0f4981a587c 100644 --- a/src/main/startup/serve-signal-handlers.ts +++ b/src/main/startup/serve-signal-handlers.ts @@ -1,13 +1,28 @@ -type ServeSignalSource = { - on(event: 'SIGINT' | 'SIGTERM' | 'SIGHUP', listener: () => void): unknown +type ServeSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP' +type SignalListener = (signal: NodeJS.Signals) => void + +export type ServeSignalSource = { + on(event: ServeSignal, listener: SignalListener): unknown + removeListener(event: ServeSignal, listener: SignalListener): unknown + listeners(event: ServeSignal): SignalListener[] } export function registerServeSignalHandlers( signalSource: ServeSignalSource, quitApplication: () => void ): void { - // Keep every listener installed so duplicate delivery cannot fall through to default termination. - signalSource.on('SIGINT', quitApplication) - signalSource.on('SIGTERM', quitApplication) - signalSource.on('SIGHUP', quitApplication) + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { + // Why reclaim: Electron's native handler replaces Node's for any signal that already had a + // listener before app ready, so these never ran and a SIGTERM arrived as a native quit. + // Dropping the last listener releases Node's handler; re-adding installs it over Electron's. + const existing = signalSource.listeners(signal) + for (const listener of existing) { + signalSource.removeListener(signal, listener) + } + for (const listener of existing) { + signalSource.on(signal, listener) + } + // Keep every listener installed so duplicate delivery cannot fall through to default termination. + signalSource.on(signal, quitApplication) + } } diff --git a/src/main/startup/serve-user-data-path-guard.test.ts b/src/main/startup/serve-user-data-path-guard.test.ts new file mode 100644 index 00000000000..5030ced5b37 --- /dev/null +++ b/src/main/startup/serve-user-data-path-guard.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import { checkServeUserDataPath } from './serve-user-data-path-guard' + +describe('checkServeUserDataPath', () => { + it('refuses a serve that landed on a profile other than the one the CLI chose', () => { + const env: NodeJS.ProcessEnv = { ORCA_SERVE_USER_DATA_PATH: '/isolated/profile' } + expect( + checkServeUserDataPath({ isServeMode: true, userDataPath: '/default/orca', env }) + ).toContain('/isolated/profile') + expect(env.ORCA_SERVE_USER_DATA_PATH).toBeUndefined() + }) + + it('allows the chosen profile, including another spelling of it', () => { + expect( + checkServeUserDataPath({ + isServeMode: true, + userDataPath: '/isolated/profile', + env: { ORCA_SERVE_USER_DATA_PATH: '/isolated/./profile/' } + }) + ).toBeNull() + expect( + checkServeUserDataPath({ + isServeMode: true, + userDataPath: 'C:\\Users\\Me\\Orca', + env: { ORCA_SERVE_USER_DATA_PATH: 'c:\\users\\me\\orca' }, + platform: 'win32' + }) + ).toBeNull() + }) + + it('leaves launches without a CLI choice, or outside serve, alone', () => { + expect(checkServeUserDataPath({ isServeMode: true, userDataPath: '/a', env: {} })).toBeNull() + const env: NodeJS.ProcessEnv = { ORCA_SERVE_USER_DATA_PATH: '/b' } + expect(checkServeUserDataPath({ isServeMode: false, userDataPath: '/a', env })).toBeNull() + expect(env.ORCA_SERVE_USER_DATA_PATH).toBeUndefined() + }) +}) + +describe('configureOrcaUserDataPathEnv on a serve launch', () => { + it('throws before the instance lock instead of publishing a mismatched profile', async () => { + vi.doMock('electron', () => ({ app: { getPath: () => '/default/orca' } })) + const { configureOrcaUserDataPathEnv } = await import('./configure-process') + const argv = process.argv + const previous = process.env.ORCA_USER_DATA_PATH + process.argv = [...argv, '--serve'] + process.env.ORCA_SERVE_USER_DATA_PATH = '/isolated/profile' + process.env.ORCA_USER_DATA_PATH = '/isolated/profile' + try { + expect(() => configureOrcaUserDataPathEnv()).toThrow('/isolated/profile') + expect(process.env.ORCA_USER_DATA_PATH).toBe('/isolated/profile') + } finally { + process.argv = argv + delete process.env.ORCA_SERVE_USER_DATA_PATH + if (previous === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previous + } + vi.doUnmock('electron') + } + }) +}) diff --git a/src/main/startup/serve-user-data-path-guard.ts b/src/main/startup/serve-user-data-path-guard.ts new file mode 100644 index 00000000000..1bf58094cf1 --- /dev/null +++ b/src/main/startup/serve-user-data-path-guard.ts @@ -0,0 +1,27 @@ +import { SERVE_USER_DATA_PATH_ENV, isSameUserDataPath } from '../../shared/serve-user-data-path' + +/** + * Returns the refusal message when this serve launch landed on a profile other than the one + * `orca serve` resolved, or null when it may continue to the instance lock. + * + * Why before the lock: losing another profile's lock delivers a second-instance request to + * whatever app owns that profile, often the user's own desktop. + */ +export function checkServeUserDataPath(options: { + isServeMode: boolean + userDataPath: string + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform +}): string | null { + const env = options.env ?? process.env + const expected = env[SERVE_USER_DATA_PATH_ENV] + // Why consume: serve-hosted terminals must not inherit a stale expectation into a later launch. + delete env[SERVE_USER_DATA_PATH_ENV] + if (!options.isServeMode || !expected) { + return null + } + if (isSameUserDataPath(expected, options.userDataPath, options.platform)) { + return null + } + return `[serve] orca serve chose the profile at ${expected}, but this launch resolved ${options.userDataPath}; refusing to start so it cannot touch another profile's running Orca.` +} diff --git a/src/main/window/createMainWindow-user-quit-close.test.ts b/src/main/window/createMainWindow-user-quit-close.test.ts new file mode 100644 index 00000000000..fe9aa5a2beb --- /dev/null +++ b/src/main/window/createMainWindow-user-quit-close.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', async () => + (await import('./createMainWindow-test-harness')).electronModuleMock() +) +vi.mock('@electron-toolkit/utils', async () => + (await import('./createMainWindow-test-harness')).electronToolkitUtilsMock() +) +vi.mock('./macos-tahoe-release', async () => + (await import('./createMainWindow-test-harness')).macosTahoeReleaseMock() +) +vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock()) +vi.mock('../browser/browser-manager', async () => + (await import('./createMainWindow-test-harness')).browserManagerMock() +) + +import { ipcMain } from 'electron' +import { createMainWindow, WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS } from './createMainWindow' +import { resetExpectedTeardownStateForTest } from '../crash-reporting/expected-teardown-state' +import { browserWindowMock, resetMainWindowMocks } from './createMainWindow-test-harness' +import { markUserQuitWindowClose } from './user-quit-window-close' + +type Handler = (...args: any[]) => void + +function setupWindow() { + const windowHandlers: Record = {} + const ipcHandlers: Record = {} + vi.mocked(ipcMain.on).mockImplementation((channel, handler) => { + ipcHandlers[channel] = handler + return ipcMain + }) + const webContents = { + id: 42, + on: vi.fn((event: string, handler: Handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isCrashed: vi.fn(() => false) + } + const instance = { + webContents, + on: vi.fn((event: string, handler: Handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + isMinimized: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + destroy: vi.fn(), + loadFile: vi.fn(() => Promise.resolve()), + loadURL: vi.fn(() => Promise.resolve()) + } + browserWindowMock.mockImplementation(function () { + return instance + }) + return { windowHandlers, ipcHandlers, webContents, instance } +} + +function lastCloseRequestId(send: ReturnType): number { + const requests = send.mock.calls.filter(([channel]) => channel === 'window:close-requested') + const request: unknown = requests.at(-1)?.[1] + return typeof request === 'object' && request !== null && 'requestId' in request + ? Number(request.requestId) + : -1 +} + +describe('serve user-Quit window close', () => { + const originalPlatform = process.platform + + beforeEach(() => { + resetMainWindowMocks() + resetExpectedTeardownStateForTest() + vi.useFakeTimers() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + vi.useRealTimers() + }) + + it('destroys a frozen renderer after the quit ack deadline without quitting the process', async () => { + const { windowHandlers, webContents, instance } = setupWindow() + createMainWindow(null, { getIsQuitting: () => false }) + + markUserQuitWindowClose(instance) + windowHandlers.close({ preventDefault: vi.fn() }) + + expect(webContents.send).toHaveBeenCalledWith('window:close-requested', { + isQuitting: false, + requestId: expect.any(Number) + }) + await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS) + expect(instance.destroy).toHaveBeenCalledOnce() + }) + + it('leaves a responsive renderer free to veto the close', async () => { + const { windowHandlers, ipcHandlers, webContents, instance } = setupWindow() + createMainWindow(null, { getIsQuitting: () => false }) + + markUserQuitWindowClose(instance) + windowHandlers.close({ preventDefault: vi.fn() }) + ipcHandlers['window:close-request-received']?.( + { sender: { id: 42 } }, + lastCloseRequestId(webContents.send) + ) + await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS) + + expect(instance.destroy).not.toHaveBeenCalled() + }) + + it('keeps a plain window close without a deadline', async () => { + const { windowHandlers, instance } = setupWindow() + createMainWindow(null, { getIsQuitting: () => false }) + + markUserQuitWindowClose(instance) + windowHandlers.close({ preventDefault: vi.fn() }) + windowHandlers['will-prevent-unload']?.() + windowHandlers.close({ preventDefault: vi.fn() }) + // The first attempt's deadline is cleared by the veto; the second is a plain close. + await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS * 2) + + expect(instance.destroy).not.toHaveBeenCalled() + }) + + it('closes instead of hiding to the Windows tray', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const { windowHandlers, webContents, instance } = setupWindow() + const store = { + getUI: vi.fn(() => ({ trayMinimizeNoticeShown: true })), + getSettings: vi.fn(() => ({ windowBackgroundBlur: false, minimizeToTrayOnClose: true })), + updateUI: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path reads only getUI/getSettings/updateUI. + createMainWindow(store as never, { getIsQuitting: () => false }) + + markUserQuitWindowClose(instance) + windowHandlers.close({ preventDefault: vi.fn() }) + + expect(instance.hide).not.toHaveBeenCalled() + expect(webContents.send).toHaveBeenCalledWith( + 'window:close-requested', + expect.objectContaining({ isQuitting: false }) + ) + }) +}) diff --git a/src/main/window/main-window-close-lifecycle.ts b/src/main/window/main-window-close-lifecycle.ts index 0bf1c04ef88..d18c76cfccb 100644 --- a/src/main/window/main-window-close-lifecycle.ts +++ b/src/main/window/main-window-close-lifecycle.ts @@ -7,6 +7,7 @@ import type { CreateMainWindowOptions } from './main-window-contracts' import type { MainWindowFocusLifecycle } from './main-window-focus-lifecycle' import type { MainWindowStateLifecycle } from './main-window-state-lifecycle' import { syncTrafficLightPosition } from './main-window-visual-lifecycle' +import { consumeUserQuitWindowClose } from './user-quit-window-close' export const WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS = QUIT_RENDERER_ACK_TIMEOUT_MS @@ -90,8 +91,9 @@ export function installMainWindowCloseLifecycle(args: { } mainWindow.on('close', (e) => { + const userQuitClose = consumeUserQuitWindowClose(mainWindow) // Why: Alt+F4/programmatic closes hit the native event; apply the same minimize-to-tray guard the renderer-drawn X uses. - if (!windowCloseConfirmed && hideToTrayIfEnabled()) { + if (!windowCloseConfirmed && !userQuitClose && hideToTrayIfEnabled()) { e.preventDefault() return } @@ -115,7 +117,8 @@ export function installMainWindowCloseLifecycle(args: { e.preventDefault() const isQuitting = opts?.getIsQuitting?.() ?? false const requestId = ++closeRequestSequence - if (isQuitting) { + // Why userQuitClose: a serve host's user Quit only closes windows, yet a frozen renderer must not trap it. + if (isQuitting || userQuitClose) { armQuitRendererAckTimer(requestId) } // Why: renderer owns the close decision; the always-mounted App root subscription lets even pre-workspace states reply (#5144). diff --git a/src/main/window/user-quit-window-close.ts b/src/main/window/user-quit-window-close.ts new file mode 100644 index 00000000000..c65f86d0b51 --- /dev/null +++ b/src/main/window/user-quit-window-close.ts @@ -0,0 +1,20 @@ +import type { BrowserWindow } from 'electron' + +/** + * Marks a window close that a user Quit asked for on a host that stays running (`orca serve`). + * Why separate from isQuitting: the process is not quitting, but the close still needs the quit + * close semantics — the frozen-renderer ack deadline and no minimize-to-tray. + */ +/** Only the window's identity is used, so a narrow slice of BrowserWindow is enough. */ +export type UserQuitClosingWindow = Pick + +const pendingUserQuitCloses = new WeakSet() + +export function markUserQuitWindowClose(window: UserQuitClosingWindow): void { + pendingUserQuitCloses.add(window) +} + +/** One close attempt consumes the mark, so a later plain close keeps its own semantics. */ +export function consumeUserQuitWindowClose(window: UserQuitClosingWindow): boolean { + return pendingUserQuitCloses.delete(window) +} diff --git a/src/main/worktree-removal-close-records.test.ts b/src/main/worktree-removal-close-records.test.ts index 0b3722482c8..cfad31f4d20 100644 --- a/src/main/worktree-removal-close-records.test.ts +++ b/src/main/worktree-removal-close-records.test.ts @@ -198,7 +198,7 @@ describe('close records on workspace removal', () => { ]) }) - store.removeProject('repo-a') + store.removeProjectForHost('repo-a', 'local') expect( Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index 36859897938..b7eae7dbb03 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -24,8 +24,6 @@ export const reposApi = { getDefaultCreateProjectParent: (): Promise => ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), - remove: (args) => ipcRenderer.invoke('repos:remove', args), - removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), reorder: (args) => ipcRenderer.invoke('repos:reorder', args), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 409154f0c15..dd76d3f1b23 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -34,7 +34,6 @@ export type RepositoryApi = { kind?: 'git' | 'folder' displayName?: string }) => Promise<{ repo: Repo } | { error: string }> - remove: (args: { repoId: string }) => Promise // Forget a project on one execution host only, leaving the same repo id on other hosts intact. removeForHost: (args: { repoId: string; hostId: string }) => Promise reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 2dd6a57b55a..f9519ed859f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -174,12 +174,8 @@ const NativeChatComposerPane = forwardRef {continuation.offeredTurnItemId ?? 'none'} {String(continuation.view.continueAvailable)} - - {composerError ?? continuation.continueError?.text ?? 'none'} - + {composerError ?? continuation.continueError?.text ?? 'none'} diff --git a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx index 176eb8adad1..f190bdce7ad 100644 --- a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx @@ -120,6 +120,36 @@ export function SshHostAdvancedFields({ )} /> +
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.allowRemoteCliControlHelp', + 'When off, orca commands run on this host reach only its own terminals. Turn on to let them reach other hosts, orchestration, files, and integrations on this computer.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + allowRemoteCliControl: !f.allowRemoteCliControl + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.allowRemoteCliControl', + "Allow this host's orca CLI to control Orca" + )} + /> +
{ +async function renderCard( + state: SshConnectionState | undefined, + cardTarget: SshTarget = target +): Promise { const container = document.createElement('div') document.body.appendChild(container) await act(async () => { createRoot(container).render( { expect(container.textContent).not.toContain('Host key verification failed') }) }) + +describe('the Host Node runtime on an SSH target card', () => { + const connected: SshConnectionState = { + targetId: 'target-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + + it('marks a relay running on the opt-in Host Node runtime as unsupported', async () => { + const container = await renderCard( + { ...connected, hostNodeRuntime: true }, + { ...target, remoteRuntime: 'legacy' } + ) + + const note = container.querySelector('[data-ssh-host-node-runtime]') + expect(note?.textContent).toContain('Unsupported configuration') + expect(note?.textContent).toContain('Set Runtime to Auto') + }) + + it('marks an Auto host that fell back to Host Node without advice it already follows', async () => { + const container = await renderCard({ ...connected, hostNodeRuntime: true }) + + const note = container.querySelector('[data-ssh-host-node-runtime]') + expect(note?.textContent).toContain('Unsupported configuration') + expect(note?.textContent).toContain('Orca-managed Node isn’t available') + expect(note?.textContent).not.toContain('Set Runtime to Auto') + }) + + it('says nothing about the runtime on a default connect', async () => { + const container = await renderCard(connected) + + expect(container.querySelector('[data-ssh-host-node-runtime]')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx index fc9319600bf..847c610a1d6 100644 --- a/src/renderer/src/components/settings/SshTargetCard.tsx +++ b/src/renderer/src/components/settings/SshTargetCard.tsx @@ -321,6 +321,23 @@ export function SshTargetCard({ {state.plainSsh.message}

) : null} + {status === 'connected' && state?.hostNodeRuntime ? ( +

+ {/* Why two: only an opt-in can be undone in settings; Auto lands here when Orca-managed Node couldn't run. */} + {target.remoteRuntime === 'legacy' + ? translate( + 'auto.components.settings.SshTargetCard.hostNodeUnsupported', + 'Unsupported configuration: Orca runs on this host’s Node.js with terminal support installed by npm on the host. Set Runtime to Auto in this host’s SSH settings to use Orca-managed Node.' + ) + : translate( + 'auto.components.settings.SshTargetCard.hostNodeFallback', + 'Unsupported configuration: Orca-managed Node isn’t available for this connection, so Orca runs on this host’s Node.js with terminal support installed by npm on the host.' + )} +

+ ) : null}
diff --git a/src/renderer/src/components/settings/ssh-target-draft.ts b/src/renderer/src/components/settings/ssh-target-draft.ts index 38d68734f6a..6c0aed22cc2 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.ts @@ -25,6 +25,7 @@ export type EditingTarget = { relayGracePeriodSeconds: string relayKeepAliveUntilReset: boolean remoteRuntime: SshRemoteRuntimeChoice + allowRemoteCliControl: boolean } export const EMPTY_FORM: EditingTarget = { @@ -40,7 +41,8 @@ export const EMPTY_FORM: EditingTarget = { systemSshConnectionReuse: true, relayGracePeriodSeconds: String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS), relayKeepAliveUntilReset: DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS === 0, - remoteRuntime: 'auto' + remoteRuntime: 'auto', + allowRemoteCliControl: false } export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { @@ -65,7 +67,8 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { ), relayKeepAliveUntilReset: (target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) === 0, - remoteRuntime: target.remoteRuntime ?? 'auto' + remoteRuntime: target.remoteRuntime ?? 'auto', + allowRemoteCliControl: target.allowRemoteCliControl === true } } @@ -173,7 +176,8 @@ export function hasAdvancedConnectionValues(form: EditingTarget): boolean { form.proxyCommand.trim().length > 0 || form.jumpHost.trim().length > 0 || !form.systemSshConnectionReuse || - form.remoteRuntime !== 'auto' + form.remoteRuntime !== 'auto' || + form.allowRemoteCliControl ) } @@ -191,7 +195,8 @@ export function isSshTargetFormDirty(current: EditingTarget, baseline: EditingTa current.systemSshConnectionReuse !== baseline.systemSshConnectionReuse || current.relayGracePeriodSeconds !== baseline.relayGracePeriodSeconds || current.relayKeepAliveUntilReset !== baseline.relayKeepAliveUntilReset || - current.remoteRuntime !== baseline.remoteRuntime + current.remoteRuntime !== baseline.remoteRuntime || + current.allowRemoteCliControl !== baseline.allowRemoteCliControl ) } diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.test.ts b/src/renderer/src/components/settings/ssh-target-save-payload.test.ts index de2b0e22404..386a8e7113d 100644 --- a/src/renderer/src/components/settings/ssh-target-save-payload.test.ts +++ b/src/renderer/src/components/settings/ssh-target-save-payload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { EMPTY_FORM } from './ssh-target-draft' +import { EMPTY_FORM, getEditingTargetForSshTarget, isSshTargetFormDirty } from './ssh-target-draft' import { buildSshTargetSavePayload } from './ssh-target-save-payload' describe('buildSshTargetSavePayload', () => { @@ -105,4 +105,33 @@ describe('buildSshTargetSavePayload', () => { expect(result.error).toContain('Terminal timeout') } }) + + it('keeps remote CLI control off unless the user opts this host in, and clears it on update', () => { + const off = buildSshTargetSavePayload({ ...EMPTY_FORM, host: 'gpu.example.com' }) + const on = buildSshTargetSavePayload({ + ...EMPTY_FORM, + host: 'gpu.example.com', + allowRemoteCliControl: true + }) + if (!off.ok || !on.ok) { + throw new Error('payload rejected') + } + expect(off.payload.target).not.toHaveProperty('allowRemoteCliControl') + expect(off.payload.updates).toHaveProperty('allowRemoteCliControl', undefined) + expect(on.payload.target).toMatchObject({ allowRemoteCliControl: true }) + expect(on.payload.updates).toMatchObject({ allowRemoteCliControl: true }) + }) + + it('round-trips the remote CLI control opt-in through the edit form', () => { + const form = getEditingTargetForSshTarget({ + id: 'gpu', + label: 'gpu', + host: 'gpu.example.com', + port: 22, + username: 'me', + allowRemoteCliControl: true + }) + expect(form.allowRemoteCliControl).toBe(true) + expect(isSshTargetFormDirty({ ...form, allowRemoteCliControl: false }, form)).toBe(true) + }) }) diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.ts b/src/renderer/src/components/settings/ssh-target-save-payload.ts index 75434e72b09..f5cf2dc8d2d 100644 --- a/src/renderer/src/components/settings/ssh-target-save-payload.ts +++ b/src/renderer/src/components/settings/ssh-target-save-payload.ts @@ -72,7 +72,8 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay ...(proxyCommand ? { proxyCommand } : {}), ...(jumpHost ? { jumpHost } : {}), ...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}), - ...(remoteRuntime ? { remoteRuntime } : {}) + ...(remoteRuntime ? { remoteRuntime } : {}), + ...(form.allowRemoteCliControl ? { allowRemoteCliControl: true } : {}) } return { @@ -89,6 +90,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay jumpHost, systemSshConnectionReuse, remoteRuntime, + allowRemoteCliControl: form.allowRemoteCliControl || undefined, source: 'manual' } } diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index 0b5bd3cdb46..f1b8856e14f 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -13,7 +13,7 @@ import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId, - type ExecutionHostId + normalizeExecutionHostId } from '../../../../shared/execution-host' // Why: interpolated into the sentence so locales control where the name sits; @@ -29,7 +29,8 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { const isOpen = activeModal === 'confirm-remove-folder' const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : '' const displayName = typeof modalData.displayName === 'string' ? modalData.displayName : '' - const hostId = typeof modalData.hostId === 'string' ? (modalData.hostId as ExecutionHostId) : null + const hostId = + typeof modalData.hostId === 'string' ? normalizeExecutionHostId(modalData.hostId) : null // Why: for an SSH project the files live on the remote host's disk, not the // user's — "still on your disk" would be misleading. Name the host (using the @@ -75,11 +76,9 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { const [descriptionBeforeName, descriptionAfterName] = description.split(NAME_TOKEN) const handleConfirm = useCallback(() => { - if (repoId) { - void removeProject(repoId, { - ...(hostId ? { hostId } : {}), - errorFeedback: 'toast' - }) + // Why: without the opener's host a bare id could remove another host's row (#13071). + if (repoId && hostId) { + void removeProject(repoId, { hostId, errorFeedback: 'toast' }) } closeModal() }, [closeModal, hostId, removeProject, repoId]) diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index dafda9e5f5f..9d21a0c1212 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -10,7 +10,7 @@ import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import { resolveWorktreeBatchDeleteTargets, toWorktreeDeleteIdentities, @@ -54,12 +54,11 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt hostId: target.hostId, settings: state.settings }) - const hostId = repo ? getRepoExecutionHostId(repo) : target.hostId // Why: git refuses to delete the primary checkout; users can still remove the owning project from Orca (disk contents kept). state.openModal('confirm-remove-folder', { repoId: target.repoId, displayName: repo?.displayName ?? target.displayName, - ...(hostId ? { hostId } : {}) + hostId: getWorktreeExecutionHostId(target, repo ?? undefined) }) return } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts index 1f8a1c7d0fc..e872092eddf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts @@ -207,6 +207,25 @@ describe('fresh spawn leaves a local pane unbound', () => { ) }) + // #21195: connect() resolves empty while the remote transport still holds an armed retry. + it('leaves recovery to a transport that owns its own retry', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = Object.assign(createMockTransport(), { ownsRecovery: vi.fn(() => true) }) + transport.connect.mockImplementation(async () => undefined) + transportFactoryQueue.push(transport) + + const deps = createDeps({ tabId: 'tab-transport-retry' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocks cover every pane, manager, and deps member the connect path reads. + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(40) + + expect(transport.connect).toHaveBeenCalled() + expect(transport.ownsRecovery).toHaveBeenCalled() + expect(requestTerminalPaneRecovery).not.toHaveBeenCalledWith( + expect.objectContaining({ reason: 'spawn-left-pane-unbound' }) + ) + }) + it('does not remount when the spawn bound a PTY', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-bound') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 24c89af5181..14c2a0b98c3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -160,7 +160,8 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio ...(meta.snapshotCols !== undefined && meta.snapshotRows !== undefined ? { snapshotCols: meta.snapshotCols, snapshotRows: meta.snapshotRows } : {}), - ...(meta.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}) + ...(meta.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}), + ...(meta.keepsLocalScrollback ? { keepsLocalScrollback: true } : {}) } session.scheduleReplayDataDrain() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts new file mode 100644 index 00000000000..60fba81b1d9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -0,0 +1,112 @@ +import { useAppStore } from '@/store' +import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention' +import type { IpcPtyTransportOptions } from '../pty-transport-types' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** The spawn/attach options a pane hands its PTY transport. */ +export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPtyTransportOptions { + return { + terminalKittyKeyboardProtocol: + session.pane.terminal.options.vtExtensions?.kittyKeyboard === true, + cwd: session.deps.cwd, + ...(session.deps.cwdPromise || + session.deps.preconnectInput?.length || + session.buffersInputOnlyForReattach + ? { bufferInputUntilConnect: true } + : {}), + ...(session.deps.preconnectInput?.length + ? { preconnectInput: session.deps.preconnectInput } + : {}), + ...(session.deps.onPreconnectInput + ? { onPreconnectInput: session.deps.onPreconnectInput } + : {}), + // Why: only fresh local IPC spawns may recover from a saved startup cwd + // whose directory was deleted (#7239); remote-runtime and SSH spawns + // resolve cwd on another host and must keep exact cwd semantics. + ...(session.runtimeEnvironmentId === null && !session.connectionId + ? { cwdFallback: 'worktree' as const } + : {}), + env: session.paneEnv, + ...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}), + command: session.shouldDeliverStartupViaTerminalPaste + ? undefined + : session.paneStartup?.command, + ...(session.shouldUseProviderSshStartupDelivery + ? { commandDelivery: 'provider' as const } + : {}), + startupCommandDelivery: session.shouldDeliverStartupViaTerminalPaste + ? undefined + : session.connectionId && session.paneStartup?.command + ? 'shell-ready' + : session.paneStartup?.startupCommandDelivery, + connectionId: session.connectionId, + executionHostId: session.executionHostId, + worktreeId: session.deps.worktreeId, + // Why: closes the SIGKILL race documented in INVESTIGATION.md by letting + // main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before + // pty:spawn returns. Daemon-host-only: SSH path leaves these undefined + // and the main-side guard short-circuits. + tabId: session.deps.tabId, + leafId: session.pane.leafId, + ...(session.deps.placement ? { placement: session.deps.placement } : {}), + activate: session.deps.isActiveRef.current && session.deps.isVisibleRef.current, + ...(session.shellOverride ? { shellOverride: session.shellOverride } : {}), + ...(session.projectRuntime ? { projectRuntime: session.projectRuntime } : {}), + ...(session.terminalColorQueryReplies + ? { terminalColorQueryReplies: session.terminalColorQueryReplies } + : {}), + ...(session.paneStartup?.launchConfig + ? { launchConfig: session.paneStartup.launchConfig } + : {}), + ...(session.paneStartup?.resumeProviderSession + ? { resumeProviderSession: session.paneStartup.resumeProviderSession } + : {}), + ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) + ? { + agentPrompt: + session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt + } + : {}), + ...(session.paneStartup?.initialAgentStatus?.prompt + ? { agentPromptDelivery: 'auto-submit' as const } + : session.paneStartup?.draftPrompt + ? { agentPromptDelivery: 'draft' as const } + : {}), + ...(session.paneStartup?.agentArgsOverride !== undefined + ? { agentArgsOverride: session.paneStartup.agentArgsOverride } + : {}), + ...(session.agentLaunchPreferences + ? { agentLaunchPreferences: session.agentLaunchPreferences } + : {}), + ...(session.launchToken ? { launchToken: session.launchToken } : {}), + ...(session.paneStartup?.launchAgent ? { launchAgent: session.paneStartup.launchAgent } : {}), + ...(session.paneStartup?.telemetry ? { telemetry: session.paneStartup.telemetry } : {}), + onPtyExit: session.onExit, + onPtySpawn: session.onPtySpawn, + onPtyRebind: session.onPtyRebind, + retainDisposedSpawn: () => + shouldRetainDisposedPaneSpawn( + useAppStore.getState(), + session.deps.worktreeId, + session.deps.tabId, + session.pane.leafId, + session.executionHostId + ), + ...(session.mainSideEffectAuthority + ? {} + : { + onTitleChange: session.onTitleChange, + onBell: session.onBell, + onAgentBecameIdle: session.onAgentBecameIdle, + onAgentBecameWorking: session.onAgentBecameWorking, + onAgentExited: session.onAgentExited + }), + // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService + // parses OSC 9999 before renderer delivery and forwards through the hook + // server with local/SSH identity. Remote-runtime streams do not pass through + // local main, so the renderer remains their status owner for now. + ...(session.shouldOwnAgentStatusInRenderer + ? { onAgentStatus: session.handleRendererOwnedAgentStatus } + : {}) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index d6f8a6de9c6..37faa8d2cea 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -21,7 +21,7 @@ import { import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { TRANSPORT_CONNECT_SETTLE_GRACE_MS } from './pty-connect-limits' -import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention' +import { buildPaneTransportOptions } from './pane-transport-options' import { buffersInputOnlyForSshReattach } from './ssh-reattach-input-buffering' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -39,110 +39,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { : undefined session.agentLaunchPreferences = toAgentLaunchPreferences(session.paneStartup?.sessionOptions) session.buffersInputOnlyForReattach = buffersInputOnlyForSshReattach(session) - session.transportOptions = { - terminalKittyKeyboardProtocol: - session.pane.terminal.options.vtExtensions?.kittyKeyboard === true, - cwd: session.deps.cwd, - ...(session.deps.cwdPromise || - session.deps.preconnectInput?.length || - session.buffersInputOnlyForReattach - ? { bufferInputUntilConnect: true } - : {}), - ...(session.deps.preconnectInput?.length - ? { preconnectInput: session.deps.preconnectInput } - : {}), - ...(session.deps.onPreconnectInput - ? { onPreconnectInput: session.deps.onPreconnectInput } - : {}), - // Why: only fresh local IPC spawns may recover from a saved startup cwd - // whose directory was deleted (#7239); remote-runtime and SSH spawns - // resolve cwd on another host and must keep exact cwd semantics. - ...(session.runtimeEnvironmentId === null && !session.connectionId - ? { cwdFallback: 'worktree' as const } - : {}), - env: session.paneEnv, - ...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}), - command: session.shouldDeliverStartupViaTerminalPaste - ? undefined - : session.paneStartup?.command, - ...(session.shouldUseProviderSshStartupDelivery - ? { commandDelivery: 'provider' as const } - : {}), - startupCommandDelivery: session.shouldDeliverStartupViaTerminalPaste - ? undefined - : session.connectionId && session.paneStartup?.command - ? 'shell-ready' - : session.paneStartup?.startupCommandDelivery, - connectionId: session.connectionId, - executionHostId: session.executionHostId, - worktreeId: session.deps.worktreeId, - // Why: closes the SIGKILL race documented in INVESTIGATION.md by letting - // main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before - // pty:spawn returns. Daemon-host-only: SSH path leaves these undefined - // and the main-side guard short-circuits. - tabId: session.deps.tabId, - leafId: session.pane.leafId, - ...(session.deps.placement ? { placement: session.deps.placement } : {}), - activate: session.deps.isActiveRef.current && session.deps.isVisibleRef.current, - ...(session.shellOverride ? { shellOverride: session.shellOverride } : {}), - ...(session.projectRuntime ? { projectRuntime: session.projectRuntime } : {}), - ...(session.terminalColorQueryReplies - ? { terminalColorQueryReplies: session.terminalColorQueryReplies } - : {}), - ...(session.paneStartup?.launchConfig - ? { launchConfig: session.paneStartup.launchConfig } - : {}), - ...(session.paneStartup?.resumeProviderSession - ? { resumeProviderSession: session.paneStartup.resumeProviderSession } - : {}), - ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) - ? { - agentPrompt: - session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt - } - : {}), - ...(session.paneStartup?.initialAgentStatus?.prompt - ? { agentPromptDelivery: 'auto-submit' as const } - : session.paneStartup?.draftPrompt - ? { agentPromptDelivery: 'draft' as const } - : {}), - ...(session.paneStartup?.agentArgsOverride !== undefined - ? { agentArgsOverride: session.paneStartup.agentArgsOverride } - : {}), - ...(session.agentLaunchPreferences - ? { agentLaunchPreferences: session.agentLaunchPreferences } - : {}), - ...(session.launchToken ? { launchToken: session.launchToken } : {}), - ...(session.paneStartup?.launchAgent ? { launchAgent: session.paneStartup.launchAgent } : {}), - ...(session.paneStartup?.telemetry ? { telemetry: session.paneStartup.telemetry } : {}), - onPtyExit: session.onExit, - onPtySpawn: session.onPtySpawn, - onPtyRebind: session.onPtyRebind, - retainDisposedSpawn: () => - shouldRetainDisposedPaneSpawn( - useAppStore.getState(), - session.deps.worktreeId, - session.deps.tabId, - session.pane.leafId, - session.executionHostId - ), - ...(session.mainSideEffectAuthority - ? {} - : { - onTitleChange: session.onTitleChange, - onBell: session.onBell, - onAgentBecameIdle: session.onAgentBecameIdle, - onAgentBecameWorking: session.onAgentBecameWorking, - onAgentExited: session.onAgentExited - }), - // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService - // parses OSC 9999 before renderer delivery and forwards through the hook - // server with local/SSH identity. Remote-runtime streams do not pass through - // local main, so the renderer remains their status owner for now. - ...(session.shouldOwnAgentStatusInRenderer - ? { onAgentStatus: session.handleRendererOwnedAgentStatus } - : {}) - } + session.transportOptions = buildPaneTransportOptions(session) if (session.connectionOwnerHydrating) { // Why: this pane holds an inert transport until its host resolves; register it so // the repos:changed handler remounts it instead of leaving the terminal blank. @@ -317,7 +214,9 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { const connectStillSettling = session.transportConnectInFlightSince !== null && Date.now() - session.transportConnectInFlightSince < TRANSPORT_CONNECT_SETTLE_GRACE_MS - if (connectStillSettling || session.disposed) { + // Why: a transport that is retrying its own binding owns recovery; a remount would race it. + const transportOwnsRecovery = !providerRejected && session.transport.ownsRecovery?.() === true + if (connectStillSettling || transportOwnsRecovery || session.disposed) { return } const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index f748de0ea73..7f0e90347f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -105,7 +105,8 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { terminalOwner, snapshotCols, snapshotRows, - carriesNormalBuffer + carriesNormalBuffer, + keepsLocalScrollback } = payload session.pendingReplayData = null const isCurrentPayload = (): boolean => @@ -131,7 +132,11 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { if (!isCurrentPayload()) { continue } - const image = buildFoldedImageReplayWrites(data, session.isPaneOnAlternateScreen()) + const image = buildFoldedImageReplayWrites( + data, + session.isPaneOnAlternateScreen(), + keepsLocalScrollback === true + ) replayPayload = image.payload await session.writeReplayDataAsync(image.preamble) } else { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts index 749d8c73492..87c06cbbb24 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts @@ -7,6 +7,7 @@ vi.mock('../terminal-pane-recovery', () => ({ })) function buildSession(overrides: Record = {}): never { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bag names every session member settleSpawnThatLeftPaneUnbound reads. return { deps: { tabId: 'tab-1', worktreeId: 'wt-1', restoredLeafId: 'leaf-1' }, pane: { id: 4, leafId: 'pane-leaf' }, @@ -14,6 +15,7 @@ function buildSession(overrides: Record = {}): never { terminalRecoveryInstance: { id: 3 }, directSshRetryAttempt: undefined, settlePaneAttachAttempt: vi.fn(), + transport: {}, ...overrides } as never } @@ -47,6 +49,24 @@ describe('settleSpawnThatLeftPaneUnbound', () => { expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() }) + // #21195: a remount here would race the transport's own armed retry onto the same pane. + it('leaves recovery to a remote transport that armed its own retry', () => { + const settlePaneAttachAttempt = vi.fn() + + settleSpawnThatLeftPaneUnbound( + buildSession({ transport: { ownsRecovery: () => true }, settlePaneAttachAttempt }) + ) + + expect(settlePaneAttachAttempt).toHaveBeenCalledExactlyOnceWith(undefined, 'failed') + expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() + }) + + it('remounts when the transport reports no recovery of its own', () => { + settleSpawnThatLeftPaneUnbound(buildSession({ transport: { ownsRecovery: () => false } })) + + expect(requestTerminalPaneRecovery).toHaveBeenCalledOnce() + }) + it('settles the spawn as failed before remounting', () => { const settlePaneAttachAttempt = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts index a6dfd3c5a96..91b1e5ebd8b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts @@ -14,13 +14,15 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' * rebinds one. A remount reattaches over the still-live PTY and drains the * buffer. * - * A direct-SSH lease runs its own retry ledger, so it keeps ownership here and - * a second remount never races it. */ + * A direct-SSH lease runs its own retry ledger, and a transport that armed or + * parked its own retry owns the pane too, so a second remount never races + * either of them. */ export function settleSpawnThatLeftPaneUnbound(session: ConnectPanePtySession): void { // Read before settling: the settle clears the lease this branch tests. - const directSshRetryOwnsRecovery = Boolean(session.directSshRetryAttempt) + const retryOwnsRecovery = + Boolean(session.directSshRetryAttempt) || session.transport.ownsRecovery?.() === true session.settlePaneAttachAttempt(session.directSshRetryAttempt, 'failed') - if (directSshRetryOwnsRecovery) { + if (retryOwnsRecovery) { return } warnTerminalLifecycleAnomaly('fresh spawn left the pane unbound', { diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index 8903348ee16..970461d7507 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -41,6 +41,7 @@ export type ProcessPtyOutputOptions = { snapshotCols?: number snapshotRows?: number carriesNormalBuffer?: boolean + keepsLocalScrollback?: boolean } function removeSuppressedCursorNativeTitles( @@ -230,7 +231,8 @@ export function createPtyOutputProcessor({ ...(options.snapshotCols !== undefined && options.snapshotRows !== undefined ? { snapshotCols: options.snapshotCols, snapshotRows: options.snapshotRows } : {}), - ...(options.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}) + ...(options.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}), + ...(options.keepsLocalScrollback ? { keepsLocalScrollback: true } : {}) } if (Object.keys(replayMeta).length > 0) { callbacks.onReplayData(data, replayMeta) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index c45457dfbc1..7702a63a9a0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -72,6 +72,8 @@ export type PtyReplayDataMeta = { /** An image that starts on the normal buffer and enters alt itself; absent for * raw byte replays such as an SSH relay's ring buffer. */ carriesNormalBuffer?: boolean + /** The image carries no history, so replay clears the screen but keeps scrollback. */ + keepsLocalScrollback?: boolean } export type LocalPtySessionMetadata = { @@ -219,6 +221,8 @@ export type PtyTransport = { getRecoveryState?: () => PtyTransportRecoveryState /** Starts a fresh connection epoch while preserving the authoritative remote PTY identity. */ retryRecovery?: () => boolean + /** True while the transport has a retry armed or parked; pane-level remounts must defer to it. */ + ownsRecovery?: () => boolean /** Lets a wrapper retain input when recovery re-enters connect internally. */ setConnectForRecovery?: (connect: PtyTransport['connect']) => void /** The user dismissed the error surface; the next occurrence of the same message must surface again. */ diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts index 2cd40bd4a33..9ef1dac921b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts @@ -124,8 +124,8 @@ describe('remote transport snapshot source-grid threading', () => { deliverSnapshot({ cols: 154, rows: 68, seq: 9, source: 'headless' }, 'recovered') await expect.poll(() => onReplayData.mock.calls.length, { timeout: 5000 }).toBe(2) expect(onReplayData).toHaveBeenLastCalledWith( - '\x1b[?2026l\x1b[2J\x1b[3J\x1b[Hrecovered', - expect.objectContaining({ snapshotCols: 154, snapshotRows: 68 }) + '\x1b[?2026l\x1b[2J\x1b[Hrecovered', + expect.objectContaining({ snapshotCols: 154, snapshotRows: 68, keepsLocalScrollback: true }) ) // A host that publishes no dimensions must read as unknown, not as a grid. diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 4ad4e950c81..f9e95fb7c8c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -191,7 +191,10 @@ describe('createRemoteRuntimePtyTransport', () => { }) ) - expect(onReplayData).toHaveBeenCalledWith('hello', { carriesNormalBuffer: true }) + expect(onReplayData).toHaveBeenCalledWith('hello', { + carriesNormalBuffer: true, + keepsLocalScrollback: true + }) expect(onConnect).toHaveBeenCalled() expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 })) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts index 58034193be8..ad9ef82a06d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts @@ -249,9 +249,11 @@ describe('createRemoteRuntimePtyTransport', () => { expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) const listCallsAfterBound = hostListCalls - await expect( - transport.sendInputAccepted?.('retry while reconnecting', 'driving') - ).resolves.toBe(false) + // Why: held for terminal-stale; the replacement handle below must never receive it (#10065). + const heldWhileReconnecting = transport.sendInputAccepted?.( + 'retry while reconnecting', + 'driving' + ) await vi.advanceTimersByTimeAsync(16_000) // The accepted-snapshot listener already owns recovery. User input must @@ -291,6 +293,7 @@ describe('createRemoteRuntimePtyTransport', () => { 'remote:env-1@@terminal-after-timeout', 'remote:env-1@@terminal-stale' ) + await expect(heldWhileReconnecting).resolves.toBe(false) expect(onPtyExit).not.toHaveBeenCalled() expect(transport.getPtyId()).toBe('remote:env-1@@terminal-after-timeout') expect(transport.isConnected()).toBe(false) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts index 30c03745abe..f0ead469a68 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts @@ -321,8 +321,8 @@ describe('createRemoteRuntimePtyTransport', () => { }) subscriptionSendBinary.mockClear() - // Why: replacement input stays disabled until terminal.resolvePane proves the new handle belongs to this pane. - expect(transport.sendInput('x', 'driving')).toBe(false) + // Why: replacement input is held for the new handle until terminal.resolvePane proves it belongs to this pane. + expect(transport.sendInput('x', 'driving')).toBe(true) vi.advanceTimersByTime(8) const inputFrames = subscriptionSendBinary.mock.calls diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts new file mode 100644 index 00000000000..578056d7e87 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createRemoteRuntimeTransportMocks, + readyHostSessionInventoryResponse, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' +import type { PtyTransport } from './pty-transport-types' + +let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null +let resolvedPaneHandle = 'terminal-1' + +const { + runtimeCall, + runtimeSubscribe, + latestSubscribePayload, + inputFrameTexts, + subscribeFrameCount, + emitSnapshot, + resetRemoteRuntimeTransport +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) + +const PAIRING_CHANGED = 'Runtime environment pairing changed; refresh and try again' + +type RevisionedRequest = { method?: string; expectedEnvironmentPairingRevision?: number } + +function requestRevision(request: unknown): number | undefined { + return typeof request === 'object' && + request !== null && + 'expectedEnvironmentPairingRevision' in request && + typeof request.expectedEnvironmentPairingRevision === 'number' + ? request.expectedEnvironmentPairingRevision + : undefined +} + +function subscribeRevisions(): (number | undefined)[] { + return runtimeSubscribe.mock.calls.map(([request]) => requestRevision(request)) +} + +/** A managed server's catalog row; its pairing handshake proves `hostKey`. */ +function managedServer(pairingRevision: number, hostKey: string) { + return { + id: 'env-1', + name: 'Box', + createdAt: 1, + updatedAt: pairingRevision, + pairingRevision, + hostKeyFingerprint: hostKey, + lastUsedAt: null, + runtimeId: null, + endpoints: [ + { id: 'ws', kind: 'websocket' as const, label: 'Box', endpoint: 'ws://127.0.0.1:46768/' } + ], + preferredEndpointId: 'ws', + orcadDeployment: { + sshTargetId: 'box', + sshTargetGeneration: 1, + localPort: 46768, + remotePort: 6768 + } + } +} + +/** + * Main's environment record, mirrored into the real catalog store. A managed-server update + * restarts the server and re-pairs it under the same environment id, so main refuses every request + * that still carries the old revision — before it reaches the host, whose terminals are untouched. + */ +async function installRepairableMain(): Promise<{ + repair: (hostKey?: string) => void + publishCatalog: () => void + setReachable: (reachable: boolean) => void + store: Awaited> +}> { + let mainRevision = 1 + let mainHostKey = 'host-A' + let reachable = true + const unreachable = (): Error => + Object.assign(new Error('Could not connect to the remote Orca runtime.'), { + code: 'remote_runtime_unavailable' + }) + const store = await createCatalogStore() + const publishCatalog = (): void => { + store.getState().setRuntimeEnvironments([managedServer(mainRevision, mainHostKey)]) + } + publishCatalog() + const { setRuntimeEnvironmentCatalogRefresher } = + await import('@/runtime/runtime-environment-pairing-refresh') + setRuntimeEnvironmentCatalogRefresher(async () => publishCatalog()) + const isStale = (request: RevisionedRequest): boolean => + request.expectedEnvironmentPairingRevision !== undefined && + request.expectedEnvironmentPairingRevision !== mainRevision + const hostCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (request: RevisionedRequest) => { + if (!reachable) { + throw unreachable() + } + if (isStale(request)) { + return { + ok: false, + error: { code: 'runtime_environment_changed', message: PAIRING_CHANGED } + } + } + if (request.method === 'session.tabs.list') { + return readyHostSessionInventoryResponse('terminal-1', 'host-tab-1') + } + return hostCall?.(request) + }) + const hostSubscribe = runtimeSubscribe.getMockImplementation() + runtimeSubscribe.mockImplementation(async (request: RevisionedRequest, callbacks: unknown) => { + if (!reachable) { + throw unreachable() + } + if (isStale(request)) { + throw new Error( + `Error invoking remote method 'runtimeEnvironments:subscribe': Error: ${PAIRING_CHANGED}` + ) + } + return hostSubscribe?.(request, callbacks) + }) + return { + repair: (hostKey = 'host-A') => { + mainRevision = 2 + mainHostKey = hostKey + }, + publishCatalog, + setReachable: (next) => { + reachable = next + }, + store + } +} + +async function createCatalogStore() { + vi.doMock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() } + })) + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const { resetDeferredPeerChecksForTests } = + await import('@/store/slices/runtime-environment-peer-replacement') + resetDeferredPeerChecksForTests() + return createTestStore() +} + +async function restartServerUnderLivePane(tabId: string): Promise<{ + transport: PtyTransport + onError: ReturnType + repair: (hostKey?: string) => void + publishCatalog: () => void + setReachable: (reachable: boolean) => void +}> { + const { repair, publishCatalog, setReachable } = await installRepairableMain() + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId, + leafId: 'pane:1' + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: { onError } + }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + return { transport, onError, repair, publishCatalog, setReachable } +} + +// A forced managed-server update re-pairs the environment while its terminals keep running. +describe('remote runtime pane across a pairing rotation', () => { + beforeEach(() => { + resetRemoteRuntimeTransport() + }) + + afterEach(async () => { + const { setRuntimeEnvironmentCatalogRefresher } = + await import('@/runtime/runtime-environment-pairing-refresh') + setRuntimeEnvironmentCatalogRefresher(null) + }) + + it('rebinds a pane to its still-live terminal on the new pairing and delivers typed keys', async () => { + const { transport, onError, repair } = await restartServerUnderLivePane('tab-1') + const firstCallbacks = subscriptionCallbacks + + repair() + firstCallbacks?.onClose?.() + expect(transport.sendInput('echo typed-after-update\r', 'driving')).toBe(true) + + await vi.waitFor(() => expect(subscribeRevisions().at(-1)).toBe(2)) + await vi.waitFor(() => expect(subscriptionCallbacks).not.toBe(firstCallbacks)) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') + expect(latestSubscribePayload().terminal).toBe('terminal-1') + await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('echo typed-after-update\r')) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + }) + + it('rebinds a pane whose stream reopened before main re-paired, instead of dropping its keys', async () => { + const { transport, onError, repair, publishCatalog } = await restartServerUnderLivePane('tab-1') + const firstCallbacks = subscriptionCallbacks + + // The server is back and the stream reopened on the old pairing; main re-pairs only afterwards. + repair() + publishCatalog() + + await vi.waitFor(() => expect(subscribeRevisions().at(-1)).toBe(2)) + await vi.waitFor(() => expect(subscriptionCallbacks).not.toBe(firstCallbacks)) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + expect(transport.sendInput('echo typed-after-repair\r', 'driving')).toBe(true) + + await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('echo typed-after-repair\r')) + expect(latestSubscribePayload().terminal).toBe('terminal-1') + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + }) + + it('rebinds a host session pane through its inventory on the new pairing', async () => { + const { transport, onError, repair } = + await restartServerUnderLivePane('web-terminal-host-tab-1') + const firstCallbacks = subscriptionCallbacks + + repair() + firstCallbacks?.onClose?.() + + await vi.waitFor(() => expect(subscribeRevisions().at(-1)).toBe(2), { timeout: 5_000 }) + await vi.waitFor(() => expect(subscriptionCallbacks).not.toBe(firstCallbacks)) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') + expect(latestSubscribePayload().terminal).toBe('terminal-1') + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + }) + + it('Reconnect rebinds a pane whose retries stopped while the server restarted re-paired', async () => { + vi.useFakeTimers() + try { + const { transport, onError, repair, publishCatalog, setReachable } = + await restartServerUnderLivePane('web-terminal-host-tab-1') + setReachable(false) + subscriptionCallbacks?.onClose?.() + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + repair() + setReachable(true) + // Another subscriber's refusal already brought the renderer catalog to the new revision. + publishCatalog() + const subscribesBefore = runtimeSubscribe.mock.calls.length + runtimeCall.mockClear() + expect(transport.retryRecovery?.()).toBe(true) + + await vi.waitFor(() => + expect(runtimeSubscribe.mock.calls.length).toBeGreaterThan(subscribesBefore) + ) + expect(subscribeRevisions().at(-1)).toBe(2) + await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') + for (const [request] of runtimeCall.mock.calls) { + expect(requestRevision(request)).toBe(2) + } + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['a host session pane', 'web-terminal-host-tab-1'], + ['a client-placed pane', 'tab-1'] + ])( + 'sends nothing from %s to a different machine re-paired under the same id', + async (_label, tabId) => { + vi.useFakeTimers() + try { + const { transport, repair, publishCatalog } = await restartServerUnderLivePane(tabId) + const callsBefore = runtimeCall.mock.calls.length + const subscribesBefore = runtimeSubscribe.mock.calls.length + + // A reinstalled or replacement server: same registration, different host key. + repair('host-B') + publishCatalog() + transport.sendInput('typed after the swap\r', 'driving') + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS * 2) + + const reachedNewMachine = [ + ...runtimeCall.mock.calls.slice(callsBefore), + ...runtimeSubscribe.mock.calls.slice(subscribesBefore) + ].filter(([request]) => requestRevision(request) === 2) + expect(reachedNewMachine).toEqual([]) + expect(inputFrameTexts()).toEqual([]) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + } + ) + + it('holds a pane while the re-paired host identity is unresolved, then follows it once proven', async () => { + vi.useFakeTimers() + try { + const { transport, repair, publishCatalog } = await restartServerUnderLivePane('tab-1') + const firstCallbacks = subscriptionCallbacks + repair('') + publishCatalog() + firstCallbacks?.onClose?.() + await vi.advanceTimersByTimeAsync(2_000) + expect(subscribeRevisions().filter((revision) => revision === 2)).toEqual([]) + + repair('host-A') + publishCatalog() + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(subscribeRevisions().at(-1)).toBe(2)) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.getRecoveryState?.().phase).toBe('connected')) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts new file mode 100644 index 00000000000..64f954ff9dc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createRemoteRuntimeTransportMocks, + readyHostSessionInventoryResponse, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' + +let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null +let resolvedPaneHandle = 'terminal-1' + +const { + runtimeCall, + runtimeSubscribe, + latestSubscribePayload, + inputFrameTexts, + emitSnapshot, + resetRemoteRuntimeTransport +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) + +function unreachable(): Error { + return Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) +} + +// Every exit from binding a remote pane must leave one owner: the host's answer, or the transport's own retry. +describe('remote runtime pane binding ownership', () => { + beforeEach(() => { + resetRemoteRuntimeTransport() + }) + + it('settles an attach whose pane the host no longer knows instead of latching in connecting (#21344)', async () => { + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'terminal.resolvePane') { + return { ok: false, error: { code: 'runtime_error', message: 'terminal_not_found' } } + } + return defaultCall?.(args) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + onPtyExit + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: { onError } + }) + + await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalled()) + // Why -1: the host answered for its registry, but a restarting host can republish later. + expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1', -1) + expect(onError).toHaveBeenCalledWith('Remote terminal was closed.') + expect(transport.getRecoveryState?.().phase).toBe('ended') + expect(transport.ownsRecovery?.()).toBe(false) + expect(transport.sendInput('typed after close', 'driving')).toBe(false) + }) + + it('retries an attach whose pane lookup lost contact instead of latching in connecting', async () => { + vi.useFakeTimers() + try { + let hostReachable = false + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (!hostReachable) { + throw unreachable() + } + return defaultCall?.(args) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: 80, + rows: 24, + callbacks: { onError } + }) + await vi.advanceTimersByTimeAsync(0) + + expect(transport.getRecoveryState?.().phase).toBe('backoff') + expect(transport.ownsRecovery?.()).toBe(true) + expect(onError).not.toHaveBeenCalled() + // Typed while the host is unreachable: held for this pane's terminal, not dropped (#20802). + expect(transport.sendInput('echo held', 'driving')).toBe(true) + + hostReachable = true + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.advanceTimersByTimeAsync(20) + + expect(transport.isConnected()).toBe(true) + expect(transport.getRecoveryState?.().phase).toBe('connected') + expect(transport.ownsRecovery?.()).toBe(false) + expect(inputFrameTexts().join('')).toBe('echo held') + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reports owning recovery after a recoverable connect failure so the pane is not remounted (#21195)', async () => { + vi.useFakeTimers() + try { + runtimeCall.mockImplementation(async () => { + throw unreachable() + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + const result = await transport.connect({ + url: '', + sessionId: 'remote:env-1@@', + callbacks: {} + }) + + expect(result).toBeUndefined() + expect(transport.getPtyId()).toBeNull() + expect(transport.ownsRecovery?.()).toBe(true) + transport.destroy?.() + expect(transport.ownsRecovery?.()).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('delivers acknowledged input typed during a same-terminal recovery once it rebinds (#25784)', async () => { + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: { method: string }) => + args.method === 'terminal.send' + ? { ok: true, result: { send: { accepted: true } } } + : defaultCall?.(args) + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + await transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + await vi.waitFor(() => expect(transport.isConnected()).toBe(true)) + const firstCallbacks = subscriptionCallbacks + + firstCallbacks?.onError?.({ + code: 'remote_runtime_unavailable', + message: 'Remote runtime connection closed.' + }) + expect(transport.isConnected()).toBe(false) + const accepted = transport.sendInputAccepted?.('git status\r', 'driving') + + await vi.waitFor(() => expect(subscriptionCallbacks).not.toBe(firstCallbacks)) + await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) + emitSnapshot(latestSubscribePayload().streamId, 'prompt$ ') + + await expect(accepted).resolves.toBe(true) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.send', + params: expect.objectContaining({ terminal: 'terminal-1', text: 'git status\r' }) + }) + ) + transport.destroy?.() + }) + + it('does not close a pane on an inventory that was already in flight before its leaf published (#20923)', async () => { + let releaseStaleInventory: () => void = () => {} + const staleInventory = new Promise((resolve) => { + releaseStaleInventory = resolve + }) + const siblingOnly = readyHostSessionInventoryResponse('terminal-sibling', 'host-tab-1') + // Why: rewrite the leaf so only a sibling of the requested pane is present. + const staleSnapshot = JSON.parse(JSON.stringify(siblingOnly).replaceAll('pane:1', 'pane:2')) + let listCalls = 0 + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: { method: string; params?: unknown }) => { + if (args.method === 'session.tabs.activate') { + return { ok: true, result: { tabs: [] } } + } + if (args.method === 'session.tabs.list') { + listCalls += 1 + if (listCalls === 1) { + await staleInventory + return staleSnapshot + } + return readyHostSessionInventoryResponse('terminal-1', 'host-tab-1') + } + return defaultCall?.(args) + }) + const { listRemoteRuntimeSessionTabsDeduped } = + await import('@/runtime/remote-runtime-session-tabs-inflight') + // Another pane's inventory request starts before this leaf is published. + const preexisting = listRemoteRuntimeSessionTabsDeduped({ + environmentId: 'env-1', + worktreeId: 'wt-1', + load: async () => { + const response = await runtimeCall({ method: 'session.tabs.list' }) + return { snapshot: response.result } + } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + const connected = transport.connect({ url: '', callbacks: { onError } }) + await new Promise((resolve) => setTimeout(resolve, 400)) + releaseStaleInventory() + await preexisting + + await expect(connected).resolves.toMatchObject({ id: 'remote:env-1@@terminal-1' }) + expect(onError).not.toHaveBeenCalledWith('Remote terminal was closed.') + expect(listCalls).toBeGreaterThanOrEqual(2) + transport.destroy?.() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts index a947456c0e6..ce3f4445f32 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts @@ -303,6 +303,31 @@ describe('createRemoteRuntimePtyTransport', () => { } }) + it('ignores a hold whose grid the host does not know yet instead of shrinking the pane', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { getFitOverrideForPty } = await import('@/lib/pane-manager/mobile-fit-overrides') + const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' }) + await transport.connect({ url: '', cols: 120, rows: 40, callbacks: {} }) + const { streamId } = latestSubscribePayload() + const ptyId = transport.getPtyId() + expect(ptyId).not.toBeNull() + + // A just-restarted server answers before it has learned the adopted terminal's size. + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'fit-override-changed', + streamId, + mode: 'remote-desktop-fit', + cols: 0, + rows: 0 + } + }) + + expect(ptyId ? getFitOverrideForPty(ptyId) : null).toBeNull() + transport.destroy?.() + }) + it('gives separate paired viewers of the same host pane distinct refresh identities', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const first = createRemoteRuntimePtyTransport('env-1', { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts index 2ee83ace72b..65c0d38eb47 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts @@ -60,7 +60,8 @@ describe('createRemoteRuntimePtyTransport', () => { ) expect(onReplayData).toHaveBeenCalledWith('beforeafter\x1b]0;Remote title\x07\x07', { - carriesNormalBuffer: true + carriesNormalBuffer: true, + keepsLocalScrollback: true }) await vi.waitFor(() => expect(onTitleChange).toHaveBeenCalledWith('Remote title', 'Remote title') @@ -91,7 +92,10 @@ describe('createRemoteRuntimePtyTransport', () => { 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after' ) - expect(onReplayData).toHaveBeenCalledWith('beforeafter', { carriesNormalBuffer: true }) + expect(onReplayData).toHaveBeenCalledWith('beforeafter', { + carriesNormalBuffer: true, + keepsLocalScrollback: true + }) expect(onAgentStatus).not.toHaveBeenCalled() expect(onBell).not.toHaveBeenCalled() expect(onConnect).toHaveBeenCalled() @@ -183,7 +187,10 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) const { streamId } = latestSubscribePayload() emitSnapshot(streamId, 'initial') - expect(onReplayData).toHaveBeenCalledWith('initial', { carriesNormalBuffer: true }) + expect(onReplayData).toHaveBeenCalledWith('initial', { + carriesNormalBuffer: true, + keepsLocalScrollback: true + }) expect(onConnect).toHaveBeenCalled() const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 }) @@ -281,7 +288,10 @@ describe('createRemoteRuntimePtyTransport', () => { expect(latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)).toBeUndefined() emitSnapshot(streamId, 'initial replay') - expect(onReplayData).toHaveBeenCalledWith('initial replay', { carriesNormalBuffer: true }) + expect(onReplayData).toHaveBeenCalledWith('initial replay', { + carriesNormalBuffer: true, + keepsLocalScrollback: true + }) expect(onConnect).toHaveBeenCalled() await vi.waitFor(() => diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-fixtures.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-fixtures.ts index 978d0428060..a0635501676 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-fixtures.ts @@ -3,6 +3,7 @@ import { TerminalStreamOpcode, decodeTerminalStreamFrame, decodeTerminalStreamJson, + decodeTerminalStreamText, encodeTerminalStreamFrame, encodeTerminalStreamJson, encodeTerminalStreamText @@ -106,6 +107,21 @@ export function createTerminalStreamFixtures(bindings: { ) } + function inputFrameTexts(): string[] { + return bindings.sendBinary.mock.calls.flatMap(([bytes]) => { + const frame = decodeTerminalStreamFrame(bytes) + return frame?.opcode === TerminalStreamOpcode.Input + ? [decodeTerminalStreamText(frame.payload)] + : [] + }) + } + + function subscribeFrameCount(): number { + return bindings.sendBinary.mock.calls.filter( + ([bytes]) => decodeTerminalStreamFrame(bytes)?.opcode === TerminalStreamOpcode.Subscribe + ).length + } + function latestFrameForOpcode(opcode: TerminalStreamOpcode) { return bindings.sendBinary.mock.calls .map((call) => decodeTerminalStreamFrame(call[0])) @@ -137,6 +153,8 @@ export function createTerminalStreamFixtures(bindings: { emitOutput, emitSnapshot, latestFrameForOpcode, + inputFrameTexts, + subscribeFrameCount, emitSnapshotFrame } } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts index 1e68f55e7e5..59b28f07e32 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts @@ -22,6 +22,8 @@ const { emitOutput, emitSnapshot, latestFrameForOpcode, + inputFrameTexts, + subscribeFrameCount, resetRemoteRuntimeTransport } = createRemoteRuntimeTransportMocks({ getCallbacks: () => subscriptionCallbacks, @@ -205,13 +207,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) - await vi.waitFor(() => - expect( - subscriptionSendBinary.mock.calls - .map((call) => decodeTerminalStreamFrame(call[0])) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) - ).toHaveLength(2) - ) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) const reconnectStreamId = latestSubscribePayload().streamId emitSnapshot(reconnectStreamId, 'RECONNECT_SNAPSHOT') subscriptionCallbacks?.onResponse({ @@ -275,13 +271,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) - await vi.waitFor(() => - expect( - subscriptionSendBinary.mock.calls - .map((call) => decodeTerminalStreamFrame(call[0])) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) - ).toHaveLength(2) - ) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) const reconnectStreamId = latestSubscribePayload().streamId // An exited-but-preserved pane has nothing to push and will never emit live bytes, // so without the re-arm the pane stays blank until a visibility flip. @@ -300,13 +290,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(3)) - await vi.waitFor(() => - expect( - subscriptionSendBinary.mock.calls - .map((call) => decodeTerminalStreamFrame(call[0])) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) - ).toHaveLength(3) - ) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(3)) const populatedReconnectStreamId = latestSubscribePayload().streamId emitSnapshot(populatedReconnectStreamId, 'RECOVERY_SNAPSHOT') subscriptionCallbacks?.onResponse({ @@ -434,7 +418,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(unsubscribe).toHaveBeenCalledTimes(1) }) - it('recovers repeated partitions without changing PTY identity or accepting detached input', async () => { + it('recovers repeated partitions without changing PTY identity and delivers detached input once', async () => { const callbacksByEpoch: NonNullable[] = [] const unsubscribeByEpoch: ReturnType[] = [] runtimeSubscribe.mockImplementation( @@ -466,7 +450,8 @@ describe('createRemoteRuntimePtyTransport', () => { }) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput(`detached-${cycle}`, 'driving')).toBe(false) + // Why: keys typed while the same PTY recovers are held and delivered once it rebinds (#25784). + expect(transport.sendInput(`detached-${cycle}`, 'driving')).toBe(true) expect(unsubscribeByEpoch[cycle]).toHaveBeenCalledTimes(1) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(cycle + 2)) await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) @@ -478,6 +463,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(transport.sendInputImmediate(`input-${cycle}`)).toBe(true) expect(transport.getPtyId()).toBe(ptyId) + expect(inputFrameTexts().filter((text) => text.includes(`detached-${cycle}`))).toHaveLength(1) expect(onData).toHaveBeenCalledWith(`output-${cycle}`, expect.any(Object)) expect( decodeTerminalStreamText( @@ -551,12 +537,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(transport.retryRecovery?.()).toBe(true) expect(transport.retryRecovery?.()).toBe(false) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(callsAtCutoff + 1)) - await vi.waitFor(() => { - const subscribeFrames = subscriptionSendBinary.mock.calls - .map((call) => decodeTerminalStreamFrame(call[0])) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) - expect(subscribeFrames).toHaveLength(2) - }) + await vi.waitFor(() => expect(subscribeFrameCount()).toBe(2)) const manualStream = latestSubscribePayload() expect(manualStream.terminal).toBe('terminal-1') emitSnapshot(manualStream.streamId, 'after manual reconnect') diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index ec4c4bcfe1c..e91e3579074 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,5 +1,10 @@ import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' import { withRemoteReattachInputBuffer } from './remote-reattach-input-buffer' +import { + createRemoteRuntimeRecoveryInputHold, + type RemoteRuntimeInputEndpoint +} from './remote-runtime-recovery-input-hold' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' /* eslint-disable max-lines -- Why: remote PTY transport keeps lifecycle, JSON fallback, and binary stream wiring together so reconnect/destroy ordering stays testable as one behavior surface. */ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' import { @@ -68,7 +73,7 @@ import { import { replaceFitOverridePtyId, setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides' import { replaceDriverPtyId, setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '@/runtime/web-terminal-surface-id' -import { listRemoteRuntimeSessionTabsDeduped } from '@/runtime/remote-runtime-session-tabs-inflight' +import { listRemoteRuntimeSessionTabsAfterCurrentInFlight } from '@/runtime/remote-runtime-session-tabs-inflight' import { subscribeAcceptedWebSessionTerminalHandle } from '@/runtime/web-session-terminal-handle-events' import { hostSnapshotAffirmsWorktreeContents } from '@/runtime/host-session-snapshot-authority' import { runRemoteAgentSessionLaunch } from '@/runtime/remote-agent-session-launch' @@ -84,7 +89,15 @@ import { ptyReplayHandlers, ptyShutdownLifecycleHandlers } from './pty-shutdown-data-suspension' -import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { + getRuntimeEnvironmentRevision, + resolveContinuedRuntimeEnvironmentRevision +} from '@/runtime/runtime-environment-revision' +import { + isRuntimeEnvironmentPairingChangedError, + refreshRuntimeEnvironmentsAfterPairingChange, + runtimeEnvironmentPairingChangedError +} from '@/runtime/runtime-environment-pairing-refresh' const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8 const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 @@ -103,6 +116,11 @@ const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_00 type HostHandleReplacementPolicy = 'reuse' | 'prefer-replacement' | 'require-replacement' +type PersistedHostPaneResolution = + | { kind: 'resolved'; terminal: RuntimeTerminalResolvePane } + | { kind: 'absent' } + | { kind: 'unavailable' } + type HostSessionHandleWaitResult = { handle: string | null | undefined inventoryFailed: boolean @@ -129,6 +147,25 @@ function isRemoteTerminalStaleMessage(message: string): boolean { return message.includes('terminal_handle_stale') } +// Why: lost contact, host back-pressure, or a re-minted handle all leave the pane resolvable later. +function isRetryableUnboundAttachError(error: unknown): boolean { + const clientError = toRemoteRuntimeClientErrorLike(error) + return ( + isRecoverableRemoteRuntimeConnectionError(clientError) || + isRuntimeRpcQueueOverloadError(clientError) || + isRemoteTerminalStaleMessage(runtimeTerminalErrorMessage(error)) + ) +} + +// Why: main refuses a stale pairing before dispatch (a managed server update re-pairs it), so the +// terminal is untouched and only the binding must retry on the re-read pairing. +function isRecoverablePaneBindingError(error: unknown): boolean { + return ( + isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) || + isRuntimeEnvironmentPairingChangedError(error) + ) +} + function isRemoteTerminalGoneMessage(message: string): boolean { return ( message.includes('terminal_exited') || @@ -186,8 +223,18 @@ export function createRemoteRuntimePtyTransport( let authoritativeExecutionHostId: ExecutionHostId | null = executionHostId ?? null let authoritativeHostPlatform: NodeJS.Platform | null = null let authoritativePtyIncarnationId: string | null = null - let currentRuntimeEnvironmentId = runtimeEnvironmentId - const runtimeEnvironmentPairingRevision = getRuntimeEnvironmentRevision(runtimeEnvironmentId) + // Why: never adopts a persisted existingPtyId's environment; the worktree owner chose this transport. + const currentRuntimeEnvironmentId = runtimeEnvironmentId + // Why: the pane belongs to the machine it was opened on; it may follow that machine's own + // re-pair (a server update) but never a same-id re-pair to another or unverified machine. + let paneRevision = resolveContinuedRuntimeEnvironmentRevision( + runtimeEnvironmentId, + getRuntimeEnvironmentRevision(runtimeEnvironmentId) + ) + function currentPaneRevision(): number | undefined { + paneRevision = resolveContinuedRuntimeEnvironmentRevision(runtimeEnvironmentId, paneRevision) + return paneRevision + } let multiplexedStream: RemoteRuntimeMultiplexedTerminal | null = null let multiplexedStreamHandle: string | null = null let desiredOutputPaused = false @@ -251,10 +298,15 @@ export function createRemoteRuntimePtyTransport( }) } + const recoveryInputHold = createRemoteRuntimeRecoveryInputHold() const recovery = new RemoteRuntimePtyRecoveryState(() => { if (recovery.currentPhase === 'disposed') { clearPublishedHandleWait() } + if (recovery.currentPhase === 'disconnected' || recovery.currentPhase === 'disposed') { + // Why: once auto-recovery gives up, held keys would land at an arbitrary later reconnect. + recoveryInputHold.discard() + } if (recovery.currentPhase === 'disconnected') { // Why: only the wall-clock deadline is evidence the window was spent; a UI latch from a fatal // resubscribe must not license reattaching a fenced same handle (#12683). @@ -652,10 +704,11 @@ export function createRemoteRuntimePtyTransport( const request = nextRequest let snapshot: RuntimeMobileSessionTabsResult try { + // Why: a joined pre-publication inventory cannot prove this leaf is gone (#20923). snapshot = request === 'list' ? ( - await listRemoteRuntimeSessionTabsDeduped({ + await listRemoteRuntimeSessionTabsAfterCurrentInFlight({ environmentId: currentRuntimeEnvironmentId, worktreeId, load: async () => ({ @@ -753,10 +806,7 @@ export function createRemoteRuntimePtyTransport( } return hostHandle } catch (error) { - if ( - !isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) || - !isCurrent() - ) { + if (!isRecoverablePaneBindingError(error) || !isCurrent()) { throw error } if (recoveryEpoch !== undefined && !recovery.isCurrent(recoveryEpoch)) { @@ -816,10 +866,11 @@ export function createRemoteRuntimePtyTransport( } const request = nextRequest try { + // Why: a joined pre-publication inventory cannot prove this leaf is gone (#20923). const listed = request === 'list' ? ( - await listRemoteRuntimeSessionTabsDeduped({ + await listRemoteRuntimeSessionTabsAfterCurrentInFlight({ environmentId: currentRuntimeEnvironmentId, worktreeId, load: async () => ({ @@ -887,7 +938,7 @@ export function createRemoteRuntimePtyTransport( return false } if (lastAttachOptions) { - transport.attach(lastAttachOptions) + attachPane(lastAttachOptions, true) return true } if (lastConnectOptions) { @@ -1039,9 +1090,15 @@ export function createRemoteRuntimePtyTransport( method, params, timeoutMs, - expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision + expectedEnvironmentPairingRevision: currentPaneRevision() }) - return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + try { + return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + } catch (error) { + // Why await: the caller's retry must read the new revision, not race the catalog re-read. + await refreshRuntimeEnvironmentsAfterPairingChange(error) + throw error + } } async function callRuntime( @@ -1214,13 +1271,13 @@ export function createRemoteRuntimePtyTransport( return null } - async function resolvePersistedHostPane(): Promise { + async function resolvePersistedHostPane(): Promise { if (!tabId || !leafId || !worktreeId) { - return null + return { kind: 'unavailable' } } const paneKey = `${tabId}:${leafId}` if (resolvePaneUnavailable) { - return null + return { kind: 'unavailable' } } let terminal: RuntimeTerminalResolvePane try { @@ -1233,10 +1290,14 @@ export function createRemoteRuntimePtyTransport( const message = runtimeTerminalErrorMessage(error) if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { resolvePaneUnavailable = true - return null + return { kind: 'unavailable' } } - if (message.includes('terminal_not_found') || message.includes('method_not_found')) { - return null + if (message.includes('terminal_not_found')) { + // Why: the host's own pane registry answered, so this is removal evidence, not lost contact. + return { kind: 'absent' } + } + if (message.includes('method_not_found')) { + return { kind: 'unavailable' } } throw error } @@ -1249,7 +1310,8 @@ export function createRemoteRuntimePtyTransport( } if (terminal.worktreeId === undefined) { const worktree = toRuntimeWorktreeSelector(worktreeId) - const { snapshot: listed } = await listRemoteRuntimeSessionTabsDeduped({ + // Why: an inventory that started before resolvePane answered can predate this handle. + const { snapshot: listed } = await listRemoteRuntimeSessionTabsAfterCurrentInFlight({ environmentId: currentRuntimeEnvironmentId, worktreeId, load: async () => ({ @@ -1266,7 +1328,7 @@ export function createRemoteRuntimePtyTransport( throw new Error('terminal_owner_mismatch') } } - return terminal + return { kind: 'resolved', terminal } } async function adoptResolvedHostPane( @@ -1393,6 +1455,58 @@ export function createRemoteRuntimePtyTransport( return recovery.isActive || recovery.currentPhase === 'disconnected' } + // Why: a pane binding or auto-recovering a known handle holds typing for it instead of dropping it (#25784). + function shouldHoldInput(): boolean { + return ( + !destroyed && + !terminalEnded && + handle !== null && + recovery.currentPhase !== 'disconnected' && + (recovery.isActive || (connecting && !connected) || recoveryInputHold.isHolding()) + ) + } + + function heldInputEndpoint(targetHandle: string) { + return { + handle: targetHandle, + incarnationId: authoritativePtyIncarnationId + } + } + + // Why: releases a stale hold first, so the hold check reads the handle that release left bound. + function releaseThenHoldEndpoint( + inputKind: TerminalInputKind + ): RemoteRuntimeInputEndpoint | null { + if (recoveryInputHold.isHolding()) { + releaseHeldInput() + } + return handle && inputKind !== 'query-reply' && shouldHoldInput() + ? heldInputEndpoint(handle) + : null + } + + function releaseHeldInput(): void { + const boundHandle = handle + if (destroyed || terminalEnded || !boundHandle) { + recoveryInputHold.discard() + return + } + if ( + !connected || + recoveryBlocksIo() || + !attachmentReady || + !getCurrentMultiplexedStream(boundHandle) + ) { + return + } + recoveryInputHold.release(heldInputEndpoint(boundHandle), { + isCurrent: () => connected && handle === boundHandle && !recoveryBlocksIo(), + sendInput: (data) => sendInputNow(data), + sendInputImmediate: (data) => sendInputImmediateNow(data), + sendInputAccepted: sendInputAcceptedToRuntime + }) + } + async function sendInputAcceptedToRuntime(data: string): Promise { const targetHandle = handle if (!connected || !targetHandle || recoveryBlocksIo()) { @@ -1566,6 +1680,7 @@ export function createRemoteRuntimePtyTransport( } function retireRemoteTerminalId(exitCode?: number): void { + recoveryInputHold.discard() recovery.cancel() resetRecoveryReplacementPolicy() resetSameHandleEndReuse() @@ -1595,6 +1710,8 @@ export function createRemoteRuntimePtyTransport( nextIncarnationId: string | null = null ): void { clearPublishedHandleWait() + // Why: keys typed at the replaced shell must not run in its successor (#10065). + recoveryInputHold.discard() const replacedPtyId = remotePtyId unregisterShutdownHandlers(replacedPtyId) handle = nextHandle @@ -1722,12 +1839,13 @@ export function createRemoteRuntimePtyTransport( scheduleCapacityPressureRetry() return } - if (isRecoverableRemoteRuntimeConnectionError(clientError)) { + if (isRecoverablePaneBindingError(error)) { // Why: a partition is attachment state, not a terminal failure; keep the red error surface for actionable fatal errors. scheduleResubscribeAfterTransportClose() return } connecting = false + recoveryInputHold.discard() emitRecoveryState() surfaceErrorMessage(message) } @@ -1744,7 +1862,7 @@ export function createRemoteRuntimePtyTransport( closeMultiplexedStream() } clearPendingViewportClaim() - if (!isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))) { + if (!isRecoverablePaneBindingError(error)) { return false } if (recovery.currentPhase === 'disconnected') { @@ -1828,7 +1946,8 @@ export function createRemoteRuntimePtyTransport( ) return } else if (tabId && leafId && worktreeId) { - const resolved = await resolvePersistedHostPane() + const resolution = await resolvePersistedHostPane() + const resolved = resolution.kind === 'resolved' ? resolution.terminal : null if (destroyed || !connected || handle !== previousHandle) { return } @@ -1914,8 +2033,7 @@ export function createRemoteRuntimePtyTransport( .catch((error) => { if (!destroyed && connected && handle && recovery.isCurrent(recoveryEpoch)) { clearPendingViewportClaim() - const clientError = toRemoteRuntimeClientErrorLike(error) - if (isRecoverableRemoteRuntimeConnectionError(clientError)) { + if (isRecoverablePaneBindingError(error)) { retryScheduled = recovery.schedule(recoveryEpoch, (nextEpoch) => { const currentReplacementPolicy = handle ? getRecoveryReplacementPolicy(handle) @@ -1989,6 +2107,10 @@ export function createRemoteRuntimePtyTransport( generation === subscriptionGeneration && (expectedRecoveryEpoch === undefined || recovery.ownsEpoch(expectedRecoveryEpoch)) && isCurrentRemoteTerminal(subscribedHandle, subscribedPtyId) + if (currentPaneRevision() !== getRuntimeEnvironmentRevision(currentRuntimeEnvironmentId)) { + // Why local: the shared stream would subscribe on a pairing not proven to be this pane's machine. + throw runtimeEnvironmentPairingChangedError() + } const nextStream = await getRemoteRuntimeTerminalMultiplexer( currentRuntimeEnvironmentId ).subscribeTerminal({ @@ -2037,6 +2159,7 @@ export function createRemoteRuntimePtyTransport( ...(meta?.cols !== undefined && meta.rows !== undefined ? { snapshotCols: meta.cols, snapshotRows: meta.rows } : {}), + ...(meta?.keepsLocalScrollback ? { keepsLocalScrollback: true } : {}), carriesNormalBuffer: true }) } @@ -2074,6 +2197,7 @@ export function createRemoteRuntimePtyTransport( storedCallbacks.onStreamRecovered?.() } storedCallbacks.onStatus?.('shell') + releaseHeldInput() }, onEnd: (verdict) => { if (!isCurrentSubscription()) { @@ -2092,6 +2216,7 @@ export function createRemoteRuntimePtyTransport( return } unregisterShutdownHandlers(subscribedPtyId) + recoveryInputHold.discard() connected = false connecting = false handle = null @@ -2151,6 +2276,7 @@ export function createRemoteRuntimePtyTransport( } } else { connecting = false + recoveryInputHold.discard() recovery.cancel() setAttachmentUnavailable() emitRecoveryState() @@ -2190,6 +2316,156 @@ export function createRemoteRuntimePtyTransport( } // Why: a live claim may already have cleared the flag, so drain on every install. flushPendingClaimInput(nextStream) + releaseHeldInput() + } + + // Why replay: a recovery retry re-enters attach inside its own epoch; cancelling it would restart + // the backoff ladder forever and the auto-recovery deadline could never latch (#18495). + function attachPane(options: Parameters[0], replay: boolean): void { + const attachLifecycleEpoch = ++lifecycleEpoch + const generation = ++attachGeneration + cancelTerminalCreateRetryWait() + if (!replay) { + recovery.cancel() + } + resetRecoveryReplacementPolicy() + resetSameHandleEndReuse() + clearPublishedHandleWait() + lastAttachOptions = options + storedCallbacks = options.callbacks + terminalEnded = false + connecting = true + emitRecoveryState(true) + const previousHandle = handle + const previousPtyId = remotePtyId + const nextHandle = getRemoteRuntimeTerminalHandle(options.existingPtyId) + if (previousHandle && previousHandle !== nextHandle) { + // Why: debounced input is scoped by the current terminal handle at flush time. + inputBatcher.clear() + recoveryInputHold.discard() + } + const persistedEnvironmentId = getRemoteRuntimePtyEnvironmentId(options.existingPtyId) + handle = nextHandle + unregisterShutdownHandlers(previousPtyId) + connected = false + remotePtyId = null + clearPendingViewportClaim() + closeMultiplexedStream() + if (!nextHandle) { + // Why: an empty parsed handle ('remote:') is falsy but not null; shouldHoldInput keys on null. + handle = null + connecting = false + emitRecoveryState() + surfaceErrorMessage('Remote runtime terminal id is invalid.') + return + } + const persistedHandle = nextHandle + void (async () => { + const adoptPersistedHandle = () => + adoptResolvedHostPane( + { + handle: persistedHandle, + tabId: tabId ?? '', + leafId: leafId ?? '', + ptyId: null, + worktreeId + }, + options, + false, + generation + ) + if (isWebTerminalSurfaceTabId(tabId ?? '')) { + await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch) + return + } + if (!tabId || !leafId || !worktreeId) { + await adoptPersistedHandle() + return + } + const resolution = await resolvePersistedHostPane() + if (generation !== attachGeneration || destroyed) { + return + } + if (resolution.kind === 'resolved') { + await adoptResolvedHostPane(resolution.terminal, options, false, generation) + return + } + if (resolvePaneUnavailable && persistedEnvironmentId === currentRuntimeEnvironmentId) { + await adoptPersistedHandle() + return + } + surfaceErrorMessage('Remote terminal was closed.') + if (resolution.kind === 'absent') { + // Why: the host answered, but its pane registry may still be rebuilding after a restart, + // so retire with the host-loss sentinel instead of closing the tab outright (#21344). + remotePtyId = options.existingPtyId + retireRemoteTerminalId(-1) + return + } + // Why: a host that cannot resolve this pane gives no later answer to wait for; settle offline. + connecting = false + emitRecoveryState() + })().catch((error) => { + if (generation !== attachGeneration || attachLifecycleEpoch !== lifecycleEpoch || destroyed) { + return + } + clearPendingViewportClaim() + if (!connected && isRetryableUnboundAttachError(error)) { + // Why: no handle is bound yet, so the resubscribe path cannot own this retry; replay attach + // instead of latching in 'connecting' with nothing armed (#18495). + connecting = false + scheduleConnectRetryAfterRecoverableFailure() + return + } + recovery.cancel() + handleRemoteTerminalError(error) + }) + } + + function sendInputNow(data: string): boolean { + if (!connected || !handle || recoveryBlocksIo()) { + return false + } + if (!data) { + return true + } + // Why: literal LF bytes from paste/programmatic input must survive; callers use \r or the enter flag for semantic Enter. + return inputBatcher.push(data) + } + + function sendInputImmediateNow(data: string): boolean { + const targetHandle = handle + const targetLifecycleEpoch = lifecycleEpoch + if (!connected || !targetHandle || recoveryBlocksIo()) { + return false + } + if (!data) { + return true + } + // Why: wait behind async validation, but keep the reply as its own host-classifiable write. + if (inputBatcher.hasPendingValidation()) { + inputBatcher.enqueueAfterValidation(() => { + if ( + !connected || + lifecycleEpoch !== targetLifecycleEpoch || + handle !== targetHandle || + recoveryBlocksIo() + ) { + return + } + const pending = inputBatcher.takePending() + if (pending) { + sendUnacknowledgedInput(pending) + } + sendUnacknowledgedInput(data, true) + }) + return true + } + const pending = inputBatcher.takePending() + if (pending && !sendUnacknowledgedInput(pending)) { + return false + } + return sendUnacknowledgedInput(data, true) } let connectForRecovery: PtyTransport['connect'] = (options) => transport.connect(options) @@ -2224,9 +2500,9 @@ export function createRemoteRuntimePtyTransport( if (options.sessionId && !getRemoteRuntimeTerminalHandle(options.sessionId)) { // Why: a HUB session persists host-native PTY ids; resolve its pane handle without exposing that SSH identity as a client transport id. - const terminal = await resolvePersistedHostPane() - if (terminal) { - return await adoptResolvedHostPane(terminal, options) + const resolution = await resolvePersistedHostPane() + if (resolution.kind === 'resolved') { + return await adoptResolvedHostPane(resolution.terminal, options) } } @@ -2369,7 +2645,7 @@ export function createRemoteRuntimePtyTransport( }) // Snapshot parity must not delay attachment to a terminal the host already created. void refreshWebRuntimeSessionTabsSnapshot(createEnvironmentId, worktreeId, { - expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision, + expectedEnvironmentPairingRevision: currentPaneRevision(), acceptCurrentSnapshot: true, confirmAgentSessionHandoff: { provisionalTabId: tabId, @@ -2431,9 +2707,7 @@ export function createRemoteRuntimePtyTransport( if (isRemoteTerminalGoneMessage(message)) { recovery.cancel() handleRemoteTerminalError(error) - } else if ( - isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) - ) { + } else if (isRecoverablePaneBindingError(error)) { scheduleConnectRetryAfterRecoverableFailure() } else { recovery.cancel() @@ -2446,107 +2720,13 @@ export function createRemoteRuntimePtyTransport( }, attach(options) { - const attachLifecycleEpoch = ++lifecycleEpoch - const generation = ++attachGeneration - cancelTerminalCreateRetryWait() - recovery.cancel() - resetRecoveryReplacementPolicy() - resetSameHandleEndReuse() - clearPublishedHandleWait() - lastAttachOptions = options - storedCallbacks = options.callbacks - terminalEnded = false - connecting = true - emitRecoveryState(true) - // Why: persisted ids are untrusted cache state; the worktree owner selected this transport and must remain authoritative. - currentRuntimeEnvironmentId = runtimeEnvironmentId - const previousHandle = handle - const previousPtyId = remotePtyId - const nextHandle = getRemoteRuntimeTerminalHandle(options.existingPtyId) - if (previousHandle && previousHandle !== nextHandle) { - // Why: debounced input is scoped by the current terminal handle at flush time. - inputBatcher.clear() - } - const persistedEnvironmentId = getRemoteRuntimePtyEnvironmentId(options.existingPtyId) - handle = nextHandle - unregisterShutdownHandlers(previousPtyId) - connected = false - remotePtyId = null - clearPendingViewportClaim() - closeMultiplexedStream() - if (!nextHandle) { - handle = null - connecting = false - emitRecoveryState() - surfaceErrorMessage('Remote runtime terminal id is invalid.') - return - } - const persistedHandle = nextHandle - void (async () => { - if (isWebTerminalSurfaceTabId(tabId ?? '')) { - await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch) - return - } - if (!tabId || !leafId || !worktreeId) { - await adoptResolvedHostPane( - { - handle: persistedHandle, - tabId: tabId ?? '', - leafId: leafId ?? '', - ptyId: null, - worktreeId - }, - options, - false, - generation - ) - return - } - const resolved = await resolvePersistedHostPane() - if (generation !== attachGeneration || destroyed) { - return - } - if ( - !resolved && - resolvePaneUnavailable && - persistedEnvironmentId === currentRuntimeEnvironmentId - ) { - await adoptResolvedHostPane( - { - handle: persistedHandle, - tabId: tabId ?? '', - leafId: leafId ?? '', - ptyId: null, - worktreeId - }, - options, - false, - generation - ) - return - } - if (!resolved) { - surfaceErrorMessage('Remote terminal was closed.') - return - } - await adoptResolvedHostPane(resolved, options, false, generation) - })().catch((error) => { - if ( - generation !== attachGeneration || - attachLifecycleEpoch !== lifecycleEpoch || - destroyed - ) { - return - } - clearPendingViewportClaim() - recovery.cancel() - handleRemoteTerminalError(error) - }) + attachPane(options, false) }, disconnect() { lifecycleEpoch += 1 attachGeneration += 1 + recoveryInputHold.discard() cancelTerminalCreateRetryWait() recovery.cancel() resetRecoveryReplacementPolicy() @@ -2584,6 +2764,7 @@ export function createRemoteRuntimePtyTransport( outputProcessor.disposePendingSideEffectGauge() lifecycleEpoch += 1 attachGeneration += 1 + recoveryInputHold.discard() cancelTerminalCreateRetryWait() recovery.cancel() resetRecoveryReplacementPolicy() @@ -2603,56 +2784,30 @@ export function createRemoteRuntimePtyTransport( storedCallbacks = {} }, - // Why no kind: terminal.send has no launch kind, and its query-reply kind is for mobile - // clients, so the host classifies a desktop's bytes itself. - sendInput(data: string): boolean { - if (!connected || !handle || recoveryBlocksIo()) { - return false + // Why: the kind only gates the local hold; terminal.send gets no kind, so the host classifies desktop bytes itself. + sendInput(data, inputKind): boolean { + const held = releaseThenHoldEndpoint(inputKind) + if (held) { + return !data || recoveryInputHold.enqueue(held, data, inputKind) } - if (!data) { - return true - } - // Why: literal LF bytes from paste/programmatic input must survive; callers use \r or the enter flag for semantic Enter. - return inputBatcher.push(data) + return sendInputNow(data) }, // Why: query replies (CPR/DSR/DA/OSC) are read in raw mode with a short timeout; the 8ms debounce would miss it and echo the reply onto the prompt (#7329). - sendInputImmediate(data: string): boolean { - const targetHandle = handle - const targetLifecycleEpoch = lifecycleEpoch - if (!connected || !targetHandle || recoveryBlocksIo()) { - return false + sendInputImmediate: (data: string): boolean => sendInputImmediateNow(data), + + sendInputAccepted(data, inputKind) { + const held = releaseThenHoldEndpoint(inputKind) + if (held && data) { + return recoveryInputHold.enqueueAccepted(held, data, inputKind) } - if (!data) { - return true - } - // Why: wait behind async validation, but keep the reply as its own host-classifiable write. - if (inputBatcher.hasPendingValidation()) { - inputBatcher.enqueueAfterValidation(() => { - if ( - !connected || - lifecycleEpoch !== targetLifecycleEpoch || - handle !== targetHandle || - recoveryBlocksIo() - ) { - return - } - const pending = inputBatcher.takePending() - if (pending) { - sendUnacknowledgedInput(pending) - } - sendUnacknowledgedInput(data, true) - }) - return true - } - const pending = inputBatcher.takePending() - if (pending && !sendUnacknowledgedInput(pending)) { - return false - } - return sendUnacknowledgedInput(data, true) + return sendInputAcceptedToRuntime(data) }, - sendInputAccepted: sendInputAcceptedToRuntime, + // Why: a transport with an armed retry or a parked one owns this pane's recovery; a remount would race it (#21195). + ownsRecovery() { + return !destroyed && !terminalEnded && recoveryBlocksIo() + }, claimViewport(cols: number, rows: number): boolean { if (!connected || !handle) { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts new file mode 100644 index 00000000000..4c24bfab1be --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRemoteRuntimeRecoveryInputHold } from './remote-runtime-recovery-input-hold' + +function createWriter() { + const sent: string[] = [] + return { + sent, + writer: { + isCurrent: () => true, + sendInput: vi.fn((data: string) => { + sent.push(data) + return true + }), + sendInputImmediate: vi.fn(() => true), + sendInputAccepted: vi.fn(async (data: string) => { + sent.push(data) + return true + }) + } + } +} + +const endpoint = { handle: 'terminal-1', incarnationId: 'inc-1' } + +describe('remote runtime recovery input hold', () => { + it('delivers held input in order to the terminal it was typed into', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const { sent, writer } = createWriter() + expect(hold.enqueue(endpoint, 'ls', 'driving')).toBe(true) + const accepted = hold.enqueueAccepted(endpoint, '\r', 'driving') + + hold.release(endpoint, writer) + + await expect(accepted).resolves.toBe(true) + expect(sent).toEqual(['ls', '\r']) + await vi.waitFor(() => expect(hold.isHolding()).toBe(false)) + }) + + it('drops held input when the pane rebinds to a different terminal', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const { sent, writer } = createWriter() + const accepted = hold.enqueueAccepted(endpoint, 'rm -rf build\r', 'driving') + + hold.release({ handle: 'terminal-2', incarnationId: null }, writer) + + await expect(accepted).resolves.toBe(false) + expect(sent).toEqual([]) + expect(hold.isHolding()).toBe(false) + }) + + it('drops held input when the same handle reports a new incarnation', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const { sent, writer } = createWriter() + hold.enqueue(endpoint, 'make\r', 'driving') + + hold.release({ handle: 'terminal-1', incarnationId: 'inc-2' }, writer) + + expect(sent).toEqual([]) + }) + + it('accepts new input right after a release drained the previous hold', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const { sent, writer } = createWriter() + hold.enqueue(endpoint, 'a', 'driving') + hold.release(endpoint, writer) + await vi.waitFor(() => expect(sent).toEqual(['a'])) + + expect(hold.enqueue(endpoint, 'b', 'driving')).toBe(true) + hold.release(endpoint, writer) + await vi.waitFor(() => expect(sent).toEqual(['a', 'b'])) + }) + + it('resolves held acknowledged input as undelivered on discard', async () => { + const hold = createRemoteRuntimeRecoveryInputHold() + const accepted = hold.enqueueAccepted(endpoint, 'x', 'driving') + + hold.discard() + + await expect(accepted).resolves.toBe(false) + expect(hold.isHolding()).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts new file mode 100644 index 00000000000..99798cdaff8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts @@ -0,0 +1,104 @@ +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' +import { + createPtyPreconnectInputBuffer, + type PtyPreconnectInputBuffer +} from './pty-preconnect-input-buffer' + +/** The remote terminal a held keystroke was typed into. */ +export type RemoteRuntimeInputEndpoint = { + handle: string + incarnationId: string | null +} + +type HeldInputWriter = { + isCurrent: () => boolean + sendInput: (data: string, inputKind: TerminalInputKind) => boolean + sendInputImmediate: (data: string) => boolean + sendInputAccepted: (data: string, inputKind: TerminalInputKind) => Promise +} + +export type RemoteRuntimeRecoveryInputHold = { + isHolding: () => boolean + enqueue: ( + endpoint: RemoteRuntimeInputEndpoint, + data: string, + inputKind: TerminalInputKind + ) => boolean + enqueueAccepted: ( + endpoint: RemoteRuntimeInputEndpoint, + data: string, + inputKind: TerminalInputKind + ) => Promise + /** Delivers held input to the endpoint it was typed into, or drops it if the pane rebound elsewhere. */ + release: (bound: RemoteRuntimeInputEndpoint, writer: HeldInputWriter) => void + discard: () => void +} + +function isSameEndpoint( + held: RemoteRuntimeInputEndpoint, + bound: RemoteRuntimeInputEndpoint +): boolean { + // Why: an incarnation learned only after the hold began is not evidence of a respawn. + return ( + held.handle === bound.handle && + (held.incarnationId === null || + bound.incarnationId === null || + held.incarnationId === bound.incarnationId) + ) +} + +/** Keeps keystrokes typed while a remote pane is binding or recovering, scoped to one endpoint. */ +export function createRemoteRuntimeRecoveryInputHold(): RemoteRuntimeRecoveryInputHold { + let held: { + endpoint: RemoteRuntimeInputEndpoint + buffer: PtyPreconnectInputBuffer + } | null = null + + const discard = (): void => { + const current = held + held = null + current?.buffer.clear() + } + + const bufferFor = (endpoint: RemoteRuntimeInputEndpoint): PtyPreconnectInputBuffer => { + if (held && !isSameEndpoint(held.endpoint, endpoint)) { + // Why: input typed at one shell must never run in its replacement (#10065). + discard() + } + if (!held?.buffer.isBuffering()) { + // Why: a drained buffer refuses input; its release bookkeeping may still be settling. + held = { endpoint, buffer: createPtyPreconnectInputBuffer() } + } + return held.buffer + } + + return { + isHolding: () => held?.buffer.isBuffering() === true, + enqueue: (endpoint, data, inputKind) => + bufferFor(endpoint).enqueue(data, 'ordinary', inputKind), + enqueueAccepted: (endpoint, data, inputKind) => + bufferFor(endpoint).enqueueAccepted(data, inputKind), + release(bound, writer) { + const current = held + if (!current) { + return + } + if (!isSameEndpoint(current.endpoint, bound)) { + discard() + return + } + void current.buffer + .flush({ + ...writer, + isCurrent: () => held === current && writer.isCurrent() + }) + .catch(() => undefined) + .finally(() => { + if (held === current && !current.buffer.isBuffering()) { + held = null + } + }) + }, + discard + } +} diff --git a/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts b/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts index d5ed2ff5f49..9949f8966ff 100644 --- a/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts @@ -380,19 +380,23 @@ describe('host-published recovery snapshot onto a live alt screen', () => { // alt and show the host's normal screen. it('repaints from the normal buffer once the host TUI has exited', async () => { const { data, meta } = await publishHostRecovery(AGENT_EXIT) + // The ACK-overflow recovery is screen-only and says so. + expect(meta.keepsLocalScrollback).toBe(true) expect(meta.terminalOwner).toBe('shell') expect(meta.alternateScreen).toBe(false) const client = await render([LIVE_PANE, ...(await drainOntoLiveAltScreen(data, meta))]) const host = await render([LIVE_PANE, AGENT_EXIT]) - const fresh = await render([data]) try { expect(client.buffer.active.type).toBe('normal') expect(viewport(client, 'normal')).toEqual(viewport(host, 'normal')) - expect(bufferLines(client, 'normal')).toEqual(bufferLines(fresh, 'normal')) + // A screen-only image keeps the pane's history (#14593); the one row the missed output + // scrolled off the host's screen is the only thing it cannot restore. + expect(bufferLines(client, 'normal')).toEqual( + bufferLines(host, 'normal').filter((line) => line !== 'SETUP-OUTPUT-5') + ) } finally { client.dispose() host.dispose() - fresh.dispose() } }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-snapshot-scrollback-preservation.test.ts b/src/renderer/src/components/terminal-pane/remote-snapshot-scrollback-preservation.test.ts new file mode 100644 index 00000000000..28b7bf743f0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-snapshot-scrollback-preservation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { encodeTerminalStreamJson } from '../../../../shared/terminal-stream-protocol' +import { + decodeSnapshotInfo, + pushedSnapshotKeepsLocalScrollback +} from '../../runtime/remote-runtime-terminal-snapshot-state' +import { writeHeadlessTerminal } from './pty-connection-test-async' +import { buildFoldedImageReplayWrites } from './terminal-snapshot-replay-paint' + +const COLS = 40 +const ROWS = 5 + +function lines(term: Terminal): string[] { + const buffer = term.buffer.normal + return Array.from( + { length: buffer.length }, + (_, row) => buffer.getLine(row)?.translateToString(true) ?? '' + ).filter((line) => line.length > 0) +} + +async function paneWithHistory(count: number): Promise { + const term = new Terminal({ cols: COLS, rows: ROWS, scrollback: 1000, allowProposedApi: true }) + await writeHeadlessTerminal( + term, + Array.from({ length: count }, (_, i) => `HISTORY-${i}`).join('\r\n') + ) + return term +} + +async function replay(term: Terminal, image: string, keepScrollback: boolean): Promise { + const writes = buildFoldedImageReplayWrites(image, false, keepScrollback) + await writeHeadlessTerminal(term, writes.preamble) + await writeHeadlessTerminal(term, writes.payload) +} + +function snapshotInfo(fields: Record) { + return decodeSnapshotInfo(encodeTerminalStreamJson({ cols: COLS, rows: ROWS, ...fields })) +} + +describe('remote snapshot replay keeps history the image cannot replace (#14593)', () => { + it('reads scrollbackRows off the wire and treats absence as an older host', () => { + expect(snapshotInfo({ scrollbackRows: 1000 })?.scrollbackRows).toBe(1000) + expect(snapshotInfo({ scrollbackRows: -1 })?.scrollbackRows).toBeUndefined() + expect(snapshotInfo({ scrollbackRows: 1.5 })?.scrollbackRows).toBeUndefined() + expect(pushedSnapshotKeepsLocalScrollback(snapshotInfo({}))).toBe(true) + expect(pushedSnapshotKeepsLocalScrollback(snapshotInfo({ scrollbackRows: 0 }))).toBe(true) + expect(pushedSnapshotKeepsLocalScrollback(snapshotInfo({ scrollbackRows: 1000 }))).toBe(false) + }) + + it('a screen-only image repaints the screen and keeps the scrollback above it', async () => { + const term = await paneWithHistory(40) + await replay(term, 'SCREEN-A\r\nSCREEN-B', true) + const after = lines(term) + expect(after.slice(0, 35)).toEqual(Array.from({ length: 35 }, (_, i) => `HISTORY-${i}`)) + expect(after.slice(-2)).toEqual(['SCREEN-A', 'SCREEN-B']) + term.dispose() + }) + + it('an image that carries history still replaces the scrollback without duplicating it', async () => { + const term = await paneWithHistory(40) + const image = Array.from({ length: 12 }, (_, i) => `HOST-${i}`).join('\r\n') + await replay(term, image, false) + expect(lines(term)).toEqual(Array.from({ length: 12 }, (_, i) => `HOST-${i}`)) + term.dispose() + }) + + it('pre-TUI shell output survives a screen-only image of a running TUI (#6106)', async () => { + const term = await paneWithHistory(20) + // The host folds its normal screen in, then enters alt for the TUI frame. + await replay(term, 'SHELL-SCREEN\x1b[?1049h\x1b[2J\x1b[HTUI-FRAME', true) + expect(term.buffer.active.type).toBe('alternate') + expect(lines(term).slice(0, 15)).toEqual(Array.from({ length: 15 }, (_, i) => `HISTORY-${i}`)) + term.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index ef534946ef1..8be7cff5ea8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -77,7 +77,8 @@ export function buildSnapshotReplayPreamble( */ export function buildFoldedImageReplayWrites( data: string, - paneOnAlternateScreen: boolean + paneOnAlternateScreen: boolean, + keepScrollback = false ): { preamble: string; payload: string } { const split = paneOnAlternateScreen ? splitAtAlternateScreenEntry(data) : null if (split) { @@ -90,7 +91,11 @@ export function buildFoldedImageReplayWrites( } } return { - preamble: buildSnapshotReplayPreamble({ targetAlternateScreen: false, paneOnAlternateScreen }), + preamble: buildSnapshotReplayPreamble({ + targetAlternateScreen: false, + paneOnAlternateScreen, + keepScrollback + }), payload: data } } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index f9834ac2fe0..d7a0d5dc614 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3134,6 +3134,9 @@ "viewing": "Viewing: {{path}}" } }, + "menu": { + "quitApp": "Quit {{appName}}" + }, "runtimeRpc": { "startupFailure": { "guidance": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25baa79eb85..d1bfaf4285e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -332,6 +332,7 @@ "reportCrash": "Report Crash...", "file": "File", "exit": "Exit", + "quitApp": "Quit {{appName}}", "edit": "Edit", "appearance": "Appearance", "toggleLeftSidebar": "Toggle Left Sidebar", @@ -8817,7 +8818,9 @@ "c77f1abfe3": "Ending remote terminals", "18968ede9e": "Error", "f0871e6bfb": "connect", - "47e94bd6ba": "connected" + "47e94bd6ba": "connected", + "hostNodeFallback": "Unsupported configuration: Orca-managed Node isn’t available for this connection, so Orca runs on this host’s Node.js with terminal support installed by npm on the host.", + "hostNodeUnsupported": "Unsupported configuration: Orca runs on this host’s Node.js with terminal support installed by npm on the host. Set Runtime to Auto in this host’s SSH settings to use Orca-managed Node." }, "SshTargetDestructiveActions": { "7e66942808": "This will stop active terminal sessions on this SSH target. Reconnecting will not restore them.", @@ -8865,6 +8868,8 @@ "remoteRuntimeAuto": "Auto", "remoteRuntimePinnedNode": "Orca-managed Node", "remoteRuntimeHostNode": "Host Node", + "allowRemoteCliControl": "Allow this host's orca CLI to control Orca", + "allowRemoteCliControlHelp": "When off, orca commands run on this host reach only its own terminals. Turn on to let them reach other hosts, orchestration, files, and integrations on this computer.", "editTitle": "Edit SSH host", "addTitle": "Add SSH host", "editDescription": "Update connection details for this machine. Changes apply on next connect.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 020e9cf1c13..750fbef3167 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -164,6 +164,7 @@ "reportCrash": "Informar fallo...", "file": "Archivo", "exit": "Salir", + "quitApp": "Salir de {{appName}}", "edit": "Editar", "appearance": "Apariencia", "toggleLeftSidebar": "Alternar barra lateral izquierda", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 46622f0ffc1..92ac812a798 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -265,6 +265,7 @@ "reportCrash": "Signaler un plantage...", "file": "Fichier", "exit": "Quitter", + "quitApp": "Quitter {{appName}}", "edit": "Édition", "appearance": "Apparence", "toggleLeftSidebar": "Basculer la barre latérale gauche", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e5e78f750fe..caed57c905b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -164,6 +164,7 @@ "reportCrash": "クラッシュを報告…", "file": "ファイル", "exit": "終了", + "quitApp": "{{appName}} を終了", "edit": "編集", "appearance": "外観", "toggleLeftSidebar": "左サイドバーの表示/非表示", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3d9b8ed3e88..2684f911e6c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -164,6 +164,7 @@ "reportCrash": "크래시 신고...", "file": "파일", "exit": "종료", + "quitApp": "{{appName}} 종료", "edit": "편집", "appearance": "외관", "toggleLeftSidebar": "왼쪽 사이드바 표시/숨기기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 65da17b2c29..60f088cf0b4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -164,6 +164,7 @@ "reportCrash": "发送崩溃报告...", "file": "文件", "exit": "退出", + "quitApp": "退出 {{appName}}", "edit": "编辑", "appearance": "外观", "toggleLeftSidebar": "显示/隐藏左侧边栏", diff --git a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts index 2a3a24af8b5..7fc0a8e093d 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts @@ -15,9 +15,13 @@ import { clearResyncTimer, clearSnapshot, concatBytes, - decodeSnapshotInfo + decodeSnapshotInfo, + pushedSnapshotKeepsLocalScrollback } from './remote-runtime-terminal-snapshot-state' -import type { RemoteRuntimeMultiplexedTerminalState } from './remote-runtime-terminal-multiplexer-types' +import type { + RemoteRuntimeMultiplexedTerminalCallbacks, + RemoteRuntimeMultiplexedTerminalState +} from './remote-runtime-terminal-multiplexer-types' export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntimeTerminalResponseController { protected handleSnapshotOrErrorFrame( @@ -71,7 +75,23 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime (typeof info?.requestId === 'number' ? info.requestId === pendingRequest.requestId : stream.initialSnapshotReceived) + const keepsLocalScrollback = pushedSnapshotKeepsLocalScrollback(info) if (snapshotApplied) { + const pushedMeta: NonNullable< + Parameters[1] + > = { + pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, + seq: info?.seq, + kittyKeyboardFlags: info?.kittyKeyboardFlags, + alternateScreen: info?.alternateScreen, + terminalOwner: info?.terminalOwner, + // Why: the image encodes wraps and cursor moves against the host's + // grid, so the restorer must replay it there — the request path has + // always carried these; the pushes silently dropped them. + cols: info?.cols, + rows: info?.rows, + keepsLocalScrollback + } if (matchesPendingRequest) { pendingRequest.resolve({ availability: classifySnapshotAvailability(stream.snapshotOverflowed, info), @@ -91,36 +111,18 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime }) clearPendingSnapshotRequest(stream) } else if (target === 'initial') { - stream.callbacks.onSnapshot(data ?? '', { - pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, - seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags, - alternateScreen: info?.alternateScreen, - terminalOwner: info?.terminalOwner, - // Why: the image encodes wraps and cursor moves against the host's - // grid, so the restorer must replay it there — the request path has - // always carried these; the pushes silently dropped them. - cols: info?.cols, - rows: info?.rows - }) + stream.callbacks.onSnapshot(data ?? '', pushedMeta) } else if (target === 'recovery') { // Why: a server-pushed recovery snapshot replaces terminal state - // mid-session; clear the screen and scrollback before applying it. + // mid-session; clear the screen, and the scrollback only when the + // image carries its own, before applying it. // An empty snapshot is still applied so stale dropped output does // not linger on a terminal the model says is blank. // RELEASE_SYNCHRONIZED_OUTPUT: \x1b[2J does not clear mode 2026, so a pane // holding an open latch would not paint this recovery snapshot at all. stream.callbacks.onSnapshot( - `${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, - { - pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, - seq: info?.seq, - kittyKeyboardFlags: info?.kittyKeyboardFlags, - alternateScreen: info?.alternateScreen, - terminalOwner: info?.terminalOwner, - cols: info?.cols, - rows: info?.rows - } + `${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J${keepsLocalScrollback ? '' : '\x1b[3J'}\x1b[H${data ?? ''}`, + pushedMeta ) } } else if (matchesPendingRequest) { diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts index 0f591a50412..2f8512453ca 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -243,7 +243,7 @@ describe('remote terminal frame-drop resync', () => { expect(data).toEqual(['aaa']) expect(server.droppedFrames).toBe(1) // Instead, a fresh authoritative snapshot recovers the terminal. - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) server.replaySnapshotCoveredOutput('ccc') server.output('ddd') @@ -270,7 +270,7 @@ describe('remote terminal frame-drop resync', () => { expect(server.snapshotRequests).toEqual([undefined, undefined]) server.output('eee') - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) expect(data).toEqual(['aaa', 'eee']) } finally { vi.useRealTimers() @@ -298,7 +298,7 @@ describe('remote terminal frame-drop resync', () => { server.output('eee') expect(server.snapshotRequests).toEqual([undefined, undefined]) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) expect(data).toEqual(['aaa', 'eee']) }) @@ -321,7 +321,7 @@ describe('remote terminal frame-drop resync', () => { await expect(manualSnapshot).rejects.toThrow('stream failed') expect(server.snapshotRequests).toEqual([expect.any(Number), undefined]) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) server.output('ddd') expect(data).toEqual(['aaa', 'ddd']) @@ -342,7 +342,7 @@ describe('remote terminal frame-drop resync', () => { server.output('eee') expect(server.snapshotRequests).toEqual([undefined, undefined]) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) expect(data).toEqual(['aaa', 'eee']) } finally { vi.useRealTimers() @@ -452,7 +452,7 @@ describe('remote terminal frame-drop resync', () => { await Promise.resolve() expect(data).toEqual([]) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) }) it('uses UTF-16 sequence units when detecting gaps in multibyte output', async () => { @@ -466,7 +466,7 @@ describe('remote terminal frame-drop resync', () => { await Promise.resolve() expect(data).toEqual(['é']) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) }) it('defers recovery until an in-flight manual snapshot finishes', async () => { @@ -489,7 +489,7 @@ describe('remote terminal frame-drop resync', () => { await expect(manualSnapshot).resolves.toMatchObject({ data: 'MANUAL' }) expect(server.snapshotRequests).toHaveLength(2) - expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[HRECOVERED']) server.output('ddd') expect(data).toEqual(['aaa', 'ddd']) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-base.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-base.ts index a9d27b7c6c9..5d53f5e76fd 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-base.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-base.ts @@ -20,6 +20,7 @@ import type { RuntimeEnvironmentSubscriptionHandle } from './remote-runtime-terminal-multiplexer-types' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { refreshRuntimeEnvironmentsAfterPairingChange } from './runtime-environment-pairing-refresh' export abstract class RemoteRuntimeTerminalMultiplexerBase { protected readonly streams = new Map() @@ -51,6 +52,10 @@ export abstract class RemoteRuntimeTerminalMultiplexerBase { this.handleClose('Runtime environment pairing changed.') } + closeForEnvironmentRetirement(): void { + this.handleClose('Runtime environment was removed or replaced by another machine.', false) + } + protected allocateStreamId(): number { const start = this.nextStreamId do { @@ -114,12 +119,14 @@ export abstract class RemoteRuntimeTerminalMultiplexerBase { this.subscription = subscription this.resolveReadyIfConnected() }) - .catch((error) => { + .catch(async (error) => { if (this.connectPromise === connectPromise) { this.connectPromise = null this.readyResolver = null this.readyRejecter = null } + // Why: a pane retries on this rejection; it must find the re-read pairing, not the stale one. + await refreshRuntimeEnvironmentsAfterPairingChange(error) reject(error instanceof Error ? error : new Error(String(error))) }) }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts index a8632c132a8..c072e315796 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts @@ -45,6 +45,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { * it, which must read as unknown so replay keeps the pane's own grid. */ cols?: number rows?: number + /** The image carries no history, so replay must keep the pane's own scrollback. */ + keepsLocalScrollback?: boolean } ) => void onSubscribed?: () => void @@ -193,6 +195,8 @@ export type RemoteRuntimeSnapshotInfo = { requestId?: number truncated?: boolean unavailable?: TerminalSnapshotUnavailableReason + /** History rows above the screen; absent from hosts that predate the field. */ + scrollbackRows?: number // Why: a mid-escape tail the emulator could not serialize; the transport // must write it AFTER the replay reset so the next live chunk completes it // instead of rendering literally (#7329). diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 06c2ed4736c..c106da12ce4 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -4,7 +4,11 @@ import { } from './remote-runtime-terminal-e2e-control' import type { RemoteRuntimeTerminalMultiplexerBase } from './remote-runtime-terminal-multiplexer-base' import { RemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer-implementation' -import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { + getRuntimeEnvironmentRevision, + onRuntimeEnvironmentRevisionsChanged, + onRuntimeEnvironmentsRetired +} from './runtime-environment-revision' export { REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS, @@ -25,6 +29,25 @@ export type { const multiplexers = new Map() +// Why: a removed or replaced machine's streams end for good, before its new revision is published. +onRuntimeEnvironmentsRetired((environmentIds) => { + for (const environmentId of environmentIds) { + multiplexers.get(environmentId)?.closeForEnvironmentRetirement() + } +}) + +// Why: a stream opened before main re-paired stays open but sendFrame refuses its stale revision, +// so input would vanish silently; a recoverable close lets each pane rebind, and the pane itself +// only follows the new pairing once it is proven to be the same machine. +onRuntimeEnvironmentRevisionsChanged((environmentIds) => { + for (const environmentId of environmentIds) { + const multiplexer = multiplexers.get(environmentId) + if (multiplexer && !multiplexer.matchesCurrentEnvironmentRevision()) { + multiplexer.closeForEnvironmentReplacement() + } + } +}) + function releaseRemoteRuntimeTerminalMultiplexer( environmentId: string, multiplexer: RemoteRuntimeTerminalMultiplexerBase diff --git a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts index 5682a1a3bdd..7453a85fa1d 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts @@ -103,7 +103,10 @@ export abstract class RemoteRuntimeTerminalResponseController extends RemoteRunt event.mode !== 'remote-desktop-fit' && event.mode !== 'desktop-fit') || typeof event.cols !== 'number' || - typeof event.rows !== 'number' + typeof event.rows !== 'number' || + // Why: a restarted host that has not learned the grid yet publishes a 0x0 hold; parking + // xterm there shrinks the pane to one cell, so keys typed into it never reach the shell. + (event.mode !== 'desktop-fit' && (event.cols <= 0 || event.rows <= 0)) ) { return } diff --git a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts index d8f930dc898..4919419af29 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts @@ -97,6 +97,7 @@ export function decodeSnapshotInfo( kittyKeyboardFlags?: unknown alternateScreen?: unknown terminalOwner?: unknown + scrollbackRows?: unknown }>(payload) if (!raw) { return null @@ -116,11 +117,27 @@ export function decodeSnapshotInfo( requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined, truncated: raw.truncated === true, unavailable: parseTerminalSnapshotUnavailableReason(raw.unavailable), + scrollbackRows: + typeof raw.scrollbackRows === 'number' && + Number.isSafeInteger(raw.scrollbackRows) && + raw.scrollbackRows >= 0 + ? raw.scrollbackRows + : undefined, pendingEscapeTailAnsi: typeof raw.pendingEscapeTailAnsi === 'string' ? raw.pendingEscapeTailAnsi : undefined } } +/** + * Whether a pushed (initial or recovery) image leaves the pane's history alone. + * Absent counts as none: hosts that predate the field pushed desktop images screen-only (#14593). + */ +export function pushedSnapshotKeepsLocalScrollback( + info: RemoteRuntimeSnapshotInfo | null +): boolean { + return (info?.scrollbackRows ?? 0) === 0 +} + export function retryWorthySnapshotOutcome( cause: RemoteRuntimeSnapshotRetryCause ): RemoteRuntimeSnapshotOutcome { diff --git a/src/renderer/src/runtime/runtime-environment-pairing-refresh.ts b/src/renderer/src/runtime/runtime-environment-pairing-refresh.ts index 3f124a574c8..a247aa248cc 100644 --- a/src/renderer/src/runtime/runtime-environment-pairing-refresh.ts +++ b/src/renderer/src/runtime/runtime-environment-pairing-refresh.ts @@ -15,6 +15,10 @@ export function setRuntimeEnvironmentCatalogRefresher(refresher: CatalogRefreshe refreshCatalog = refresher } +export function runtimeEnvironmentPairingChangedError(): Error { + return new Error(`${PAIRING_CHANGED_MESSAGE}; refresh and try again`) +} + export function isRuntimeEnvironmentPairingChangedError(error: unknown): boolean { const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' return message.includes(PAIRING_CHANGED_MESSAGE) diff --git a/src/renderer/src/runtime/runtime-environment-revision.ts b/src/renderer/src/runtime/runtime-environment-revision.ts index 3f08f22ee4b..9c0a2b7d8d3 100644 --- a/src/renderer/src/runtime/runtime-environment-revision.ts +++ b/src/renderer/src/runtime/runtime-environment-revision.ts @@ -1,8 +1,44 @@ const revisionByEnvironmentId = new Map() +// Pairings proven to continue the same machine: old revision → the revision that replaced it. +const continuedRevisionsByEnvironmentId = new Map>() +const retirementListeners = new Set<(environmentIds: readonly string[]) => void>() +const revisionChangeListeners = new Set<(environmentIds: readonly string[]) => void>() + +export type RuntimeEnvironmentContinuity = { + /** Ids now naming a different machine. */ + retired: readonly string[] + /** Re-pairs proven to be the same machine (same registration and host key). */ + sameHost: readonly { id: string; fromRevision: number; toRevision: number }[] +} export function replaceRuntimeEnvironmentRevisions( - environments: readonly { id: string; createdAt: number; pairingRevision?: number }[] + environments: readonly { id: string; createdAt: number; pairingRevision?: number }[], + continuity: RuntimeEnvironmentContinuity = { retired: [], sameHost: [] } ): void { + const previous = new Map(revisionByEnvironmentId) + const nextIds = new Set(environments.map((environment) => environment.id)) + const retired = new Set([ + ...continuity.retired, + ...[...previous.keys()].filter((id) => !nextIds.has(id)) + ]) + for (const id of retired) { + continuedRevisionsByEnvironmentId.delete(id) + } + // Why first: an old machine's transports must be fenced while the old revision still stands, + // so nothing they retry can be authenticated with the replacement's credentials. + if (retired.size > 0) { + for (const listener of retirementListeners) { + listener([...retired]) + } + } + for (const { id, fromRevision, toRevision } of continuity.sameHost) { + if (fromRevision === toRevision || retired.has(id)) { + continue + } + const continued = continuedRevisionsByEnvironmentId.get(id) ?? new Map() + continued.set(fromRevision, toRevision) + continuedRevisionsByEnvironmentId.set(id, continued) + } revisionByEnvironmentId.clear() for (const environment of environments) { revisionByEnvironmentId.set( @@ -10,12 +46,57 @@ export function replaceRuntimeEnvironmentRevisions( environment.pairingRevision ?? environment.createdAt ) } + const changed = [...previous].flatMap(([id, revision]) => + retired.has(id) || revisionByEnvironmentId.get(id) === revision ? [] : [id] + ) + if (changed.length > 0) { + for (const listener of revisionChangeListeners) { + listener(changed) + } + } +} + +/** Fires, before the new revisions are published, with ids removed or now naming another machine. */ +export function onRuntimeEnvironmentsRetired( + listener: (environmentIds: readonly string[]) => void +): () => void { + retirementListeners.add(listener) + return () => retirementListeners.delete(listener) +} + +/** Fires with the ids still present whose saved pairing moved. */ +export function onRuntimeEnvironmentRevisionsChanged( + listener: (environmentIds: readonly string[]) => void +): () => void { + revisionChangeListeners.add(listener) + return () => revisionChangeListeners.delete(listener) } export function getRuntimeEnvironmentRevision(environmentId: string): number | undefined { return revisionByEnvironmentId.get(environmentId) } +/** + * The newest pairing proven to be the same machine as `revision`. A holder of an older pairing may + * follow a same-host rotation, but never a re-pair whose host identity is different or unresolved. + */ +export function resolveContinuedRuntimeEnvironmentRevision( + environmentId: string, + revision: number | undefined +): number | undefined { + if (revision === undefined) { + return getRuntimeEnvironmentRevision(environmentId) + } + const continued = continuedRevisionsByEnvironmentId.get(environmentId) + let current = revision + const seen = new Set() + while (continued?.has(current) && !seen.has(current)) { + seen.add(current) + current = continued.get(current) ?? current + } + return current +} + export function captureRuntimeEnvironmentRequestRevision( environmentId: string, expectedRevision?: number diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 994cdbe5824..cfaa771af49 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -575,7 +575,9 @@ describe('remote runtime terminal multiplex ACK gate', () => { pendingEscapeTailAnsi: undefined, // The host's serialization grid; the restorer replays there, not at the pane's own. cols: 120, - rows: 40 + rows: 40, + // No scrollbackRows: an older host's screen-only image. + keepsLocalScrollback: true }) expect(onSubscribed).toHaveBeenCalledTimes(1) @@ -585,17 +587,19 @@ describe('remote runtime terminal multiplex ACK gate', () => { cols: 120, rows: 40, reason: 'ack-pending-overflow', - truncated: false + truncated: false, + scrollbackRows: 1000 }, 'recovered state' ) - // Why: an unsolicited recovery snapshot replaces terminal state, so it - // clears screen and scrollback first and must not replay the subscribe - // lifecycle. + // Why: an unsolicited recovery snapshot that carries history replaces + // terminal state, so it clears screen and scrollback first and must not + // replay the subscribe lifecycle. expect(onSnapshot).toHaveBeenCalledWith(`\x1b[?2026l\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, { pendingEscapeTailAnsi: undefined, cols: 120, - rows: 40 + rows: 40, + keepsLocalScrollback: false }) expect(onSubscribed).toHaveBeenCalledTimes(1) @@ -611,10 +615,12 @@ describe('remote runtime terminal multiplex ACK gate', () => { }, '' ) - expect(onSnapshot).toHaveBeenCalledWith('\x1b[?2026l\x1b[2J\x1b[3J\x1b[H', { + // A screen-only recovery (scrollbackRows absent or 0) keeps the pane's history (#14593). + expect(onSnapshot).toHaveBeenCalledWith('\x1b[?2026l\x1b[2J\x1b[H', { pendingEscapeTailAnsi: undefined, cols: 120, - rows: 40 + rows: 40, + keepsLocalScrollback: true }) expect(onSubscribed).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index c0f64fed827..3c1e79288cc 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -4,7 +4,6 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' import { selectProjectGroupRemovalTargets } from '../slices/project-group-removal-targets' import { - catalogOwnsHost, getProjectGroupHostId, projectGroupMatchesOwnerHost, resolveProjectGroupOwnerHostId, @@ -180,26 +179,36 @@ export function createProjectGroupMutationActions( const removedProjectIds: string[] = [] const failedProjectRemovals: ProjectRemovalFailure[] = [] - // Why: the group's catalog can hold rows from several hosts (a local catalog also owns SSH rows), - // so each project is removed on its own host rather than on the group's. - const findOwnedProjects = (projectId: string): Repo[] => - get().repos.filter( - (repo) => - repo.id === projectId && - (!ownerHostId || catalogOwnsHost(ownerHostId, getRepoExecutionHostId(repo))) - ) - for (const projectId of targets.projectIds) { - const ownedProjects = findOwnedProjects(projectId) - const projectHostId = - ownedProjects.length === 1 ? getRepoExecutionHostId(ownedProjects[0]) : undefined - try { - if (ownedProjects.length > 0) { - await get().removeProject(projectId, { hostId: projectHostId }) - } - } catch (err) { - console.error('Failed to remove contained project:', err) + // Why: members were captured before the cascade cleared membership; a same-id row on another + // host outside the group must survive, so each member row is removed on its own host. + const membersById = new Map() + for (const member of targets.projects) { + const row = findRepoForHost(get().repos, member.id, { hostId: member.hostId }) + if (row) { + membersById.set(member.id, [...(membersById.get(member.id) ?? []), row]) } - const stillExists = findOwnedProjects(projectId).length > 0 + } + for (const projectId of new Set(targets.projectIds)) { + const members = membersById.get(projectId) ?? [] + // Why: an unstamped group already routes to the focused host, so only that host's member is removed. + const focusedMember = ownerHostId + ? null + : findRepoForHost(members, projectId, { settings: get().settings }) + const removable = ownerHostId ? members : focusedMember ? [focusedMember] : [] + const removableHostIds = removable.map((row) => getRepoExecutionHostId(row)) + for (const hostId of removableHostIds) { + try { + await get().removeProject(projectId, { hostId }) + } catch (err) { + console.error('Failed to remove contained project:', err) + } + } + // Why: an ambiguous unstamped member with no focused-host row was left in place, so it counts as remaining. + const stillExists = + (members.length > 0 && removableHostIds.length === 0) || + removableHostIds.some((hostId) => + get().repos.some((repo) => repoMatchesHostIdentity(repo, projectId, hostId)) + ) if (stillExists) { failedProjectRemovals.push({ projectId, diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 572e8a78180..1943b3dd722 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -4,7 +4,7 @@ import type { AppState } from '../types' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency' import { omitSparsePresetsForRepos } from '../slices/sparse-presets' -import { findRepoForHost, repoMatchesHostIdentity } from '../slices/repo-host-identity' +import { repoMatchesHostIdentity } from '../slices/repo-host-identity' import { callRuntimeRpc, getActiveRuntimeTarget, @@ -57,11 +57,10 @@ export function createRepoRemovalActions( return { removeProject: async (projectId, options) => { try { - // Why: pass an explicit hostId so a duplicate id across hosts resolves to the intended row, not the focused-host fallback. - const ownerRepo = findRepoForHost(get().repos, projectId, { - settings: get().settings, - hostId: options?.hostId - }) + // Why: exact host match only; the unique-candidate/focused fallbacks could pick another host's row (#13071). + const ownerRepo = get().repos.find((repo) => + repoMatchesHostIdentity(repo, projectId, options.hostId) + ) if (!ownerRepo) { return } @@ -79,14 +78,8 @@ export function createRepoRemovalActions( ) } } - // Why: derive the target from the owner's settings (via options.hostId) so an SSH host removal never routes repo.rm to the focused runtime. - const target = getActiveRuntimeTarget( - settingsForRepoOwner(get(), projectId, options?.hostId) - ) - // Why: repos:remove is id-only and would delete every host's row; scope local removal to the owning host so cross-host duplicates keep other rows. - const idExistsOnOtherHost = get().repos.some( - (repo) => repo.id === projectId && getRepoExecutionHostId(repo) !== ownerHostId - ) + // Why: derive the target from the owner row's host so an SSH host removal never routes repo.rm to the focused runtime. + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId, ownerHostId)) // Why before the host call: its announcement can start a listing refresh that drops these tabs. const chatDraftKeys = captureWorkspaceChatDraftKeys( get(), @@ -96,10 +89,9 @@ export function createRepoRemovalActions( })) ) try { + // Why: always host-scoped; this catalog may be stale and miss a same-id row on another host (#13071). await (target.kind === 'local' - ? idExistsOnOtherHost - ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) - : window.api.repos.remove({ repoId: projectId }) + ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) } catch (err) { // Why: the owner already dropped this project, so purge the local ghost row instead of aborting (#11994). @@ -285,7 +277,7 @@ export function createRepoRemovalActions( } catch (err) { console.error('Failed to remove repo:', err) // Why: bulk and background callers aggregate their own failures, so only opted-in single-project entry points toast (#11994). - if (options?.errorFeedback === 'toast') { + if (options.errorFeedback === 'toast') { toast.error( translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'), { diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 16cf707b122..325930d0872 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -253,11 +253,11 @@ export type RepoSlice = { groupId: string | null, order?: number ) => Promise - // options.hostId disambiguates which host's row to remove when the id exists on multiple hosts; else the focused host is assumed. + // options.hostId is required: a bare id can resolve to another host's row (#13071). Removes nothing when that host has no row. // options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting. removeProject: ( projectId: string, - options?: { hostId?: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } + options: { hostId: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } ) => Promise updateProject: (projectId: string, updates: ProjectUpdate) => Promise // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the focused host is assumed. diff --git a/src/renderer/src/store/slices/project-group-removal-targets.ts b/src/renderer/src/store/slices/project-group-removal-targets.ts index 667d74ceb1f..3ff2e55b357 100644 --- a/src/renderer/src/store/slices/project-group-removal-targets.ts +++ b/src/renderer/src/store/slices/project-group-removal-targets.ts @@ -4,10 +4,14 @@ import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { catalogOwnsHost, getProjectGroupHostId } from './project-group-owner-routing' +export type ProjectGroupRemovalProject = { id: string; hostId: ExecutionHostId } + export type ProjectGroupRemovalTargets = { groupExists: boolean deletedGroupIds: Set projectIds: string[] + // Why: ids repeat across hosts and membership is per row, so removal must target the exact member rows. + projects: ProjectGroupRemovalProject[] } export function selectProjectGroupRemovalTargets( @@ -27,25 +31,30 @@ export function selectProjectGroupRemovalTargets( return { groupExists: false, deletedGroupIds: new Set(), - projectIds: [] + projectIds: [], + projects: [] } } const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) const projectIds: string[] = [] + const projects: ProjectGroupRemovalProject[] = [] for (const repo of repos) { + const repoHostId = getRepoExecutionHostId(repo) if ( repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId) && - ownsRowHost(getRepoExecutionHostId(repo)) + ownsRowHost(repoHostId) ) { projectIds.push(repo.id) + projects.push({ id: repo.id, hostId: repoHostId }) } } return { groupExists: true, deletedGroupIds, - projectIds + projectIds, + projects } } diff --git a/src/renderer/src/store/slices/project-removal-owner-host.test.ts b/src/renderer/src/store/slices/project-removal-owner-host.test.ts new file mode 100644 index 00000000000..4d798fbcf93 --- /dev/null +++ b/src/renderer/src/store/slices/project-removal-owner-host.test.ts @@ -0,0 +1,238 @@ +/** + * #13071: a project id can exist on several hosts. Removing one host's project must + * never resolve the bare id to another host's row (the unique-candidate or + * focused-host fallback), or the other host's live project is destroyed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { purgeOrphanedRuntimeSshProjects } from './worktrees/teardown/orphaned-runtime-ssh-project-purge' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const baseRepo: Repo = { + id: 'same-repo', + path: '/work/same', + displayName: 'Same', + badgeColor: '#000', + addedAt: 1 +} +const localRow: Repo = { ...baseRepo, executionHostId: 'local' } +const sshRow: Repo = { + ...baseRepo, + path: '/remote/same', + connectionId: 'target-1', + executionHostId: 'ssh:target-1' +} +const runtimeRow: Repo = { + ...baseRepo, + path: '/server/same', + executionHostId: 'runtime:env-b' +} + +const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + executionHostId: 'local' +} + +const reposRemove = vi.fn() +const reposRemoveForHost = vi.fn() +const projectGroupsDelete = vi.fn() +const runtimeCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + for (const mock of [reposRemove, reposRemoveForHost, projectGroupsDelete, runtimeCall]) { + mock.mockReset() + } + reposRemove.mockResolvedValue(undefined) + reposRemoveForHost.mockResolvedValue(undefined) + projectGroupsDelete.mockResolvedValue(true) + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + projectGroups: { delete: projectGroupsDelete }, + pty: { kill: vi.fn() }, + runtimeEnvironments: { call: runtimeCall }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + }) +}) + +describe('project removal stays on its owner host (#13071)', () => { + it('leaves the only remaining row alone when the named host has no row', async () => { + const store = createTestStore() + // Host A's row dropped from the catalog; host B's row is the unique candidate for the id. + store.setState({ repos: [runtimeRow] }) + + await store.getState().removeProject('same-repo', { hostId: 'local' }) + + expect(store.getState().repos).toEqual([runtimeRow]) + expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() + expect(runtimeCall).not.toHaveBeenCalled() + }) + + it('purges a destroyed SSH host row without touching the same id on the focused host', async () => { + const store = createTestStore() + store.setState({ repos: [localRow, sshRow] }) + + await purgeOrphanedRuntimeSshProjects(store.getState, ['target-1']) + + expect(store.getState().repos).toEqual([localRow]) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'same-repo', + hostId: 'ssh:target-1' + }) + expect(reposRemove).not.toHaveBeenCalled() + }) + + it('removes every row an owned group holds under the id, each on its own host', async () => { + const store = createTestStore() + store.setState({ + projectGroups: [group], + repos: [ + { ...localRow, projectGroupId: group.id }, + { ...sshRow, projectGroupId: group.id }, + runtimeRow + ] + }) + + const result = await store.getState().deleteProjectGroupWithContainedProjects(group.id, { + removeContainedProjects: true, + hostId: 'local' + }) + + expect(result).toMatchObject({ + removedProjectIds: ['same-repo'], + failedProjectRemovals: [] + }) + // The runtime host's row is outside the local catalog and must survive. + expect(store.getState().repos).toEqual([runtimeRow]) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'same-repo', + hostId: 'local' + }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'same-repo', + hostId: 'ssh:target-1' + }) + expect(runtimeCall).not.toHaveBeenCalled() + }) + + it('keeps a same-id row on another host that sits outside the deleted group', async () => { + const store = createTestStore() + const otherGroup: ProjectGroup = { ...group, id: 'group-2', name: 'Other' } + const outsideSshRow: Repo = { ...sshRow, projectGroupId: otherGroup.id } + store.setState({ + projectGroups: [group, otherGroup], + repos: [{ ...localRow, projectGroupId: group.id }, outsideSshRow] + }) + + const result = await store.getState().deleteProjectGroupWithContainedProjects(group.id, { + removeContainedProjects: true, + hostId: 'local' + }) + + expect(result).toMatchObject({ + removedProjectIds: ['same-repo'], + failedProjectRemovals: [] + }) + expect(store.getState().repos).toEqual([outsideSshRow]) + expect(reposRemoveForHost).toHaveBeenCalledTimes(1) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'local' }) + }) + + it('removes only the member row of an unstamped group, not the focused-host non-member', async () => { + const store = createTestStore() + const unstampedGroup: ProjectGroup = { ...group, executionHostId: undefined } + const memberSshRow: Repo = { ...sshRow, projectGroupId: unstampedGroup.id } + store.setState({ projectGroups: [unstampedGroup], repos: [localRow, memberSshRow] }) + + const result = await store.getState().deleteProjectGroupWithContainedProjects(group.id, { + removeContainedProjects: true + }) + + expect(result).toMatchObject({ + removedProjectIds: ['same-repo'], + failedProjectRemovals: [] + }) + expect(store.getState().repos).toEqual([localRow]) + expect(reposRemoveForHost).toHaveBeenCalledTimes(1) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'ssh:target-1' }) + }) +}) + +describe('removal stays host-scoped when this catalog is stale (#13071)', () => { + type MainRow = { id: string; hostId: string; kind: 'git' | 'folder' } + + // Mirrors the main catalog: id-only removal drops every host's row, removeForHost one row. + function stubMainCatalog(rows: MainRow[]): { current: MainRow[] } { + const main = { current: rows } + reposRemove.mockImplementation(async ({ repoId }: { repoId: string }) => { + main.current = main.current.filter((row) => row.id !== repoId) + }) + reposRemoveForHost.mockImplementation( + async ({ repoId, hostId }: { repoId: string; hostId: string }) => { + main.current = main.current.filter((row) => !(row.id === repoId && row.hostId === hostId)) + } + ) + return main + } + + it.each(['git', 'folder'] as const)( + 'keeps an unseen same-id SSH %s project when removing the local one', + async (kind) => { + const main = stubMainCatalog([ + { id: 'same-repo', hostId: 'local', kind }, + { id: 'same-repo', hostId: 'ssh:target-1', kind } + ]) + const store = createTestStore() + // Another window added the SSH twin; this renderer has not refreshed yet. + store.setState({ repos: [{ ...localRow, kind }] }) + + await store.getState().removeProject('same-repo', { hostId: 'local' }) + + expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'local' }) + expect(main.current).toEqual([{ id: 'same-repo', hostId: 'ssh:target-1', kind }]) + } + ) + + it.each(['git', 'folder'] as const)( + 'removes a direct SSH %s project without touching an unseen local twin', + async (kind) => { + const main = stubMainCatalog([ + { id: 'same-repo', hostId: 'local', kind }, + { id: 'same-repo', hostId: 'ssh:target-1', kind } + ]) + const store = createTestStore() + store.setState({ repos: [{ ...sshRow, kind }] }) + + await store.getState().removeProject('same-repo', { hostId: 'ssh:target-1' }) + + expect(reposRemove).not.toHaveBeenCalled() + expect(main.current).toEqual([{ id: 'same-repo', hostId: 'local', kind }]) + } + ) + + it('never falls back to id-only removal when the selected row is already gone in main', async () => { + const main = stubMainCatalog([{ id: 'same-repo', hostId: 'ssh:target-1', kind: 'git' }]) + const store = createTestStore() + store.setState({ repos: [localRow] }) + + await store.getState().removeProject('same-repo', { hostId: 'local' }) + + expect(reposRemove).not.toHaveBeenCalled() + expect(main.current).toEqual([{ id: 'same-repo', hostId: 'ssh:target-1', kind: 'git' }]) + }) +}) diff --git a/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts b/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts index d3cac1929aa..a8e4e049679 100644 --- a/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts +++ b/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts @@ -159,7 +159,7 @@ describe('deleting one host copy of a same-named project', () => { it('removes only the remote row when the same name exists locally', async () => { const store = seed([localTwin, remoteATwin], 'env-a') - await store.getState().removeProject('env-a-uuid') + await store.getState().removeProject('env-a-uuid', { hostId: 'runtime:env-a' }) expect(repoRmCalls()).toEqual([ expect.objectContaining({ @@ -177,7 +177,8 @@ describe('deleting one host copy of a same-named project', () => { await store.getState().removeProject('local-uuid', { hostId: 'local' }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'local-uuid' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'local-uuid', hostId: 'local' }) + expect(reposRemove).not.toHaveBeenCalled() expect(repoRmCalls()).toEqual([]) expect(remainingRepoIds(store)).toEqual(['env-a-uuid']) }) @@ -185,7 +186,7 @@ describe('deleting one host copy of a same-named project', () => { it('removes only remote A when the same name exists on remote B', async () => { const store = seed([remoteATwin, remoteBTwin], 'env-b') - await store.getState().removeProject('env-a-uuid') + await store.getState().removeProject('env-a-uuid', { hostId: 'runtime:env-a' }) expect(repoRmCalls()).toEqual([ expect.objectContaining({ diff --git a/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts b/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts index afc54b88af1..a6bceb41ef5 100644 --- a/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts +++ b/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts @@ -1,11 +1,12 @@ import { expect, it, vi } from 'vitest' import { toast } from 'sonner' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' import { ephemeralVmCleanup, ephemeralVmListRuntimes, installReposRuntimeRoutingHarness, - reposRemove, + reposRemoveForHost, sshRepo } from './repos-runtime-routing-fixture' import { createTestStore } from './store-test-helpers' @@ -38,10 +39,13 @@ it('retains a runtime-owned SSH project when VM cleanup fails', async () => { const store = createTestStore() store.setState({ repos: [runtimeRepo], activeRepoId: runtimeRepo.id }) - await store.getState().removeProject(runtimeRepo.id, { errorFeedback: 'toast' }) + await store.getState().removeProject(runtimeRepo.id, { + hostId: getRepoExecutionHostId(runtimeRepo), + errorFeedback: 'toast' + }) expect(store.getState().repos).toEqual([runtimeRepo]) - expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() expect(toast.error).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ description: expect.stringContaining('Retry cleanup') }) diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 9b6bf765d43..a67543a705a 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -301,7 +301,7 @@ describe('repo slice host identity routing', () => { ]) }) - it('removes only the focused host row and worktrees for duplicate repo ids', async () => { + it('removes only the named host row and worktrees for duplicate repo ids', async () => { const localWorktree = makeWorktree({ id: 'same-repo::/local/wt', repoId: 'same-repo' @@ -345,7 +345,7 @@ describe('repo slice host identity routing', () => { } }) - await store.getState().removeProject('same-repo') + await store.getState().removeProject('same-repo', { hostId: 'local' }) expect(store.getState().repos).toEqual([remoteDuplicate]) // Current contract: bare-ID UI selections clear even though the sibling host row survives. @@ -404,13 +404,13 @@ describe('repo slice host identity routing', () => { it('allows two removals of the same owner row to overlap', async () => { const { promise: firstRemoval, resolve: resolveFirstRemoval } = Promise.withResolvers() const { promise: secondRemoval, resolve: resolveSecondRemoval } = Promise.withResolvers() - reposRemove.mockReturnValueOnce(firstRemoval).mockReturnValueOnce(secondRemoval) + reposRemoveForHost.mockReturnValueOnce(firstRemoval).mockReturnValueOnce(secondRemoval) const store = createTestStore() store.setState({ repos: [localDuplicate] }) - const first = store.getState().removeProject(localDuplicate.id) - const second = store.getState().removeProject(localDuplicate.id) - expect(reposRemove).toHaveBeenCalledTimes(2) + const first = store.getState().removeProject(localDuplicate.id, { hostId: 'local' }) + const second = store.getState().removeProject(localDuplicate.id, { hostId: 'local' }) + expect(reposRemoveForHost).toHaveBeenCalledTimes(2) resolveSecondRemoval() resolveFirstRemoval() @@ -553,7 +553,7 @@ describe('repo slice host identity routing', () => { } }) - await store.getState().removeProject('same-repo') + await store.getState().removeProject('same-repo', { hostId: 'runtime:env-1' }) expect(store.getState().repos).toEqual([localDuplicate]) expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) diff --git a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts index 8d8093fc8fe..5e793f03a05 100644 --- a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts @@ -191,8 +191,9 @@ describe('project group deletion store routing', () => { failedProjectRemovals: [] }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'direct' }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'nested' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'direct', hostId: 'local' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'nested', hostId: 'local' }) + expect(reposRemove).not.toHaveBeenCalled() expect(store.getState().repos).toEqual([siblingRepo]) }) @@ -227,19 +228,18 @@ describe('project group deletion store routing', () => { groupId: projectGroup.id, requestedProjectIds: ['shared', 'shared'], removedProjectIds: ['shared'], - failedProjectRemovals: [ - { - projectId: 'shared', - reason: 'Project remained in Orca after removeProject completed.' - } - ] + failedProjectRemovals: [] }) expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'shared', hostId: 'local' }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: 'shared' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'shared', + hostId: 'ssh:ssh-1' + }) + expect(reposRemove).not.toHaveBeenCalled() expect(store.getState().repos).toEqual([]) }) @@ -270,7 +270,7 @@ describe('project group deletion store routing', () => { it('reports project removal failures by comparing store state after removeProject', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - reposRemove.mockImplementation(async ({ repoId }: { repoId: string }) => { + reposRemoveForHost.mockImplementation(async ({ repoId }: { repoId: string }) => { if (repoId === 'nested') { throw new Error('remove failed') } diff --git a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts index c936001cc3d..b18fd5743dd 100644 --- a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts @@ -61,7 +61,7 @@ beforeEach(() => { }) vi.stubGlobal('window', { api: { - repos: { remove: vi.fn() }, + repos: {}, projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } @@ -280,11 +280,10 @@ describe('project group state cascades stay scoped to the owner host', () => { it('removes contained projects only from the owner host', async () => { projectGroupsDelete.mockResolvedValue(true) - const reposRemove = vi.fn().mockResolvedValue(undefined) const reposRemoveForHost = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('window', { api: { - repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + repos: { removeForHost: reposRemoveForHost }, projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } diff --git a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts index 2c35bb35fb8..551e4363ee7 100644 --- a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts +++ b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts @@ -13,6 +13,7 @@ * grew monotonically. The single `removeWorktree` path already routes through * `purgeWorktreeTerminalState`; project removal did not. */ +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { describe, it, expect, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import type { Repo } from '../../../../shared/repo-types' @@ -28,7 +29,7 @@ beforeEach(() => { ptyKill.mockReset() vi.stubGlobal('window', { api: { - repos: { remove: reposRemove }, + repos: { removeForHost: reposRemove }, pty: { kill: ptyKill }, runtimeEnvironments: { call: vi.fn() } } @@ -63,7 +64,7 @@ describe('removeProject purges per-worktree state (leak regression)', () => { const store = createTestStore() seedTwoProjects(store) - await store.getState().removeProject(repo1.id) + await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) const s = store.getState() // Removed project's worktree is purged from every map. @@ -79,7 +80,7 @@ describe('removeProject purges per-worktree state (leak regression)', () => { const store = createTestStore() seedTwoProjects(store) - await store.getState().removeProject(repo1.id) + await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) const s = store.getState() // Surviving project's worktree state is untouched (guard over-eviction). diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts index f7dca6594ae..9879516226e 100644 --- a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -41,7 +41,7 @@ export const reposAdd: Mock = vi.fn() export const reposPickFolder: Mock = vi.fn() export const reposClone: Mock = vi.fn() export const reposCloneRemote: Mock = vi.fn() -export const reposRemove: Mock = vi.fn() +export const reposRemoveForHost: Mock = vi.fn() export const reposUpdate: Mock = vi.fn() export const reposReorder: Mock = vi.fn() export const reposReorderForHost: Mock = vi.fn() @@ -72,7 +72,7 @@ export function installReposRuntimeRoutingHarness(): void { reposPickFolder.mockReset() reposClone.mockReset() reposCloneRemote.mockReset() - reposRemove.mockReset() + reposRemoveForHost.mockReset() reposUpdate.mockReset() reposReorder.mockReset() reposReorderForHost.mockReset() @@ -101,7 +101,7 @@ export function installReposRuntimeRoutingHarness(): void { clone: reposClone, cloneRemote: reposCloneRemote, pickFolder: reposPickFolder, - remove: reposRemove, + removeForHost: reposRemoveForHost, update: reposUpdate, reorder: reposReorder, reorderForHost: reposReorderForHost diff --git a/src/renderer/src/store/slices/repos-stale-fetch.test.ts b/src/renderer/src/store/slices/repos-stale-fetch.test.ts index d3b04b1b078..8993c823ec0 100644 --- a/src/renderer/src/store/slices/repos-stale-fetch.test.ts +++ b/src/renderer/src/store/slices/repos-stale-fetch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { createTestStore, makeLayout, makeTab } from './store-test-helpers' import type { Repo } from '../../../../shared/repo-types' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' @@ -60,7 +61,7 @@ beforeEach(() => { api: { repos: { list: reposList, - remove: vi.fn().mockResolvedValue(undefined) + removeForHost: vi.fn().mockResolvedValue(undefined) } } }) @@ -119,7 +120,9 @@ describe('repos slice stale-fetch race (#7020)', () => { store.setState({ repos: [localRepo] }) const pending = store.getState().fetchRepos() - await store.getState().removeProject(localRepo.id) + await store + .getState() + .removeProject(localRepo.id, { hostId: getRepoExecutionHostId(localRepo) }) expect(store.getState().repos).toEqual([]) resolveCatalog([localRepo]) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index d5a246b5da5..4c9a2897201 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from '../github/cache-identity' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' @@ -17,7 +18,7 @@ import { reposCloneRemote, reposList, reposPickFolder, - reposRemove, + reposRemoveForHost, reposReorder, reposUpdate, runtimeEnvironmentCall, @@ -35,6 +36,8 @@ vi.mock('sonner', () => ({ installReposRuntimeRoutingHarness() +const ownerOf = (repo: Repo) => ({ hostId: getRepoExecutionHostId(repo) }) + describe('repo slice runtime routing', () => { it('fetches repos from local IPC when no remote environment is active', async () => { reposList.mockResolvedValue([localRepo]) @@ -658,7 +661,7 @@ describe('repo slice runtime routing', () => { activeRepoId: remoteRepo.id }) - await store.getState().removeProject(remoteRepo.id) + await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) expect(store.getState().repos).toEqual([]) expect(store.getState().activeRepoId).toBeNull() @@ -668,7 +671,7 @@ describe('repo slice runtime routing', () => { params: { repo: remoteRepo.id }, timeoutMs: 15_000 }) - expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() }) it('removes SSH-owned repos through local IPC even when a runtime is focused', async () => { @@ -683,11 +686,11 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(sshRepo.id) + await store.getState().removeProject(sshRepo.id, ownerOf(sshRepo)) expect(store.getState().repos).toEqual([]) expect(store.getState().activeRepoId).toBeNull() - expect(reposRemove).toHaveBeenCalledWith({ repoId: sshRepo.id }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: sshRepo.id, hostId: 'ssh:ssh-1' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) @@ -704,11 +707,11 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(sshRepo.id) + await store.getState().removeProject(sshRepo.id, ownerOf(sshRepo)) expect(store.getState().repos).toEqual([localRepo]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktreeId]: 200 }) - expect(reposRemove).toHaveBeenCalledWith({ repoId: sshRepo.id }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: sshRepo.id, hostId: 'ssh:ssh-1' }) }) it('drops persisted visit timestamps for removed unhydrated runtime repos', async () => { @@ -731,7 +734,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(remoteRepo.id) + await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) expect(store.getState().repos).toEqual([localRepo]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktreeId]: 200 }) @@ -741,7 +744,7 @@ describe('repo slice runtime routing', () => { params: { repo: remoteRepo.id }, timeoutMs: 15_000 }) - expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() }) it('evicts GitHub caches for removed repos using repo id and legacy path keys', async () => { @@ -761,7 +764,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(localRepo.id) + await store.getState().removeProject(localRepo.id, ownerOf(localRepo)) expect(Object.keys(store.getState().workItemsCache)).toEqual([ workItemsCacheKey('other-repo', 20, '') @@ -793,7 +796,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(remoteRepo.id) + await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -839,7 +842,7 @@ describe('repo slice runtime routing', () => { activeWorktreeId: hiddenWorktree.id }) - await store.getState().removeProject(localRepo.id) + await store.getState().removeProject(localRepo.id, ownerOf(localRepo)) expect(store.getState().detectedWorktreesByRepo[localRepo.id]).toBeUndefined() expect(store.getState().tabsByWorktree[hiddenWorktree.id]).toBeUndefined() diff --git a/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts b/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts index e5e5316dab9..11b0a8da756 100644 --- a/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts +++ b/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { - peerReplacedEnvironmentIds, + classifyPeerReplacements, replacedRuntimeEnvironmentIds, resetDeferredPeerChecksForTests } from './runtime-environment-peer-replacement' @@ -25,11 +25,11 @@ function managed( } function retired(previous: ReturnType, next: ReturnType): string[] { - return peerReplacedEnvironmentIds( + return classifyPeerReplacements( [previous], [next], replacedRuntimeEnvironmentIds([previous], [next]) - ) + ).retired } describe('which re-paired environments name a different machine', () => { @@ -46,11 +46,11 @@ describe('which re-paired environments name a different machine', () => { it('defers a re-pair whose host key is not known yet instead of retiring it', () => { expect(retired(managed(), managed({ pairingRevision: 2, hostKey: null }))).toEqual([]) expect( - peerReplacedEnvironmentIds( + classifyPeerReplacements( [managed({ pairingRevision: 2, hostKey: null })], [managed({ pairingRevision: 2, hostKey: 'key-b' })], [] - ) + ).retired ).toEqual(['env-1']) }) @@ -61,7 +61,7 @@ describe('which re-paired environments name a different machine', () => { it('retires a re-paired environment that is not a managed server', () => { const paired = { id: 'env-1', createdAt: 1, pairingRevision: 1, hostKeyFingerprint: 'k' } expect( - peerReplacedEnvironmentIds([paired], [{ ...paired, pairingRevision: 2 }], ['env-1']) + classifyPeerReplacements([paired], [{ ...paired, pairingRevision: 2 }], ['env-1']).retired ).toEqual(['env-1']) }) @@ -69,3 +69,36 @@ describe('which re-paired environments name a different machine', () => { expect(replacedRuntimeEnvironmentIds([managed()], [managed()])).toEqual([]) }) }) + +describe('which re-pairs a live pane may follow', () => { + const classify = (previous: ReturnType, next: ReturnType) => + classifyPeerReplacements([previous], [next], replacedRuntimeEnvironmentIds([previous], [next])) + + it('reports a same-key update as a same-host rotation from the old pairing', () => { + expect(classify(managed(), managed({ pairingRevision: 2 }))).toEqual({ + retired: [], + sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }] + }) + }) + + it('never reports a changed key or a new registration as a same-host rotation', () => { + expect(classify(managed(), managed({ pairingRevision: 2, hostKey: 'key-b' })).sameHost).toEqual( + [] + ) + expect(classify(managed(), managed({ generation: 2, pairingRevision: 2 })).sameHost).toEqual([]) + }) + + it('reports a deferred re-pair only once its key proves the same host, from the original pairing', () => { + expect(classify(managed(), managed({ pairingRevision: 2, hostKey: null }))).toEqual({ + retired: [], + sameHost: [] + }) + expect( + classifyPeerReplacements( + [managed({ pairingRevision: 2, hostKey: null })], + [managed({ pairingRevision: 2 })], + [] + ) + ).toEqual({ retired: [], sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }] }) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts b/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts index d76f3657890..cdec9c9e401 100644 --- a/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts +++ b/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts @@ -14,17 +14,13 @@ export function replacedRuntimeEnvironmentIds( return next .filter((environment) => { const before = previousById.get(environment.id) - return ( - before !== undefined && - (before.pairingRevision ?? before.createdAt) !== - (environment.pairingRevision ?? environment.createdAt) - ) + return before !== undefined && pairingRevisionOf(before) !== pairingRevisionOf(environment) }) .map((environment) => environment.id) } -// Re-pairs of a managed server whose host key was not yet known: the host key it had before. -const deferredHostKeyById = new Map() +// Re-pairs of a managed server whose host key was not yet known: the host key and pairing it had before. +const deferredHostKeyById = new Map() export function resetDeferredPeerChecksForTests(): void { deferredHostKeyById.clear() @@ -44,20 +40,35 @@ function sameRegistration( ) } +function pairingRevisionOf(environment: CatalogEnvironment | undefined): number | null { + return environment ? (environment.pairingRevision ?? environment.createdAt) : null +} + +export type SameHostPairingRotation = { id: string; fromRevision: number; toRevision: number } + /** - * Ids that now name a different machine, whose workspaces and tabs are retired. A managed server - * re-pairs on every update and is the same machine while the host's key digest, which its pairing - * handshake proves, is unchanged under the same SSH target registration. A registration alone is - * no proof (a reinstall or a target that resolves elsewhere keeps it), so a re-pair whose key is - * not known yet is decided later, once a catalog carries it, rather than purged on a guess. + * `retired`: ids that now name a different machine, whose workspaces and tabs are retired. A managed + * server re-pairs on every update and is the same machine while the host's key digest, which its + * pairing handshake proves, is unchanged under the same SSH target registration. A registration + * alone is no proof (a reinstall or a target that resolves elsewhere keeps it), so a re-pair whose + * key is not known yet is decided later, once a catalog carries it, rather than purged on a guess. + * `sameHost`: re-pairs proven to be the same machine, with the pairing they continue. Re-pairs in + * neither list are still unresolved. */ -export function peerReplacedEnvironmentIds( +export function classifyPeerReplacements( previous: readonly CatalogEnvironment[], next: readonly CatalogEnvironment[], replacedIds: readonly string[] -): string[] { +): { retired: string[]; sameHost: SameHostPairingRotation[] } { const nextById = new Map(next.map((environment) => [environment.id, environment])) const retired: string[] = [] + const sameHost: SameHostPairingRotation[] = [] + const recordSameHost = (id: string, fromRevision: number | null): void => { + const toRevision = pairingRevisionOf(nextById.get(id)) + if (fromRevision !== null && toRevision !== null) { + sameHost.push({ id, fromRevision, toRevision }) + } + } for (const id of replacedIds) { const before = previous.find((environment) => environment.id === id) const after = nextById.get(id) @@ -66,18 +77,22 @@ export function peerReplacedEnvironmentIds( retired.push(id) continue } - const beforeKey = before?.hostKeyFingerprint ?? deferredHostKeyById.get(id) ?? null + const deferred = deferredHostKeyById.get(id) + const beforeKey = before?.hostKeyFingerprint ?? deferred?.hostKey ?? null + const fromRevision = deferred ? deferred.revision : pairingRevisionOf(before) const afterKey = after?.hostKeyFingerprint if (beforeKey && afterKey) { deferredHostKeyById.delete(id) if (beforeKey !== afterKey) { retired.push(id) + } else { + recordSameHost(id, fromRevision) } continue } - deferredHostKeyById.set(id, beforeKey) + deferredHostKeyById.set(id, { hostKey: beforeKey, revision: fromRevision }) } - for (const [id, beforeKey] of deferredHostKeyById) { + for (const [id, deferred] of deferredHostKeyById) { const after = nextById.get(id) if (replacedIds.includes(id) || !after?.hostKeyFingerprint) { if (!after) { @@ -87,9 +102,14 @@ export function peerReplacedEnvironmentIds( } deferredHostKeyById.delete(id) const before = previous.find((environment) => environment.id === id) - if (!sameRegistration(before, after) || (beforeKey && beforeKey !== after.hostKeyFingerprint)) { + if ( + !sameRegistration(before, after) || + (deferred.hostKey && deferred.hostKey !== after.hostKeyFingerprint) + ) { retired.push(id) + } else if (deferred.hostKey) { + recordSameHost(id, deferred.revision) } } - return retired + return { retired, sameHost } } diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 25f0aa9cb30..5d6fd0aa5e7 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -26,7 +26,7 @@ import { } from '@/runtime/restored-client-hosted-browser-host-attach' import { applyRuntimeHostStatusSnapshot } from './runtime-status-snapshot' import { - peerReplacedEnvironmentIds, + classifyPeerReplacements, replacedRuntimeEnvironmentIds } from './runtime-environment-peer-replacement' @@ -61,7 +61,14 @@ export const createRuntimeStatusSlice: StateCreator { const previousEnvironments = get().runtimeEnvironments const replacedEnvironmentIds = replacedRuntimeEnvironmentIds(previousEnvironments, environments) - replaceRuntimeEnvironmentRevisions(environments) + const peerReplacement = classifyPeerReplacements( + previousEnvironments, + environments, + replacedEnvironmentIds + ) + // Why classified first: transports may follow only a same-host rotation; a replaced peer's + // transports are fenced before its new revision is published. + replaceRuntimeEnvironmentRevisions(environments, peerReplacement) // Why: diff against the accumulated in-memory saved list (not a second disk // read) so a main-initiated removal that never calls setRuntimeEnvironments // still enters the diff on the next list read. #8881. @@ -140,12 +147,7 @@ export const createRuntimeStatusSlice: StateCreator 0) { evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(retiredEnvironmentIds) get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds) diff --git a/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts b/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts index 429e3e793d8..36bd54dff77 100644 --- a/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts +++ b/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts @@ -7,6 +7,7 @@ * so orphaned entries accumulated for the renderer's whole session. repoIds are * random UUIDs (never reused), so re-adding a repo cannot reclaim the old keys. */ +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { describe, it, expect, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import type { Repo } from '../../../../shared/repo-types' @@ -21,7 +22,7 @@ beforeEach(() => { reposRemove.mockReset().mockResolvedValue(undefined) vi.stubGlobal('window', { api: { - repos: { remove: reposRemove }, + repos: { removeForHost: reposRemove }, pty: { kill: vi.fn() }, runtimeEnvironments: { call: vi.fn() } } @@ -54,7 +55,7 @@ describe('removeProject purges the removed repo sparse-preset maps (leak regress const store = createTestStore() seed(store) - await store.getState().removeProject(repo1.id) + await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) const s = store.getState() // Removed repo: every sparse-preset map key is gone. diff --git a/src/renderer/src/store/slices/ssh-connection-state-equality.ts b/src/renderer/src/store/slices/ssh-connection-state-equality.ts index 6e2281547f1..9e43841cc16 100644 --- a/src/renderer/src/store/slices/ssh-connection-state-equality.ts +++ b/src/renderer/src/store/slices/ssh-connection-state-equality.ts @@ -13,6 +13,7 @@ export function sshConnectionStatesEqual( a?.connectionGeneration === b.connectionGeneration && a?.supportsFolderDownload === b.supportsFolderDownload && a?.remotePlatform === b.remotePlatform && + a?.hostNodeRuntime === b.hostNodeRuntime && // Why: a connect can end on the same status and epoch it set up under, changing only these. JSON.stringify(a?.managedServer ?? null) === JSON.stringify(b.managedServer ?? null) && JSON.stringify(a?.plainSsh ?? null) === JSON.stringify(b.plainSsh ?? null) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 1cdb873af10..c3c4c10fd7d 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -20,7 +20,7 @@ const mockApi = createStoreSessionMockApi() describe('removeProject cascade', () => { beforeEach(() => { vi.clearAllMocks() - mockApi.repos.remove.mockResolvedValue(undefined) + mockApi.repos.removeForHost.mockResolvedValue(undefined) mockApi.pty.kill.mockResolvedValue(undefined) }) @@ -58,7 +58,7 @@ describe('removeProject cascade', () => { activeTabId: 'tab1' }) - await store.getState().removeProject('repo1') + await store.getState().removeProject('repo1', { hostId: 'local' }) const s = store.getState() expect(s.repos).toEqual([]) diff --git a/src/renderer/src/store/slices/store-session-test-harness.ts b/src/renderer/src/store/slices/store-session-test-harness.ts index 0a1f08266b7..38fdd630c88 100644 --- a/src/renderer/src/store/slices/store-session-test-harness.ts +++ b/src/renderer/src/store/slices/store-session-test-harness.ts @@ -18,7 +18,13 @@ type UsageScannerMocks = { export type StoreSessionMockApi = { worktrees: { list: Mock; create: Mock; remove: Mock; updateMeta: Mock } - repos: { list: Mock; add: Mock; remove: Mock; update: Mock; pickFolder: Mock } + repos: { + list: Mock + add: Mock + removeForHost: Mock + update: Mock + pickFolder: Mock + } pty: { kill: Mock } gh: { prForBranch: Mock; issue: Mock } settings: { get: Mock; set: Mock } @@ -41,7 +47,7 @@ export function createStoreSessionMockApi(): StoreSessionMockApi { repos: { list: vi.fn().mockResolvedValue([]), add: vi.fn().mockResolvedValue({}), - remove: vi.fn().mockResolvedValue(undefined), + removeForHost: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue({}), pickFolder: vi.fn().mockResolvedValue(null) }, diff --git a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts index bac3dc738cd..4ddd81943bb 100644 --- a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts +++ b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = { worktrees: { list: vi.fn(), remove: vi.fn(), updateMeta: vi.fn().mockResolvedValue({}) }, - repos: { remove: vi.fn() }, + repos: { removeForHost: vi.fn() }, folderWorkspaces: { delete: vi.fn() }, pty: { kill: vi.fn() }, runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } @@ -174,7 +174,7 @@ describe('workspace chat drafts on removal', () => { beforeEach(() => { vi.clearAllMocks() mockApi.worktrees.remove.mockResolvedValue(undefined) - mockApi.repos.remove.mockResolvedValue(undefined) + mockApi.repos.removeForHost.mockResolvedValue(undefined) clearNativeChatComposerDraftsForTests() clearNativeChatPendingAttachmentsForTests() }) @@ -286,7 +286,7 @@ describe('workspace chat drafts on removal', () => { } }) - await store.getState().removeProject('repo1') + await store.getState().removeProject('repo1', { hostId: 'local' }) expect(draftTexts()).toEqual(['', '', 'other']) }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts index 428479f8623..023889faaf4 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts @@ -1,4 +1,8 @@ -import { toSshExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' +import { + getRepoExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' import type { AppState } from '../../../types' // Why: an SSH per-workspace-env project's host is the runtime-owned SSH target; once that runtime is destroyed, remove the project or it lingers as a dead, never-connectable one. @@ -30,14 +34,12 @@ export async function purgeOrphanedRuntimeSshProjects( } } // A repo whose only host was the destroyed runtime can outlive its setup (pruned first by a projection refresh); remove it directly so no dead project lingers. - const orphanedRepoIds = get() - .repos.filter( - (repo) => destroyedTargetIds.has(repo.connectionId ?? '') && !purgedRepoIds.has(repo.id) - ) - .map((repo) => repo.id) - for (const repoId of orphanedRepoIds) { + const orphanedRepos = get().repos.filter( + (repo) => destroyedTargetIds.has(repo.connectionId ?? '') && !purgedRepoIds.has(repo.id) + ) + for (const repo of orphanedRepos) { try { - await get().removeProject(repoId) + await get().removeProject(repo.id, { hostId: getRepoExecutionHostId(repo) }) } catch (error) { console.error('Failed to purge orphaned per-workspace-env repo:', error) } diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts index beea1ecf193..1f2ad6e2d13 100644 --- a/src/renderer/src/web/preload-api/web-repositories-api.ts +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -25,10 +25,6 @@ export function createReposApi(): NonNullable['repos']> { ) return withRuntimeRepoMutationOwner(owned.result, owned.hostId) }, - remove: async ({ repoId }) => { - await callRuntimeResult('repo.rm', { repo: repoId }) - invalidateRuntimeWorktreeCaches() - }, // Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state. removeForHost: () => { throw new Error('Forgetting a host is unavailable in paired web clients.') diff --git a/src/shared/agent-tui-ansi-fuzz-stream.ts b/src/shared/agent-tui-ansi-fuzz-stream.ts index 885dd9c0d50..8d45fc750dc 100644 --- a/src/shared/agent-tui-ansi-fuzz-stream.ts +++ b/src/shared/agent-tui-ansi-fuzz-stream.ts @@ -15,7 +15,7 @@ // (headless-emulator.ts onQueryReply gating); replies are a separate // authority problem with its own pinned tests (session.test.ts). -/** Same seeded PRNG as retained-tail-redraw-window.equivalence.test.ts. */ +/** Seeded PRNG shared by the terminal fuzz and equivalence suites. */ export function mulberry32(seed: number): () => number { let a = seed >>> 0 return () => { diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index b450a5a98c8..03fe4a1d2fc 100644 --- a/src/shared/cli-argument-boundary.ts +++ b/src/shared/cli-argument-boundary.ts @@ -16,6 +16,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([ 'dry-run', 'enter', 'focus', + 'grant-desktop-control', 'force', 'fresh', 'full', diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 33b3e963c91..b245232410d 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -107,6 +107,8 @@ export const ORCAD_SERVER_ENTRY_FILENAME = 'orcad-server.js' export const ORCAD_FOREIGN_SQLITE_READER_ENTRY = 'foreign-sqlite-reader-entry.js' /** Worker thread that runs workspace port detection's probe commands off the event loop. */ export const ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY = 'port-scan-command-worker-entry.js' +// Equals AI_VAULT_SERVICE_ENTRY_FILENAME: the forked child that lists agent sessions. +export const ORCAD_SESSION_SCANNER_SERVICE_ENTRY = 'session-scanner-service-entry.js' // Kept here because build-orcad.mjs imports this manifest directly under Node type stripping. export const ORCAD_RIPGREP_ARTIFACTS = [ @@ -170,6 +172,8 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ // Worker thread that reads other apps' SQLite (the OpenCode binder and history) off the event loop. { filename: ORCAD_FOREIGN_SQLITE_READER_ENTRY }, { filename: ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY }, + // Forked so transcript parsing stays off the server's event loop; session search stays in-process. + { filename: ORCAD_SESSION_SCANNER_SERVICE_ENTRY }, // Target-specific even when the JavaScript bundle is shared across packaged slots. { filename: ORCAD_SERVER_TARGET_FILENAME }, // orcad never depends on a host runtime or host-installed native module. diff --git a/src/shared/serve-option-validation.ts b/src/shared/serve-option-validation.ts index 36f844f322f..12cbf823d81 100644 --- a/src/shared/serve-option-validation.ts +++ b/src/shared/serve-option-validation.ts @@ -3,6 +3,7 @@ import { levenshtein } from './edit-distance' export type ServeOptionValidationInput = { noPairing: boolean mobilePairing: boolean + grantDesktopControl?: boolean recipeJson: boolean projectRoot: string | null | undefined } @@ -11,6 +12,9 @@ export function getServeOptionValidationError(options: ServeOptionValidationInpu if (options.noPairing && options.mobilePairing) { return 'Use either --mobile-pairing or --no-pairing, not both.' } + if (options.grantDesktopControl && (options.noPairing || options.mobilePairing)) { + return '--grant-desktop-control applies only to the default runtime pairing offer.' + } if (options.recipeJson && options.noPairing) { return 'Recipe JSON output requires runtime pairing; remove --no-pairing.' } @@ -28,6 +32,8 @@ const SERVE_SECURITY_FLAG_NAMES = [ '--serve-no-pairing', '--mobile-pairing', '--serve-mobile-pairing', + '--grant-desktop-control', + '--serve-grant-desktop-control', '--recipe-json', '--serve-recipe-json', '--pairing-address', diff --git a/src/shared/serve-user-data-path.ts b/src/shared/serve-user-data-path.ts new file mode 100644 index 00000000000..8e418ec8bcb --- /dev/null +++ b/src/shared/serve-user-data-path.ts @@ -0,0 +1,27 @@ +import { realpathSync } from 'node:fs' +import { resolve } from 'node:path' + +/** + * The profile the `orca serve` CLI resolved for this launch. The Electron child refuses to serve + * on any other profile, so it can never take (or message the owner of) another profile's lock. + */ +export const SERVE_USER_DATA_PATH_ENV = 'ORCA_SERVE_USER_DATA_PATH' + +function canonicalUserDataPath(path: string, platform: NodeJS.Platform): string { + let canonical = resolve(path) + try { + // Why: /tmp vs /private/tmp style aliases must not read as two profiles. + canonical = realpathSync.native(canonical) + } catch { + // Not created yet; the resolved spelling is all there is to compare. + } + return platform === 'win32' ? canonical.toLowerCase() : canonical +} + +export function isSameUserDataPath( + left: string, + right: string, + platform: NodeJS.Platform = process.platform +): boolean { + return canonicalUserDataPath(left, platform) === canonicalUserDataPath(right, platform) +} diff --git a/src/shared/ssh-bridge-credential-env.ts b/src/shared/ssh-bridge-credential-env.ts new file mode 100644 index 00000000000..72985d339cd --- /dev/null +++ b/src/shared/ssh-bridge-credential-env.ts @@ -0,0 +1,12 @@ +/** + * Env var carrying the per-invocation credential a bridged SSH CLI presents instead of the owner token. + * + * Its presence also puts the CLI in bridge mode: no local-only commands and no paired-server routing, + * because those act with this machine's own authority rather than through the scoped runtime socket. + */ +export const ORCA_SSH_BRIDGE_CREDENTIAL_ENV = 'ORCA_SSH_BRIDGE_CREDENTIAL' + +export function readSshBridgeCredential(env: NodeJS.ProcessEnv = process.env): string | null { + const value = env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] + return typeof value === 'string' && value.length > 0 ? value : null +} diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts index f7616cbdad6..d55aa3b36c3 100644 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -94,6 +94,15 @@ describe('SSH retained payload admission', () => { expect(malformed).not.toHaveProperty('plainSsh') }) + it('admits only a literal Host Node runtime flag', () => { + const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 } + + const flagged = admitSshConnectionState({ ...state, hostNodeRuntime: true }, 'ssh-a') + expect(flagged?.hostNodeRuntime).toBe(true) + const malformed = admitSshConnectionState({ ...state, hostNodeRuntime: 'yes' }, 'ssh-a') + expect(malformed).not.toHaveProperty('hostNodeRuntime') + }) + it('admits only a literal move offer on a relay server status', () => { const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 } const relay = { kind: 'relay', reason: 'relay_terminals_live', terminals: 2 } diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts index debebc3082f..5bbea400393 100644 --- a/src/shared/ssh-retained-payload-admission.ts +++ b/src/shared/ssh-retained-payload-admission.ts @@ -103,6 +103,7 @@ export function admitSshConnectionState( ? { remotePlatform: input.remotePlatform } : {}), ...admitSshPlainSshMode(input.plainSsh), + ...(input.hostNodeRuntime === true ? { hostNodeRuntime: true } : {}), ...admitSshManagedServerStatus(input.managedServer) } } @@ -226,6 +227,7 @@ export function admitSshConnectionStateForAuthorityReconciliation( supportsFolderDownload: input.supportsFolderDownload, remotePlatform: input.remotePlatform, plainSsh: input.plainSsh, + hostNodeRuntime: input.hostNodeRuntime, managedServer: input.managedServer }, expectedTargetId diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 1b11c472e72..290083307d2 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -11,14 +11,14 @@ export const DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 0 export const SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD = 'relay.configureGraceTime' /** - * Which runtime executes the SSH relay. `legacy` runs it on the host's Node with native deps - * installed on the host; `pinned-node` uploads Orca's pinned Node and prebuilt addons (design D5). + * Which runtime executes the SSH relay. `pinned-node` runs the D6 ladder on Orca's uploaded Node and + * prebuilt addons (design D5); `legacy` is the opt-in host Node with native deps installed by npm. */ export const SSH_REMOTE_RUNTIMES = ['legacy', 'pinned-node'] as const export type SshRemoteRuntime = (typeof SSH_REMOTE_RUNTIMES)[number] -export const DEFAULT_SSH_REMOTE_RUNTIME: SshRemoteRuntime = 'legacy' +export const DEFAULT_SSH_REMOTE_RUNTIME: SshRemoteRuntime = 'pinned-node' -/** Where the design D6 fallback ladder landed; `legacy` is the host-npm path outside it. */ +/** Where the design D6 fallback ladder landed; `legacy` is the opt-in host-npm path outside it. */ export const SSH_REMOTE_RUNTIME_RUNGS = ['A', 'B', 'C', 'D', 'legacy'] as const export type SshRemoteRuntimeRung = (typeof SSH_REMOTE_RUNTIME_RUNGS)[number] @@ -30,6 +30,8 @@ export type SshRemoteRuntimeResolution = { rung: SshRemoteRuntimeRung /** The classified refusal that stepped off Orca's pinned Node, when one did. */ pinnedRefusal?: string + /** Epoch ms the refusal was proved; a refusal the host can lose expires from it. */ + refusedAt?: number glibc: string | null runtimeSha256: string orcaMajor: number @@ -70,6 +72,9 @@ export type SshTarget = { /** Grace period in seconds before relay shuts down after disconnect. * 0 disables expiry. Default: 0 (until reset). Max: 604800 (7 days). */ relayGracePeriodSeconds?: number + /** Lets this host's relayed `orca` CLI control this Orca beyond its own terminals (other hosts, + * orchestration, files). Off by default: the host is a guest, not an owner. */ + allowRemoteCliControl?: boolean /** Set to true after a successful connection that triggered a credential * prompt (passphrase or password). Persisted so startup reconnect can * partition targets into eager (no passphrase) vs deferred (passphrase) @@ -256,6 +261,8 @@ export type SshConnectionState = { remotePlatform?: SshRemotePlatform /** Set while connected without the Orca remote server (runtime ladder rung D). */ plainSsh?: SshPlainSshMode + /** Set while the relay runs on the opt-in host-Node runtime, an unsupported configuration. */ + hostNodeRuntime?: boolean /** Which server this host runs; optional so older clients simply ignore it. */ managedServer?: SshManagedServerStatus } diff --git a/src/shared/telemetry-ssh-runtime-event-schemas.ts b/src/shared/telemetry-ssh-runtime-event-schemas.ts index 7c334cbe6ac..936e7717102 100644 --- a/src/shared/telemetry-ssh-runtime-event-schemas.ts +++ b/src/shared/telemetry-ssh-runtime-event-schemas.ts @@ -68,7 +68,8 @@ export const SSH_RUNTIME_REFUSAL_VALUES = [ 'target_unresolved', 'artifacts_unavailable', 'runtime_unavailable', - 'host_node_missing' + 'host_node_missing', + 'install_failed' ] as const export const SSH_RUNTIME_DURATION_BUCKETS = ['lt_5s', '5s_15s', '15s_60s', 'gte_60s'] as const diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index d8c80f32494..ac6efd5c9ad 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -143,11 +143,16 @@ const REPLAY_BASELINE_BUFFER_RESET = '\x1b[r' export function buildSnapshotReplayPrologue(args: { targetAlternateScreen: boolean paneOnAlternateScreen: boolean + /** The payload carries no history, so the pane's own must survive. */ + keepScrollback?: boolean }): string { // Why explicit: `?1049h` does not clear the alt buffer (xterm's own // `1049 should clear altbuffer` FIXME); `\x1b[3J` is safe only for a - // normal-buffer payload, which carries its own history. - const clear = args.targetAlternateScreen ? '\x1b[2J\x1b[H' : '\x1b[2J\x1b[3J\x1b[H' + // normal-buffer payload that carries its own history. + const clear = + args.targetAlternateScreen || args.keepScrollback === true + ? '\x1b[2J\x1b[H' + : '\x1b[2J\x1b[3J\x1b[H' const ground = `${REPLAY_BASELINE_TERMINAL_RESET}${REPLAY_BASELINE_BUFFER_RESET}` if (args.paneOnAlternateScreen === args.targetAlternateScreen) { return `${ground}${clear}${SAVE_GROUNDED_CURSOR}` diff --git a/src/shared/terminal-partial-escape-tail-ground-scan.test.ts b/src/shared/terminal-partial-escape-tail-ground-scan.test.ts index 7dfa7e39873..b30642d69d4 100644 --- a/src/shared/terminal-partial-escape-tail-ground-scan.test.ts +++ b/src/shared/terminal-partial-escape-tail-ground-scan.test.ts @@ -30,15 +30,18 @@ describe('partial escape scanning between sequences', () => { ['\x1bPqpayload', '\x1b\\'], ['\x1bPqpayload\x1b', '\\', '\x1b'], ['\x1b(', 'B'] - ])('preserves %j through every split after ordinary text', (pending, completion, expected = pending) => { - const prefix = `\x1b[32m${'build output '.repeat(128)}\x1b[0m` - for (let split = 0; split <= pending.length; split += 1) { - const first = advancePartialEscapeTail('', prefix + pending.slice(0, split)) - const continued = advancePartialEscapeTail(first, pending.slice(split)) - expect(continued).toBe(expected) - expect(advancePartialEscapeTail(continued, `${completion}ready`)).toBe('') + ])( + 'preserves %j through every split after ordinary text', + (pending, completion, expected = pending) => { + const prefix = `\x1b[32m${'build output '.repeat(128)}\x1b[0m` + for (let split = 0; split <= pending.length; split += 1) { + const first = advancePartialEscapeTail('', prefix + pending.slice(0, split)) + const continued = advancePartialEscapeTail(first, pending.slice(split)) + expect(continued).toBe(expected) + expect(advancePartialEscapeTail(continued, `${completion}ready`)).toBe('') + } } - }) + ) it('handles aborts before returning to ordinary text', () => { const text = 'ordinary text '.repeat(128) diff --git a/src/shared/unix-socket-path-limit.ts b/src/shared/unix-socket-path-limit.ts index 1d29b8daf9c..3b4fc20fad7 100644 --- a/src/shared/unix-socket-path-limit.ts +++ b/src/shared/unix-socket-path-limit.ts @@ -9,3 +9,7 @@ const SUN_PATH_SIZE: Record<'linux' | 'darwin', number> = { linux: 108, darwin: export function unixSocketPathByteLimit(os: 'linux' | 'darwin'): number { return SUN_PATH_SIZE[os] - 1 } + +export function unixSocketPathFits(socketPath: string, os: 'linux' | 'darwin'): boolean { + return new TextEncoder().encode(socketPath).length <= unixSocketPathByteLimit(os) +} diff --git a/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts b/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts new file mode 100644 index 00000000000..e868149db8b --- /dev/null +++ b/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts @@ -0,0 +1,118 @@ +/** + * Paired remote server: a desktop client that opens a remote terminal after it has printed more + * than a screen must be able to scroll back through that history (#20158). + * + * The host used to serialize desktop subscribe snapshots screen-only, so the client pane held the + * last screenful and nothing above it. Orcad-backed SSH panes use the same subscribe path. + * + * Run: + * pnpm exec playwright test \ + * tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient +} from './helpers/paired-electron-client' +import { + callEnvironment, + createPairedHostTerminal, + openPairedClientTab, + readPairedPaneContent, + waitForPairedPaneMarker +} from './helpers/paired-host-terminal' + +const LINE_COUNT = 300 +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-initial-scrollback-')) +const fixturePath = path.join(scratch, 'history-terminal.mjs') +writeFileSync( + fixturePath, + [ + `for (let i = 1; i <= ${LINE_COUNT}; i += 1) {`, + " process.stdout.write(`HIST-${String(i).padStart(4, '0')}\\r\\n`)", + '}', + "process.stdout.write('HISTORY_DONE\\r\\n')", + 'setInterval(() => {}, 1 << 30)' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function fixtureCommand(): string { + const command = [process.execPath, fixturePath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map((value) => `'${value.replaceAll("'", `'\\''`)}'`).join(' ') +} + +test('a desktop client opening a remote terminal receives its scrollback, not just the screen', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(300_000) + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('host has no active worktree') + } + return id + }) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedElectronClient(offer, testInfo, 'initial-scrollback') + let terminal: string | null = null + try { + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'paired client never saw the host worktree' } + ) + .toBe(true) + const target = await createPairedHostTerminal( + client.page, + client.environmentId, + worktreeId, + fixtureCommand() + ) + terminal = target.terminal + // Let the host print everything before the client subscribes, so only the snapshot can carry it. + await expect + .poll( + async () => { + const read = await callEnvironment(client.page, client.environmentId, 'terminal.read', { + terminal: target.terminal + }) + return JSON.stringify(read).includes('HISTORY_DONE') + }, + { timeout: 60_000, message: 'host terminal never finished printing' } + ) + .toBe(true) + await openPairedClientTab(client.page, worktreeId, target.webTabId) + expect( + await waitForPairedPaneMarker(client.page, target.webTabId, 'HISTORY_DONE', 30_000) + ).toBe(true) + const content = await readPairedPaneContent(client.page, target.webTabId) + const firstLine = content.split(/\r?\n/).find((line) => line.startsWith('HIST-')) + console.log(`[initial-scrollback] first history line on client: ${firstLine ?? '(none)'}`) + expect(content).toContain('HIST-0001') + } finally { + if (terminal) { + await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', { + terminal + }).catch(() => undefined) + } + await client.dispose() + } +}) diff --git a/tests/e2e/windows-project-runtime-smoke.spec.ts b/tests/e2e/windows-project-runtime-smoke.spec.ts index 4e4cfcbc3dc..08703875258 100644 --- a/tests/e2e/windows-project-runtime-smoke.spec.ts +++ b/tests/e2e/windows-project-runtime-smoke.spec.ts @@ -178,7 +178,8 @@ test.describe('Windows project runtime smoke', () => { ).toBeVisible() await orcaPage.evaluate(async (repoId) => { - await window.api.repos.remove({ repoId }) + const repo = window.__store!.getState().repos.find((entry) => entry.id === repoId) + await window.api.repos.removeForHost({ repoId, hostId: repo?.executionHostId ?? 'local' }) }, smoke.wslRepoId) }) })