mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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 <m4air@Mac.localdomain> * 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 <m4air@Mac.localdomain> * 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 <m4air@Mac.localdomain> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@Mac.localdomain> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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 <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> * 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 <m4air@Mac.localdomain> * 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:<id> and worker questions under run:<id>, 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> * 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 '<tool> --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 <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@m4airs-MacBook-Air.local> Co-authored-by: m4air <m4air@Mac.localdomain> * 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 <m4air@m4airs-MacBook-Air.local> * 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 <m4air@Mac.localdomain> --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
m4air
m4air
Claude Opus 5.5
parent
f5ece0f912
commit
e3639ef8d6
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 })')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Generated
+4
-3
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -102,10 +102,12 @@ export const CORE_HANDLERS: Record<string, CommandHandler> = {
|
||||
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<string, CommandHandler> = {
|
||||
pairingAddress: typeof pairingAddressValue === 'string' ? pairingAddressValue : null,
|
||||
noPairing,
|
||||
mobilePairing,
|
||||
grantDesktopControl,
|
||||
recipeJson,
|
||||
projectRoot
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
+10
-4
@@ -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:<name> 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<void> {
|
||||
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)
|
||||
|
||||
@@ -36,7 +36,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
'',
|
||||
'Common Commands:',
|
||||
' orca open [--json]',
|
||||
' orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]',
|
||||
' orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--grant-desktop-control] [--project-root <path>] [--recipe-json] [--json]',
|
||||
' orca status [--json]',
|
||||
' orca diagnostics memory [--json]',
|
||||
' orca agent-context [--json]',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 (`<appData>/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 } }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+59
-22
@@ -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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
const childArgs = [...getExecutableAppArgs(executable)]
|
||||
function serveWithElectron(
|
||||
executable: string,
|
||||
args: ServeOrcaAppArgs,
|
||||
userDataPath: string
|
||||
): Promise<number> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
: [])
|
||||
|
||||
@@ -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<string> {
|
||||
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<void>((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<void>((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')
|
||||
})
|
||||
})
|
||||
@@ -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<TResult>(
|
||||
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,
|
||||
|
||||
@@ -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 <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]',
|
||||
'orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--grant-desktop-control] [--project-root <path>] [--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: [
|
||||
|
||||
@@ -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:<id> 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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>,
|
||||
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:<id> would route through the Orca host's own paired servers and cannot be used from an SSH host."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 <user>` 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 <user>`, 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<ProcessResult, 'code' | 'timedOut'> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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'
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<Socket> {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ForkSpec, 'cwd' | 'detached' | 'stdio'> = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(getDaemonSocketBindPath(socketPath), resolve)
|
||||
})
|
||||
await new Promise<void>((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<void>((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<void>((resolve) => fake.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Keeps the daemon's Unix socket inside `sockaddr_un.sun_path`.
|
||||
*
|
||||
* The endpoint normally lives at `<userData>/daemon/daemon-v<N>.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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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@<uid>.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<ProcessResult, 'code' | 'timedOut' | 'stdout'>
|
||||
|
||||
function runLoginctlLingerProbe(
|
||||
uid: number,
|
||||
timeoutMs: number
|
||||
): Pick<ProcessResult, 'code' | 'timedOut' | 'stdout'> {
|
||||
return runProcessSync({
|
||||
program: 'loginctl',
|
||||
args: ['show-user', String(uid), '-p', 'Linger', '--value'],
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeoutMs
|
||||
})
|
||||
}
|
||||
|
||||
/** The path field of one `/proc/<pid>/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
|
||||
}
|
||||
@@ -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<s
|
||||
removeInheritedDevAgentHookEndpoint(env, opts.env)
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
removeAppImageRuntimeEnv(env)
|
||||
removeChromiumDisabledSessionBus(env)
|
||||
removeInheritedNoColor(env)
|
||||
// Why last: the aliases mirror pane identity AFTER every strip above has settled, so an
|
||||
// alias can never outlive the value it mirrors.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getAppEnvironment, hasAppEnvironment } from '../shared/app-environment'
|
||||
|
||||
/**
|
||||
* Where a forked child's built entry lives for one app root.
|
||||
*
|
||||
* Must not contain the literal text require('electron'): plain-Node fork entries
|
||||
* reach this module, and the build's plain-node-entry-guard rejects that text.
|
||||
*/
|
||||
export function resolveForkedChildEntryPath(
|
||||
entryFileName: string,
|
||||
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, 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)
|
||||
}
|
||||
+2
-1
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,7 +26,6 @@ const {
|
||||
mockStore: {
|
||||
getRepos: vi.fn().mockReturnValue([]),
|
||||
addRepo: vi.fn(),
|
||||
removeProject: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
updateRepo: vi.fn()
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof vi.fn>): 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')
|
||||
})
|
||||
})
|
||||
@@ -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<typeof vi.fn>): SshConnectionState[] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<PtySourceDeliveryIdentity, 'providerGeneration' | 'deliveryToken'>
|
||||
): boolean {
|
||||
return this.openedTokens.has(ptySourceDeliveryKey(span))
|
||||
}
|
||||
|
||||
private removeIdentity(identity: PtySourceDeliveryIdentity): void {
|
||||
this.openedTokens.delete(ptySourceDeliveryKey(identity))
|
||||
for (const [key, record] of this.identityByPty) {
|
||||
|
||||
@@ -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<void>((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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
|
||||
/**
|
||||
* The app menu's Quit entry: "Quit <macAppName>" 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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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, {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>): 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> = {}): 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)
|
||||
|
||||
@@ -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<string>
|
||||
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,
|
||||
|
||||
@@ -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<string>(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([])
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string, unknown>): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('Store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
store.removeProject('r1')
|
||||
store.removeProjectForHost('r1', 'local')
|
||||
store.flush()
|
||||
|
||||
expect(store.getMobileClientTabSelections()['device-a']).toEqual({
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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+$/)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -55,7 +55,8 @@ const LOST_NOTICE =
|
||||
|
||||
export class SshPlainShellPtyProvider implements IPtyProvider {
|
||||
private readonly shells = new Map<string, PlainShell>()
|
||||
private readonly lost = new Set<string>()
|
||||
// Why only confirmed exits answer absent: an id this provider never minted may still run on an earlier relay.
|
||||
private readonly exited = new Set<string>()
|
||||
private readonly dataListeners = new Set<SshPtyDataCallback>()
|
||||
private readonly exitListeners = new Set<SshPtyExitCallback>()
|
||||
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 {
|
||||
|
||||
@@ -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<string, string> = { 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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, string | undefined>): void {
|
||||
if (env.DBUS_SESSION_BUS_ADDRESS === CHROMIUM_DISABLED_SESSION_BUS) {
|
||||
delete env.DBUS_SESSION_BUS_ADDRESS
|
||||
}
|
||||
}
|
||||
@@ -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<string> = 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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -86,11 +86,13 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution
|
||||
}
|
||||
: null
|
||||
let ptyTailAfter: ReturnType<typeof appendNormalizedToTailBuffer> | null = null
|
||||
let ptyNormalized: ReturnType<typeof normalizeTerminalChunk> | 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user