From b368ebc7a6c0e53fb766b4fd0c65656879e8a8a9 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 12 Oct 2026 03:23:17 -0700 Subject: [PATCH] refactor(ssh): delete relay-only GC, takeover and install remnants (#27770) Co-authored-by: m4air --- config/reliability-gates.jsonc | 21 +- .../macos-login-session-pty-probe.ts | 14 +- .../providers/sftp-directory-test-fixture.ts | 24 - .../providers/ssh-filesystem-provider-sftp.ts | 101 +---- .../providers/ssh-sftp-directory-close.ts | 42 -- .../windows-agent-foreground-process.ts | 10 - .../orcad-activation-crash-recovery.test.ts | 4 +- .../ssh/orcad-managed-auto-update.test.ts | 2 +- src/main/ssh/orcad-managed-auto-update.ts | 2 +- src/main/ssh/orcad-managed-runtime-context.ts | 2 +- src/main/ssh/orcad-recovery-slot.ts | 2 +- src/main/ssh/orcad-remote-deploy.test.ts | 4 +- src/main/ssh/orcad-remote-deploy.ts | 2 +- src/main/ssh/orcad-remote-gc.test.ts | 4 +- src/main/ssh/orcad-remote-gc.ts | 2 +- src/main/ssh/orcad-remote-install.ts | 2 +- .../orcad-remote-lifecycle-windows.test.ts | 2 +- .../orcad-remote-windows-decommission.test.ts | 4 +- .../ssh/orcad-state-mutation-timeout.test.ts | 4 +- ...ocket-path-limit-shell.integration.test.ts | 103 ----- src/main/ssh/relay-socket-path-limit.test.ts | 125 ------ src/main/ssh/relay-socket-path-limit.ts | 120 +---- .../ssh/relay-version-dir-liveness.test.ts | 199 --------- src/main/ssh/relay-version-dir-liveness.ts | 65 --- .../ssh/remote-install-gc-termination.test.ts | 119 +---- ...tone.ts => remote-install-gc-tombstone.ts} | 11 +- src/main/ssh/remote-install-gc.ts | 157 +------ src/main/ssh/remote-install-model.ts | 6 + .../remote-install-previous-version.test.ts | 160 ------- .../ssh/remote-install-previous-version.ts | 106 ----- ...st.ts => remote-versioned-install.test.ts} | 257 +++++------ ...install.ts => remote-versioned-install.ts} | 78 ++-- src/main/ssh/sftp-stream-late-error.test.ts | 42 +- src/main/ssh/sftp-upload.ts | 29 +- src/main/ssh/ssh-host-key-decision.test.ts | 42 +- src/main/ssh/ssh-host-key-decision.ts | 24 - .../ssh-host-relay-endpoint-census.test.ts | 53 ++- .../ssh/ssh-host-relay-endpoint-census.ts | 32 +- src/main/ssh/ssh-known-hosts-source.test.ts | 7 +- src/main/ssh/ssh-known-hosts-source.ts | 8 - .../ssh-relay-credential-mismatch-error.ts | 4 - ...dpoint-incumbent-shell.integration.test.ts | 70 +-- .../ssh/ssh-relay-endpoint-incumbent.test.ts | 50 +-- src/main/ssh/ssh-relay-endpoint-incumbent.ts | 93 +--- .../ssh/ssh-relay-endpoint-takeover.test.ts | 220 ---------- src/main/ssh/ssh-relay-endpoint-takeover.ts | 147 ------- src/main/ssh/ssh-relay-endpoints.ts | 46 +- .../ssh/ssh-relay-incumbent-process.test.ts | 2 - src/main/ssh/ssh-relay-install-marker.ts | 7 - .../ssh/ssh-relay-install-namespace.test.ts | 186 -------- src/main/ssh/ssh-relay-install-namespace.ts | 193 -------- .../ssh-relay-native-deps-cache-commands.ts | 232 ---------- ...sh-relay-native-deps-cache-gc-real.test.ts | 415 ------------------ .../ssh/ssh-relay-native-deps-cache-gc.ts | 355 --------------- .../ssh-relay-native-deps-cache-shell.test.ts | 243 ---------- .../ssh/ssh-relay-native-deps-cache.test.ts | 277 ------------ src/main/ssh/ssh-relay-native-deps-cache.ts | 152 ------- .../ssh-relay-native-deps-install-fixture.ts | 209 --------- .../ssh-relay-superseded-endpoints.test.ts | 299 ------------- .../ssh/ssh-relay-superseded-endpoints.ts | 236 ---------- .../ssh/ssh-relay-version-mismatch-error.ts | 4 - ...elay-versioned-install-termination.test.ts | 2 +- .../ssh-relay-windows-launch-command.test.ts | 120 ----- .../ssh/ssh-relay-windows-launch-command.ts | 129 +----- src/main/ssh/ssh-remote-commands.test.ts | 151 +++---- src/main/ssh/ssh-remote-commands.ts | 159 +------ .../ssh-remote-node-toolchain-probe.test.ts | 28 +- .../ssh/ssh-remote-node-toolchain-probe.ts | 51 +-- .../process-host/src/posix-lsof-probe.test.ts | 4 +- .../ssh-managed-server-state-effects.test.ts | 3 +- src/shared/relay-artifacts.ts | 3 - .../ssh-retained-payload-admission.test.ts | 10 +- src/shared/ssh-retained-payload-admission.ts | 1 - src/shared/ssh-types.ts | 5 - src/shared/unix-socket-path-limit.ts | 2 +- src/shared/windows-breakaway-launch.ts | 5 - src/shared/windows-breakaway-launcher.test.ts | 10 +- 77 files changed, 398 insertions(+), 5716 deletions(-) delete mode 100644 src/main/providers/sftp-directory-test-fixture.ts delete mode 100644 src/main/providers/ssh-sftp-directory-close.ts delete mode 100644 src/main/ssh/relay-socket-path-limit-shell.integration.test.ts delete mode 100644 src/main/ssh/relay-socket-path-limit.test.ts delete mode 100644 src/main/ssh/relay-version-dir-liveness.test.ts delete mode 100644 src/main/ssh/relay-version-dir-liveness.ts rename src/main/ssh/{ssh-relay-gc-tombstone.ts => remote-install-gc-tombstone.ts} (71%) delete mode 100644 src/main/ssh/remote-install-previous-version.test.ts delete mode 100644 src/main/ssh/remote-install-previous-version.ts rename src/main/ssh/{ssh-relay-versioned-install.test.ts => remote-versioned-install.test.ts} (74%) rename src/main/ssh/{ssh-relay-versioned-install.ts => remote-versioned-install.ts} (66%) delete mode 100644 src/main/ssh/ssh-relay-endpoint-takeover.test.ts delete mode 100644 src/main/ssh/ssh-relay-endpoint-takeover.ts delete mode 100644 src/main/ssh/ssh-relay-install-namespace.test.ts delete mode 100644 src/main/ssh/ssh-relay-install-namespace.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-cache-commands.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-cache-gc.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-cache.test.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-cache.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-install-fixture.ts delete mode 100644 src/main/ssh/ssh-relay-superseded-endpoints.test.ts delete mode 100644 src/main/ssh/ssh-relay-superseded-endpoints.ts delete mode 100644 src/main/ssh/ssh-relay-windows-launch-command.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3b735730964..eb7f10aa66a 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3595,12 +3595,11 @@ "invariant": "A first-install relay transfer must complete in an attempt-owned fixed staging slot before acquiring the shared version install lock. Reservation, promotion, confirmed cleanup, and stale recovery must reject path replacement, persisted-identity mismatch, POSIX symlinks, and Windows reparse points. A newly acquired install lock atomically records the execution host's boot identity; only a verified identity change or the existing stale-age proof may replace it, while legacy, missing, malformed, and unreadable identity state must retain the conservative stale fallback. Recovery examines only eight fixed slot/claim/delete names and removes at most one stale valid stage per call; eight unclaimable states fail with an explicit manual-recovery message. Split-SFTP hosts must prove the stage identity on the exact transfer session, only payload contents may be promoted under the shared lock, and cancellation must boundedly join SFTP, stream, local file-handle, and transfer settlement.", "oracle": "Pause a real ssh2 SFTP relay.js write after one remotely acknowledged chunk, prove the remote file is partial, abort the live transfer, and require no shared .install-lock, leaked local descriptor, or foreign-process termination. Separately inject two unconfirmed cancellations, require an independent deployment to install, launch, answer relay RPC, and read a real repository HEAD. Keep a fresh install lock on the current POSIX or Windows boot and require takeover to fail; replace its bounded identity with a prior-boot value and race concurrent recoverers, requiring exactly one atomic winner, a current successor identity, and no tombstone residue; omit the marker and require the legacy lock to remain fenced. Replace one retained fixed slot with an old-mtime same-owner directory while preserving the original, add a fixed-slot POSIX symlink, and require installed-path recovery to skip both while reclaiming a valid stale slot behind them. Add 15,197 unrelated relay-shaped names and run the real version GC, requiring bounded stdout and no removal. Unit and wire contracts cover exact POSIX and native PowerShell 0/1/7/8/9+ quota behavior, no-follow identity fencing, payload symlink/reparse rejection, one-item repeated draining, zero lock acquisition before upload settlement, joined transfer/channel teardown including never-settling failures, SFTP redirection, package.json namespace ownership, promotion only after the lock, cross-version isolation, and system-SSH behavior.", "commands": [ - "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts" + "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts" ], "testFiles": [ "src/main/ssh/sftp-upload.test.ts", "src/main/ssh/ssh-file-transfer-abort.test.ts", - "src/main/ssh/ssh-relay-install-namespace.test.ts", "src/main/ssh/sftp-namespace-resolution.test.ts", "src/main/ssh/ssh-connection-sftp-wire.test.ts", "src/main/ssh/ssh-remote-commands.test.ts" @@ -3632,7 +3631,7 @@ "date": "2026-07-31", "runner": "local", "platform": "macos", - "command": "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts", + "command": "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts", "result": "passed", "durationSeconds": 57.9, "summary": "Eleven focused files passed 152 tests with 3 platform skips using exact POSIX sh, native ARM macOS PowerShell 7.6.4, real ssh2 split-SFTP wire sessions, fixed-slot identity races, bounded recovery, joined cancellation teardown, and cross-version isolation." @@ -17829,8 +17828,8 @@ "node config/scripts/runtime-file-watcher-resource-probe.mjs", "node config/scripts/runtime-file-watcher-fault-harness.mjs", "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/remote-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/remote-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs" ], "testFiles": [ "src/main/daemon/daemon-pty-adapter.test.ts", @@ -17876,7 +17875,7 @@ "src/shared/promise-settlement-waiters.test.ts", "config/scripts/runtime-file-watcher-fault-harness.mjs", "config/scripts/runtime-file-watcher-resource-probe.mjs", - "src/main/ssh/ssh-relay-versioned-install.test.ts", + "src/main/ssh/remote-versioned-install.test.ts", "config/scripts/package-electron-runtime-contract.test.mjs" ], "assertionRefs": [ @@ -18198,9 +18197,9 @@ ] }, { - "file": "src/main/ssh/ssh-relay-versioned-install.test.ts", + "file": "src/main/ssh/remote-versioned-install.test.ts", "assertions": [ - "remote completeness requires relay.js, relay-watcher.js, and the install sentinel" + "remote completeness requires every model artifact and the install sentinel" ] }, { @@ -18275,7 +18274,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/remote-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 10.01, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests." @@ -18329,7 +18328,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/remote-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 10.69, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests, including watcher/PTY removal fencing and recursive deletion failure behavior." @@ -18383,7 +18382,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/remote-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 9.79, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 225 tests, including terminal removal notification and recursive deletion fencing." diff --git a/src/main/providers/macos-login-session-pty-probe.ts b/src/main/providers/macos-login-session-pty-probe.ts index ffcc2098482..827c856a16d 100644 --- a/src/main/providers/macos-login-session-pty-probe.ts +++ b/src/main/providers/macos-login-session-pty-probe.ts @@ -1,4 +1,4 @@ -import { execFile, type ExecFileException } from 'node:child_process' +import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' const MACOS_EXPECT_PATH = '/usr/bin/expect' @@ -16,18 +16,6 @@ export type LoginPreflightOutcome = { reason: 'accepted' | 'rejected' | 'timeout' | 'error' } -export function classifyLoginPreflightError(error: ExecFileException): LoginPreflightOutcome { - // Why: a probe killed by our bound proves nothing about PAM and must not stick. - if (error.killed || error.code === 'ETIMEDOUT') { - return { ok: false, conclusive: false, reason: 'timeout' } - } - // Why: a natural nonzero exit is login(1)'s conclusive rejection verdict. - if (typeof error.code === 'number') { - return { ok: false, conclusive: true, reason: 'rejected' } - } - return { ok: false, conclusive: false, reason: 'error' } -} - /** Runs the login-session oracle under a real PTY when the pipe probe cannot decide. */ export function runMacosLoginSessionPtyProbe( username: string, diff --git a/src/main/providers/sftp-directory-test-fixture.ts b/src/main/providers/sftp-directory-test-fixture.ts deleted file mode 100644 index 9a62f083e4e..00000000000 --- a/src/main/providers/sftp-directory-test-fixture.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import { vi } from 'vitest' - -type ListingCallback = (error?: Error | null, result?: unknown) => void - -export function withSftpDirectoryHandles< - T extends { readdir: (path: string, callback: ListingCallback) => void } ->(sftp: T) { - const read = sftp.readdir - const exhausted = new WeakSet() - Object.assign(sftp, { - opendir: vi.fn((path: string, callback: ListingCallback) => callback(null, Buffer.from(path))), - close: vi.fn((_handle: Buffer, callback: ListingCallback) => callback()), - readdir: vi.fn((handle: Buffer, callback: ListingCallback) => { - if (exhausted.has(handle)) { - return callback(null, false) - } - exhausted.add(handle) - read(handle.toString(), callback) - }) - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Fixtures provide the file operations under test; the adapter adds opendir, handle readdir, and close. - return sftp as unknown as SFTPWrapper -} diff --git a/src/main/providers/ssh-filesystem-provider-sftp.ts b/src/main/providers/ssh-filesystem-provider-sftp.ts index 799573e4112..f0a90d93038 100644 --- a/src/main/providers/ssh-filesystem-provider-sftp.ts +++ b/src/main/providers/ssh-filesystem-provider-sftp.ts @@ -1,6 +1,4 @@ -import { closeSftpDirectoryHandle } from './ssh-sftp-directory-close' -import type { FileEntryWithStats, SFTPWrapper, Stats } from 'ssh2' -import type { FileStat } from './filesystem-provider-contract' +import type { SFTPWrapper } from 'ssh2' const ABORTED_SFTP_OPERATION_GRACE_MS = 5_000 @@ -56,34 +54,6 @@ function waitForSftpCallback( }) } -export function fileStatFromSftpStats(stats: Stats): FileStat { - let type: FileStat['type'] = 'file' - if (stats.isDirectory()) { - type = 'directory' - } else if (stats.isSymbolicLink()) { - type = 'symlink' - } - const maybeNlink = (stats as Stats & { nlink?: unknown }).nlink - return { - size: stats.size, - type, - mtime: stats.mtime * 1000, - ...(typeof maybeNlink === 'number' ? { nlink: maybeNlink } : {}) - } -} - -export function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.lstat(filePath, (err, stats) => { - if (err) { - reject(err) - return - } - resolve(fileStatFromSftpStats(stats)) - }) - }) -} - export function fastGetViaSftp( sftp: SFTPWrapper, sourcePath: string, @@ -95,72 +65,3 @@ export function fastGetViaSftp( options ) } - -export async function* readDirectoryEntriesViaSftp( - sftp: SFTPWrapper, - dirPath: string, - options?: { signal?: AbortSignal } -): AsyncGenerator { - // Keep the late handle visible to finally when cancellation races opendir. - options?.signal?.throwIfAborted() - const handle = await waitForSftpCallback( - (callback) => - sftp.opendir(dirPath, (error, value) => { - if (!error && options?.signal?.aborted) { - void closeSftpDirectoryHandle(sftp, value) - callback(new Error('Download canceled')) - return - } - callback(error, value) - }), - options - ) - let reachedEof = false - let closeError: Error | undefined - try { - options?.signal?.throwIfAborted() - while (true) { - let chunk: FileEntryWithStats[] | false - try { - chunk = await waitForSftpCallback( - (callback) => sftp.readdir(handle, callback), - options - ) - } catch (error) { - options?.signal?.throwIfAborted() - if (error instanceof Error && 'code' in error && error.code === 1) { - reachedEof = true - break - } - throw error - } - if (chunk === false) { - reachedEof = true - break - } - for (const entry of chunk) { - options?.signal?.throwIfAborted() - if (entry.filename !== '.' && entry.filename !== '..') { - yield entry - } - } - } - } finally { - closeError = await closeSftpDirectoryHandle(sftp, handle) - } - // Consumer failures enter finally via return(); do not replace their reason. - if (reachedEof) { - options?.signal?.throwIfAborted() - if (closeError) { - throw closeError - } - } -} - -export function statViaSftp( - sftp: SFTPWrapper, - filePath: string, - options?: { signal?: AbortSignal } -): Promise { - return waitForSftpCallback((callback) => sftp.stat(filePath, callback), options) -} diff --git a/src/main/providers/ssh-sftp-directory-close.ts b/src/main/providers/ssh-sftp-directory-close.ts deleted file mode 100644 index d610f1cf2b3..00000000000 --- a/src/main/providers/ssh-sftp-directory-close.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' - -const SFTP_DIRECTORY_CLOSE_TIMEOUT_MS = 5_000 -const retiredChannels = new WeakSet() - -export function isSftpDirectoryChannelRetired(sftp: SFTPWrapper): boolean { - return retiredChannels.has(sftp) -} - -export function closeSftpDirectoryHandle( - sftp: SFTPWrapper, - handle: Buffer -): Promise { - return new Promise((resolve) => { - let settled = false - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - if (error && !retiredChannels.has(sftp)) { - retiredChannels.add(sftp) - try { - sftp.end() - } catch { - // Preserve the CLOSE failure if channel teardown also fails. - } - } - resolve(error) - } - const timer = setTimeout( - () => finish(new Error('SFTP directory CLOSE timed out')), - SFTP_DIRECTORY_CLOSE_TIMEOUT_MS - ) - try { - sftp.close(handle, (error) => finish(error ?? undefined)) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - } - }) -} diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 8a439562f71..66c9b0c4770 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -62,16 +62,6 @@ export function shouldInspectWindowsAgentForeground(fallbackProcess: string): bo ) } -export async function resolveWindowsAgentForegroundProcess( - shellPid: number, - fallbackProcess: string, - options: AgentForegroundResolutionOptions -): Promise { - return ( - await resolveWindowsAgentForegroundProcessWithAvailability(shellPid, fallbackProcess, options) - ).processName -} - export async function resolveWindowsAgentForegroundProcessWithAvailability( shellPid: number, fallbackProcess: string, diff --git a/src/main/ssh/orcad-activation-crash-recovery.test.ts b/src/main/ssh/orcad-activation-crash-recovery.test.ts index c834c4454c9..45e0ac2c4b9 100644 --- a/src/main/ssh/orcad-activation-crash-recovery.test.ts +++ b/src/main/ssh/orcad-activation-crash-recovery.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as DeployHelpers from './ssh-relay-deploy-helpers' import type * as RecordFile from './orcad-remote-record-file' import type * as InstallLock from './ssh-relay-install-lock' -import type * as VersionedInstall from './ssh-relay-versioned-install' +import type * as VersionedInstall from './remote-versioned-install' import type * as Crypto from 'node:crypto' const uuid = vi.hoisted((): { fixed: string | null } => ({ fixed: null })) @@ -26,7 +26,7 @@ vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ ...(await importOriginal()), writeAtomicOrcadRemoteRecord: vi.fn() })) -vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ +vi.mock('./remote-versioned-install', async (importOriginal) => ({ ...(await importOriginal()), readLocalFullVersion: () => '0.2.0+bb01' })) diff --git a/src/main/ssh/orcad-managed-auto-update.test.ts b/src/main/ssh/orcad-managed-auto-update.test.ts index 3ab6fa0a94c..71c6f4719a0 100644 --- a/src/main/ssh/orcad-managed-auto-update.test.ts +++ b/src/main/ssh/orcad-managed-auto-update.test.ts @@ -30,7 +30,7 @@ vi.mock('./orcad-managed-migration-status', () => ({ findIncompleteManagedOrcadMigration: mocks.migrating })) vi.mock('./orcad-artifact-materializer', () => ({ materializeOrcadArtifact: mocks.materialize })) -vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: () => '0.1.0+new' })) +vi.mock('./remote-versioned-install', () => ({ readLocalFullVersion: () => '0.1.0+new' })) import { autoUpdateManagedOrcadEnvironment, diff --git a/src/main/ssh/orcad-managed-auto-update.ts b/src/main/ssh/orcad-managed-auto-update.ts index 75ddd079392..f9d9cd154de 100644 --- a/src/main/ssh/orcad-managed-auto-update.ts +++ b/src/main/ssh/orcad-managed-auto-update.ts @@ -17,7 +17,7 @@ import { ORCAD_ACTIVATION_FENCE_BUSY_CODE } from './orcad-activation-fence-hold' import { resolveLinkedOrcadContext } from './orcad-managed-runtime-context' import { runManagedOrcadUpdate, withManagedOrcadLifecycle } from './orcad-runtime-maintenance' import type { OrcadUpdateDeferCode } from './orcad-update-plan' -import { readLocalFullVersion } from './ssh-relay-versioned-install' +import { readLocalFullVersion } from './remote-versioned-install' export type ManagedOrcadAutoUpdateSkip = | 'current' diff --git a/src/main/ssh/orcad-managed-runtime-context.ts b/src/main/ssh/orcad-managed-runtime-context.ts index bed719b8f64..daa54968fe1 100644 --- a/src/main/ssh/orcad-managed-runtime-context.ts +++ b/src/main/ssh/orcad-managed-runtime-context.ts @@ -10,7 +10,7 @@ import { sshOrcadHostTransport } from './orcad-ssh-host-transport' import { probeActiveOrcadReadiness } from './orcad-active-readiness' import { resolveOrcadRemoteContext, type OrcadRemoteContext } from './orcad-remote-context' import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { computeRemoteInstallDir } from './remote-versioned-install' import { getSshConnectionManager, getSshTargetRegistryStore } from './ssh-target-registry' export const ORCAD_BIND_HOST = '127.0.0.1' diff --git a/src/main/ssh/orcad-recovery-slot.ts b/src/main/ssh/orcad-recovery-slot.ts index a385efa0b40..a2ebffbac28 100644 --- a/src/main/ssh/orcad-recovery-slot.ts +++ b/src/main/ssh/orcad-recovery-slot.ts @@ -7,7 +7,7 @@ import type { ServeReadiness } from '../server/serve-readiness' import type { OrcadActivationExpectation } from './orcad-activation-gate' import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { computeRemoteInstallDir } from './remote-versioned-install' import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' import { launchOrcadSlotAndAwaitReadiness, diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index ad03182844d..a80ef08c349 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -42,7 +42,7 @@ import { abandonInstall, finalizeInstall, isRemoteInstallComplete -} from './ssh-relay-versioned-install' +} from './remote-versioned-install' import { emptyOrcadActivationRecord, withActivatedVersion, @@ -61,7 +61,7 @@ const mockExec = vi.mocked(execCommand) const NEW_VERSION = '0.2.0+bb0100000000' const OLD_VERSION = '0.1.0+aa01' -vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ +vi.mock('./remote-versioned-install', async (importOriginal) => ({ ...(await importOriginal>()), readLocalFullVersion: () => '0.2.0+bb0100000000', isRemoteInstallComplete: vi.fn().mockResolvedValue(false), diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index c7ffa29449c..4944e9444fa 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -10,7 +10,7 @@ import { logOrcadActivationOutcome } from './orcad-activation-outcome-log' import { join } from 'node:path' import type { OrcadHostTransport } from './orcad-host-transport' import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir, readLocalFullVersion } from './ssh-relay-versioned-install' +import { computeRemoteInstallDir, readLocalFullVersion } from './remote-versioned-install' import { readOrcadActivationRecord } from './orcad-activation-record-store' import type { OrcadActivationRecord } from './orcad-activation-record' import type { OrcadActivationVerdict } from './orcad-activation-gate' diff --git a/src/main/ssh/orcad-remote-gc.test.ts b/src/main/ssh/orcad-remote-gc.test.ts index 7288b9af61c..60a73c1d9ae 100644 --- a/src/main/ssh/orcad-remote-gc.test.ts +++ b/src/main/ssh/orcad-remote-gc.test.ts @@ -10,8 +10,8 @@ vi.mock('./ssh-relay-gc-claim', () => ({ releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('token') })) -vi.mock('./ssh-relay-gc-tombstone', () => ({ - cleanupRelayGcTombstones: vi.fn().mockResolvedValue(undefined) +vi.mock('./remote-install-gc-tombstone', () => ({ + cleanupRemoteInstallGcTombstones: vi.fn().mockResolvedValue(undefined) })) vi.mock('./ssh-relay-install-lock', () => ({ RELAY_INSTALL_LOCK_NAME: '.install-lock', diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts index 4a9fa5a20b4..b5fb161bca2 100644 --- a/src/main/ssh/orcad-remote-gc.ts +++ b/src/main/ssh/orcad-remote-gc.ts @@ -17,7 +17,7 @@ import type { OrcadHostTransport } from './orcad-host-transport' import { execCommand } from './ssh-relay-deploy-helpers' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { gcOldRemoteInstallVersions } from './ssh-relay-versioned-install' +import { gcOldRemoteInstallVersions } from './remote-install-gc' import { orcadGcPinnedDirNames, type OrcadActivationRecord } from './orcad-activation-record' import { ORCAD_NEVER_LAUNCHED, diff --git a/src/main/ssh/orcad-remote-install.ts b/src/main/ssh/orcad-remote-install.ts index b5738b82a64..abc7a11305e 100644 --- a/src/main/ssh/orcad-remote-install.ts +++ b/src/main/ssh/orcad-remote-install.ts @@ -19,7 +19,7 @@ import { abandonInstall, finalizeInstall, isRemoteInstallComplete -} from './ssh-relay-versioned-install' +} from './remote-versioned-install' /** Install the bytes under `orcad-/`, using the relay's install transaction. */ export async function installOrcadBundle( diff --git a/src/main/ssh/orcad-remote-lifecycle-windows.test.ts b/src/main/ssh/orcad-remote-lifecycle-windows.test.ts index 23f6624b831..b2ca209f452 100644 --- a/src/main/ssh/orcad-remote-lifecycle-windows.test.ts +++ b/src/main/ssh/orcad-remote-lifecycle-windows.test.ts @@ -41,7 +41,7 @@ vi.mock('./orcad-remote-node-runtime', () => ({ vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'abc123def4567890' })) -vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ +vi.mock('./remote-versioned-install', async (importOriginal) => ({ ...(await importOriginal>()), readLocalFullVersion: () => '0.2.0+bb0100000000', isRemoteInstallComplete: vi.fn().mockResolvedValue(false), diff --git a/src/main/ssh/orcad-remote-windows-decommission.test.ts b/src/main/ssh/orcad-remote-windows-decommission.test.ts index 05163ac845a..a522d6f2b0c 100644 --- a/src/main/ssh/orcad-remote-windows-decommission.test.ts +++ b/src/main/ssh/orcad-remote-windows-decommission.test.ts @@ -19,14 +19,14 @@ vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ ...(await importOriginal()), writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ +vi.mock('./remote-install-gc', async (importOriginal) => ({ ...(await importOriginal>()), gcOldRemoteInstallVersions: vi.fn().mockResolvedValue(undefined) })) import { execCommand } from './ssh-relay-deploy-helpers' import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' -import { gcOldRemoteInstallVersions } from './ssh-relay-versioned-install' +import { gcOldRemoteInstallVersions } from './remote-install-gc' import { decommissionRemoteOrcad } from './orcad-remote-decommission' import { gcOldOrcadVersions } from './orcad-remote-gc' import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' diff --git a/src/main/ssh/orcad-state-mutation-timeout.test.ts b/src/main/ssh/orcad-state-mutation-timeout.test.ts index d45977dd5c7..51672fd3a9d 100644 --- a/src/main/ssh/orcad-state-mutation-timeout.test.ts +++ b/src/main/ssh/orcad-state-mutation-timeout.test.ts @@ -8,7 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as DeployHelpers from './ssh-relay-deploy-helpers' import type * as RecordFile from './orcad-remote-record-file' import type * as InstallLock from './ssh-relay-install-lock' -import type * as VersionedInstall from './ssh-relay-versioned-install' +import type * as VersionedInstall from './remote-versioned-install' vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ ...(await importOriginal()), @@ -23,7 +23,7 @@ vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ ...(await importOriginal()), writeAtomicOrcadRemoteRecord: vi.fn() })) -vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ +vi.mock('./remote-versioned-install', async (importOriginal) => ({ ...(await importOriginal()), readLocalFullVersion: () => '0.2.0+bb01' })) diff --git a/src/main/ssh/relay-socket-path-limit-shell.integration.test.ts b/src/main/ssh/relay-socket-path-limit-shell.integration.test.ts deleted file mode 100644 index ce08556c3d7..00000000000 --- a/src/main/ssh/relay-socket-path-limit-shell.integration.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { execFile } from 'node:child_process' -import { chmod, mkdtemp, mkdir, rm, symlink, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { promisify } from 'node:util' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - resolveShortRelaySocketDirCommand, - shortRelayVersionSegment -} from './relay-socket-path-limit' - -const run = promisify(execFile) - -// The generated script runs on the remote host's /bin/sh, so assert against a real shell rather -// than a string match: the hazard here is an ordering bug that only a filesystem can observe. -describe('short relay socket dir guard, against a real shell', () => { - let root: string - - const VERSION_SEGMENT = shortRelayVersionSegment('relay-0.1.0+test') - - // Retarget the generated script at a sandbox instead of the real /tmp path. - function scriptFor(dir: string): string { - return resolveShortRelaySocketDirCommand(VERSION_SEGMENT).replace( - /^dir=.*$/m, - `dir=${JSON.stringify(dir)}` - ) - } - - async function attempt(dir: string): Promise<{ ok: boolean; stdout: string }> { - try { - const { stdout } = await run('/bin/sh', ['-c', scriptFor(dir)]) - return { ok: true, stdout } - } catch { - return { ok: false, stdout: '' } - } - } - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-relay-dir-guard-')) - }) - - afterEach(async () => { - await rm(root, { recursive: true, force: true }) - }) - - it('creates the directory and its version segment when neither exists', async () => { - const dir = join(root, 'fresh') - const attempted = await attempt(dir) - expect(attempted.ok).toBe(true) - expect((await stat(dir)).mode & 0o777).toBe(0o700) - // The segment is what keeps a later build off the path this one binds. - expect((await stat(join(dir, VERSION_SEGMENT))).mode & 0o777).toBe(0o700) - expect(attempted.stdout.trim().endsWith(`${dir}/${VERSION_SEGMENT}`)).toBe(true) - }) - - it('refuses a planted symlink in the version segment without following it', async () => { - const dir = join(root, 'mine') - const victim = join(root, 'segment-victim') - await mkdir(dir) - await chmod(dir, 0o700) - await mkdir(victim) - await chmod(victim, 0o755) - await symlink(victim, join(dir, VERSION_SEGMENT)) - - const before = (await stat(victim)).mode & 0o777 - expect((await attempt(dir)).ok).toBe(false) - expect((await stat(victim)).mode & 0o777).toBe(before) - }) - - it('adopts a directory we already own at 0700, so reconnects keep working', async () => { - // Regression guard: `ls` decorates the mode with @ (xattrs), + (ACL) or . (SELinux), and an - // exact match refused a directory we own — which would have broken every reconnect. - const dir = join(root, 'mine') - await mkdir(dir) - await chmod(dir, 0o700) - expect((await attempt(dir)).ok).toBe(true) - expect((await attempt(dir)).ok).toBe(true) - }) - - it('refuses a planted symlink without changing what it points at', async () => { - const victim = join(root, 'victim') - const link = join(root, 'link') - await mkdir(victim) - await chmod(victim, 0o755) - await symlink(victim, link) - - const before = (await stat(victim)).mode & 0o777 - expect((await attempt(link)).ok).toBe(false) - // The point of the ordering: an unconditional chmod would have followed the link and - // rewritten the victim's mode before the owner check ever ran. - expect((await stat(victim)).mode & 0o777).toBe(before) - }) - - it('refuses an existing directory that is not 0700', async () => { - const dir = join(root, 'loose') - await mkdir(dir) - // Explicit chmod: mkdir's mode is masked by the process umask, so the fixture would not - // actually be world-writable and the test would not be testing what it claims. - await chmod(dir, 0o777) - expect((await attempt(dir)).ok).toBe(false) - expect((await stat(dir)).mode & 0o777).toBe(0o777) - }) -}) diff --git a/src/main/ssh/relay-socket-path-limit.test.ts b/src/main/ssh/relay-socket-path-limit.test.ts deleted file mode 100644 index 525f67492fa..00000000000 --- a/src/main/ssh/relay-socket-path-limit.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn(() => 'linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: () => false, - execCommand: vi.fn().mockResolvedValue('') -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-endpoint-credential', () => ({ - writeRelayEndpointCredential: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+8d4e15ad63eb'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { name: 'AbortError' }) -})) - -import { - parseShortRelaySocketDir, - remoteSocketPathFitsLimit, - remoteUnixSocketPathByteLimit, - shortRelayVersionSegment -} from './relay-socket-path-limit' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const LINUX = getRemoteHostPlatform('linux-x64') -const DARWIN = getRemoteHostPlatform('darwin-arm64') -const WINDOWS = getRemoteHostPlatform('win32-x64') - -/** Matches the version this suite's mocked build reports. */ -const RELAY_VERSION_DIR_NAME = 'relay-0.1.0+8d4e15ad63eb' - -describe('remote unix socket path limit', () => { - it('uses the per-OS sun_path budget and ignores Windows named pipes', () => { - expect(remoteUnixSocketPathByteLimit(LINUX)).toBe(107) - expect(remoteUnixSocketPathByteLimit(DARWIN)).toBe(103) - expect(remoteUnixSocketPathByteLimit(WINDOWS)).toBeNull() - expect(remoteSocketPathFitsLimit(WINDOWS, `\\\\.\\pipe\\orca-relay-${'a'.repeat(400)}`)).toBe( - true - ) - }) - - it('measures bytes, not characters', () => { - // 1 + 52 two-byte characters = 105 bytes: fits Linux (107), not macOS (103). - const path = `/${'é'.repeat(52)}` - expect(path.length).toBe(53) - expect(remoteSocketPathFitsLimit(LINUX, path)).toBe(true) - expect(remoteSocketPathFitsLimit(DARWIN, path)).toBe(false) - }) - - it('accepts only the marker line as the short directory', () => { - const segment = shortRelayVersionSegment(RELAY_VERSION_DIR_NAME) - expect( - parseShortRelaySocketDir( - `Welcome to Ubuntu\nORCA-RELAY-SHORT-SOCKET-DIR /tmp/.orca-relay-1000/${segment}\n`, - segment - ) - ).toBe(`/tmp/.orca-relay-1000/${segment}`) - expect(parseShortRelaySocketDir('mkdir: permission denied\n', segment)).toBeNull() - expect( - parseShortRelaySocketDir(`ORCA-RELAY-SHORT-SOCKET-DIR /etc/${segment}\n`, segment) - ).toBeNull() - // A directory belonging to another build must not be adopted as this build's. - expect( - parseShortRelaySocketDir( - `ORCA-RELAY-SHORT-SOCKET-DIR /tmp/.orca-relay-1000/${shortRelayVersionSegment('relay-9.9.9+other')}\n`, - segment - ) - ).toBeNull() - }) -}) diff --git a/src/main/ssh/relay-socket-path-limit.ts b/src/main/ssh/relay-socket-path-limit.ts index 00ed251d822..2025af7e6db 100644 --- a/src/main/ssh/relay-socket-path-limit.ts +++ b/src/main/ssh/relay-socket-path-limit.ts @@ -1,120 +1,6 @@ /** - * Keeps the remote relay's Unix socket path inside `sockaddr_un.sun_path`. - * - * The default endpoint is `$HOME/.orca-remote/relay-/relay-.sock`, - * whose fixed suffix already costs ~66 bytes. A managed-hosting `$HOME` such as - * `/var/www/` pushes the whole path past the kernel cap and libuv reports only - * `listen EINVAL`, so the relay never starts (#10726). When that happens the socket - * moves to a fixed-length base whose length no longer depends on `$HOME`. - * - * Windows relays bind named pipes (`\\.\pipe\...`), which have no `sun_path` limit. + * Where older relays bound their socket when `$HOME` pushed the default path past + * `sockaddr_un.sun_path` (#10726): a fixed-length per-uid base under `/tmp`. The host census + * still lists endpoints there. */ -import { createHash } from 'node:crypto' -import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { unixSocketPathByteLimit, unixSocketPathFits } from '../../shared/unix-socket-path-limit' - -export function remoteUnixSocketPathByteLimit(host: RemoteHostPlatform): number | null { - if (isWindowsRemoteHost(host)) { - return null - } - return unixSocketPathByteLimit(host.os === 'darwin' ? 'darwin' : 'linux') -} - -export function remoteSocketPathFitsLimit(host: RemoteHostPlatform, sockPath: string): boolean { - return ( - isWindowsRemoteHost(host) || - unixSocketPathFits(sockPath, host.os === 'darwin' ? 'darwin' : 'linux') - ) -} - -/** Fixed-length, per-uid base. `/tmp` is the only POSIX directory whose length is not user-dependent. */ export const SHORT_RELAY_SOCKET_DIR_PREFIX = '/tmp/.orca-relay-' - -/** - * The version segment the relocated socket lives under, named to match the version - * directories in `$HOME/.orca-remote` so one sweep pattern covers both bases. - * - * Why it has to exist: `relaySocketNameForInstanceId` hashes the *target*, not the - * build, so the filename alone is version-independent. Under `$HOME` the enclosing - * `relay-` directory supplies that dimension; without it here, the next - * Orca build would bind the exact path the previous build's relay still holds. The - * daemon handshake compares build hashes exactly, so that meeting is a version - * mismatch — and if the incumbent holds live work, `resolveRelayEndpointBeforeRelaunch` - * raises `RelayEndpointHeldError` and the user cannot connect at all until the old - * relay is stopped. The version is hashed rather than spelled out because the whole - * point of this base is a bounded length. - */ -export function shortRelayVersionSegment(relayVersionDirName: string): string { - return `relay-${createHash('sha256').update(relayVersionDirName).digest('hex').slice(0, 12)}` -} - -/** - * The whole hashed socket name is kept — shortening happens by replacing the - * variable-length directory, never by truncating the hash, so two targets on one - * host can never land on the same socket. - */ -export function shortRelaySocketPath(shortVersionDir: string, sockName: string): string { - return `${shortVersionDir}/${sockName}` -} - -const SHORT_DIR_MARKER = 'ORCA-RELAY-SHORT-SOCKET-DIR' - -/** - * Create (or adopt) the per-uid short socket directory and its version segment, and - * print the segment's path. - * - * Validate before mutating, never the other way round: an unconditional `chmod` follows a - * symlink, so a path planted by another user would have its *target's* mode rewritten before - * the owner check could reject it. A fresh `mkdir` under `umask 077` already yields 0700 and - * proves we own it, so the only path that adopts an existing entry is the one that first - * proves — via `ls -ldn`, which reports the entry itself rather than what it points at — that - * it is a real directory, owned by this uid, already 0700. Nothing else is touched. - */ -export function resolveShortRelaySocketDirCommand(versionSegment: string): string { - return [ - 'uid=$(id -u) || exit 1', - `dir="${SHORT_RELAY_SOCKET_DIR_PREFIX}$uid"`, - 'umask 077', - ...adoptOwnedDirectoryCommand('$dir'), - // The version segment is validated the same way rather than trusted: `$dir` being - // 0700 and ours does not prove what an earlier run left inside it still is. - `ver="$dir/${versionSegment}"`, - ...adoptOwnedDirectoryCommand('$ver'), - `printf '%s %s\n' '${SHORT_DIR_MARKER}' "$ver"` - ].join('\n') -} - -function adoptOwnedDirectoryCommand(target: string): string[] { - return [ - `if mkdir "${target}" 2>/dev/null; then`, - ' :', - 'else', - // Why the sub(): ls decorates the mode with a trailing marker for extended attributes (@), - // ACLs (+) or an SELinux context (.), so an exact match would refuse a directory we own. - ` entry=$(ls -ldn "${target}" 2>/dev/null | awk 'NR==1{sub(/[.@+]$/, "", $1); print $1" "$3}')`, - ' case "$entry" in', - ' "drwx------ $uid") ;;', - ' *) exit 1 ;;', - ' esac', - 'fi' - ] -} - -/** Tolerates login-shell banner noise ahead of the marker line. */ -export function parseShortRelaySocketDir(output: string, versionSegment: string): string | null { - for (const line of output.split('\n')) { - const trimmed = line.trim() - if (!trimmed.startsWith(`${SHORT_DIR_MARKER} `)) { - continue - } - const dir = trimmed.slice(SHORT_DIR_MARKER.length + 1).trim() - if ( - dir.startsWith(`${SHORT_RELAY_SOCKET_DIR_PREFIX}`) && - dir.endsWith(`/${versionSegment}`) && - !/[\r\n]/.test(dir) - ) { - return dir - } - } - return null -} diff --git a/src/main/ssh/relay-version-dir-liveness.test.ts b/src/main/ssh/relay-version-dir-liveness.test.ts deleted file mode 100644 index 7abf4017cb1..00000000000 --- a/src/main/ssh/relay-version-dir-liveness.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** Runs the generated POSIX liveness probe through a real `/bin/sh` against real sockets. */ -import { execFileSync, spawnSync } from 'node:child_process' -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { createServer, type Server } from 'node:net' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { RELAY_PID_FILENAME } from '../../shared/relay-artifacts' -import { - parseRelayVersionDirLiveness, - relayVersionDirLivenessCommand -} from './relay-version-dir-liveness' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { WINDOWS_RELAY_LIVENESS_JS } from './ssh-remote-commands' - -const host = getRemoteHostPlatform('linux-x64') -const posixOnly = process.platform === 'win32' ? describe.skip : describe - -function probe(dir: string, nodePath: string | undefined = process.execPath): string { - return execFileSync('/bin/sh', ['-c', relayVersionDirLivenessCommand(host, dir, { nodePath })], { - encoding: 'utf8' - }) -} - -/** A socket inode left by a SIGKILLed listener: connect is refused. Returns the dead PID. */ -function leaveStaleSocket(sockPath: string): number { - const child = spawnSync( - process.execPath, - [ - '-e', - 'require("net").createServer().listen(process.argv[1],()=>{' + - 'process.stdout.write(String(process.pid));process.kill(process.pid,"SIGKILL")})', - sockPath - ], - { encoding: 'utf8' } - ) - return Number.parseInt(child.stdout, 10) -} - -posixOnly('relayVersionDirLivenessCommand (real shell)', () => { - const dirs: string[] = [] - const servers: Server[] = [] - afterEach(async () => { - await Promise.all( - servers.splice(0).map((server) => new Promise((done) => server.close(() => done()))) - ) - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } - }) - - function versionDir(): string { - // Short base: sun_path caps socket paths near 104 bytes on macOS. - const dir = mkdtempSync(join('/tmp', 'rvl-')) - dirs.push(dir) - return dir - } - - it('is exited for a stale socket whose recorded PID is dead', () => { - const dir = versionDir() - const deadPid = leaveStaleSocket(join(dir, 'relay-a.sock')) - writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid}\n`) - - expect(parseRelayVersionDirLiveness(probe(dir))).toBe('exited') - }) - - it('is live for a stale socket whose recorded PID is still running', () => { - const dir = versionDir() - leaveStaleSocket(join(dir, 'relay-a.sock')) - writeFileSync(join(dir, RELAY_PID_FILENAME), `${process.pid}\n`) - - expect(parseRelayVersionDirLiveness(probe(dir))).toBe('live') - }) - - it('keeps the test -S rule for a refused socket without a PID file', () => { - const dir = versionDir() - leaveStaleSocket(join(dir, 'relay-a.sock')) - - expect(parseRelayVersionDirLiveness(probe(dir))).toBe('live') - }) - - it('is exited without a PID file only when no socket is left', () => { - expect(parseRelayVersionDirLiveness(probe(versionDir()))).toBe('exited') - }) - - it('is live when another relay of this build still accepts on its socket', async () => { - const dir = versionDir() - const deadPid = leaveStaleSocket(join(dir, 'relay-a.sock')) - writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid}\n`) - const server = createServer() - servers.push(server) - await new Promise((done) => server.listen(join(dir, 'relay-b.sock'), done)) - - expect(parseRelayVersionDirLiveness(probe(dir))).toBe('live') - }) - - it('is unverifiable when the connect probe times out', () => { - const dir = versionDir() - const deadPid = leaveStaleSocket(join(dir, 'relay-a.sock')) - writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid}\n`) - // Stands in for node: the connect probe prints `unknown` when its timer fires first. - const timedOutNode = join(dir, 'node') - writeFileSync(timedOutNode, '#!/bin/sh\nprintf unknown\n') - chmodSync(timedOutNode, 0o755) - - expect(parseRelayVersionDirLiveness(probe(dir, timedOutNode))).toBe('unverifiable') - }) - - it('is unverifiable when no Node is available to test a leftover socket', () => { - const dir = versionDir() - const deadPid = leaveStaleSocket(join(dir, 'relay-a.sock')) - writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid}\n`) - - expect(parseRelayVersionDirLiveness(probe(dir, ''))).toBe('unverifiable') - }) - - it('is unverifiable for an unreadable PID record', () => { - const dir = versionDir() - writeFileSync(join(dir, RELAY_PID_FILENAME), 'not-a-pid\n') - - expect(parseRelayVersionDirLiveness(probe(dir))).toBe('unverifiable') - }) -}) - -describe('parseRelayVersionDirLiveness', () => { - it('maps the Windows pipe vocabulary and treats anything else as unverifiable', () => { - expect(parseRelayVersionDirLiveness('ALIVE')).toBe('live') - expect(parseRelayVersionDirLiveness('WAITING')).toBe('exited') - expect(parseRelayVersionDirLiveness('DEAD\n')).toBe('exited') - expect(parseRelayVersionDirLiveness('')).toBe('unverifiable') - expect(parseRelayVersionDirLiveness('UNKNOWN')).toBe('unverifiable') - }) -}) - -/** The Windows probe's JavaScript under the local Node; only the pipe names are Windows-only. */ -describe('Windows relay liveness script (design D5 .relay-pid)', () => { - const dirs: string[] = [] - afterEach(() => { - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } - }) - - function windowsProbe(setup: (dir: string) => void): string { - const dir = mkdtempSync(join(tmpdir(), 'rvl-win-')) - dirs.push(dir) - setup(dir) - return parseRelayVersionDirLiveness( - execFileSync(process.execPath, ['-e', WINDOWS_RELAY_LIVENESS_JS, dir], { encoding: 'utf8' }) - ) - } - - const deadPid = (): number => - Number.parseInt( - spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { - encoding: 'utf8' - }).stdout, - 10 - ) - const marker = (dir: string): void => - writeFileSync( - join(dir, '.windows-active-pipe-a'), - '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' - ) - - it('is live for a running recorded PID without touching any pipe', () => { - expect( - windowsProbe((dir) => { - writeFileSync(join(dir, RELAY_PID_FILENAME), `${process.pid}\n`) - marker(dir) - }) - ).toBe('live') - }) - - it('is exited for a dead recorded PID whose pipes all refuse', () => { - expect( - windowsProbe((dir) => { - writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid()}\n`) - marker(dir) - }) - ).toBe('exited') - }) - - it('is exited for a dead recorded PID that left no pipe marker', () => { - expect( - windowsProbe((dir) => writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid()}\n`)) - ).toBe('exited') - }) - - it('is unverifiable for an unreadable PID record', () => { - expect(windowsProbe((dir) => writeFileSync(join(dir, RELAY_PID_FILENAME), 'x\n'))).toBe( - 'unverifiable' - ) - }) - - it('keeps the old rule without a PID file: no marker is never evidence of exit', () => { - expect(windowsProbe(() => {})).toBe('live') - }) -}) diff --git a/src/main/ssh/relay-version-dir-liveness.ts b/src/main/ssh/relay-version-dir-liveness.ts deleted file mode 100644 index 1a41d16f4f6..00000000000 --- a/src/main/ssh/relay-version-dir-liveness.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Whether a relay version directory is still in use, answered with the execution-boundary - * vocabulary (docs/reference/ssh-execution-boundary.md): `live` / `unverifiable` / `exited`. - * - * Design D5: a directory is `exited` only when its recorded `.relay-pid` is provably dead AND - * every `relay-*.sock` in it refuses a connection. The PID is checked first so a live daemon - * about to idle is never connected to (a connection would cancel its grace timer). A directory - * with no PID file was last used by a relay that predates it and keeps the `test -S` rule. - */ -import { RELAY_PID_FILENAME } from '../../shared/relay-artifacts' -import { shellEscape } from './ssh-connection-utils' -import { posixProcessAliveShellFunction } from './orcad-remote-host-support' -import { RELAY_CONNECT_PROBE_JS, type RelayEndpointVerdict } from './ssh-relay-endpoint-incumbent' -import { relayLivenessProbeCommand, type WindowsRelayLivenessOptions } from './ssh-remote-commands' -import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' - -export function relayVersionDirLivenessCommand( - host: RemoteHostPlatform, - dir: string, - options: { nodePath?: string; windows?: WindowsRelayLivenessOptions } = {} -): string { - if (isWindowsRemoteHost(host)) { - return relayLivenessProbeCommand(host, dir, options.windows) - } - return [ - `dir=${shellEscape(dir)}`, - `node=${shellEscape(options.nodePath ?? '')}`, - `pid_file="$dir"/${RELAY_PID_FILENAME}`, - 'socks=', - 'for f in "$dir"/relay-*.sock "$dir"/relay.sock; do [ -S "$f" ] && socks=yes; done', - 'if [ ! -e "$pid_file" ]; then', - ' if [ -n "$socks" ]; then echo LIVE; else echo EXITED; fi', - ' exit 0', - 'fi', - // Prints UNKNOWN and exits when kill -0 fails for any reason but "No such process". - posixProcessAliveShellFunction({ refuseUnverifiable: true }), - 'pid=$(cat "$pid_file" 2>/dev/null) || { echo UNVERIFIABLE; exit 0; }', - 'case "$pid" in "" | *[!0-9]*) echo UNVERIFIABLE; exit 0;; esac', - 'if orcad_alive "$pid"; then echo LIVE; exit 0; fi', - 'for f in "$dir"/relay-*.sock "$dir"/relay.sock; do', - ' [ -S "$f" ] || continue', - ' [ -n "$node" ] || { echo UNVERIFIABLE; exit 0; }', - // Another relay of this build may share the dir under its own socket; only a refusal clears it. - ` r=$("$node" -e ${shellEscape(RELAY_CONNECT_PROBE_JS)} "$f" 2>/dev/null) || r=unknown`, - ' case "$r" in', - ' refused | absent) ;;', - ' accepted) echo LIVE; exit 0;;', - ' *) echo UNVERIFIABLE; exit 0;;', - ' esac', - 'done', - 'echo EXITED' - ].join('\n') -} - -export function parseRelayVersionDirLiveness(output: string): RelayEndpointVerdict { - const token = output.trim().split('\n').pop()?.trim() ?? '' - // ALIVE / DEAD / WAITING are the Windows pipe probe's vocabulary. - if (token === 'LIVE' || token === 'ALIVE') { - return 'live' - } - if (token === 'EXITED' || token === 'DEAD' || token === 'WAITING') { - return 'exited' - } - return 'unverifiable' -} diff --git a/src/main/ssh/remote-install-gc-termination.test.ts b/src/main/ssh/remote-install-gc-termination.test.ts index e5380942156..812b6bc6257 100644 --- a/src/main/ssh/remote-install-gc-termination.test.ts +++ b/src/main/ssh/remote-install-gc-termination.test.ts @@ -7,18 +7,16 @@ vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ })) import type { SshConnection } from './ssh-connection' -import { gcOldRelayVersions } from './remote-install-gc' +import { gcOldRemoteInstallVersions } from './remote-install-gc' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' import { execCommand } from './ssh-relay-deploy-helpers' -import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' -import { REMOTE_INSTALL_ORDER_OK } from './remote-install-previous-version' import { getRemoteHostPlatform } from './ssh-remote-platform' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all connection access is replaced by execCommand's mock. const conn = {} as SshConnection const host = getRemoteHostPlatform('linux-x64') const home = '/home/u' -const currentDir = `${home}/.orca-remote/relay-0.1.0+bbb` -const nativeKey = 'linux-x64-0123456789abcdef' +const currentDir = `${home}/.orca-remote/orcad-0.1.0+bbb` const mockExec = vi.mocked(execCommand) beforeEach(() => { @@ -37,21 +35,15 @@ function failAfter(replies: readonly string[], confirmed = false): Error { return error } -function collectRelayVersions(): Promise { - return gcOldRelayVersions(conn, home, currentDir, host, { nativeDepsCacheKeys: [nativeKey] }) +// The liveness probe runs through the same exec, so its termination is covered like any other step. +function collectVersions(): Promise { + return gcOldRemoteInstallVersions(conn, ORCAD_INSTALL_MODEL, home, currentDir, host, { + isDirLive: async (dir) => (await execCommand(conn, `liveness ${dir}`)).trim() !== 'DEAD' + }) } -// ddd is the previous build, so it is pinned and never probed. -const installOrder = [ - `${home}/.orca-remote/relay-0.1.0+bbb/.install-complete`, - `${home}/.orca-remote/relay-0.1.0+ddd/.install-complete`, - `${home}/.orca-remote/relay-0.1.0+aaa/.install-complete`, - REMOTE_INSTALL_ORDER_OK -].join('\n') - const versionSteps = [ - ['listing', 'relay-0.1.0+aaa\nrelay-0.1.0+ddd'], - ['previous install order', installOrder], + ['listing', 'orcad-0.1.0+aaa'], ['install lock probe', 'OPEN'], ['completion probe', 'COMPLETE'], ['liveness probe', 'DEAD'], @@ -72,7 +64,7 @@ describe('version GC termination', () => { async ({ index }) => { const error = failAfter(versionSteps.slice(0, index).map(([, reply]) => reply)) - await expect(collectRelayVersions()).rejects.toBe(error) + await expect(collectVersions()).rejects.toBe(error) expect(mockExec).toHaveBeenCalledTimes(index + 1) } @@ -82,11 +74,11 @@ describe('version GC termination', () => { 'stops after an unconfirmed stale lock probe with claim held: %s', async (claimed) => { const replies = claimed - ? versionSteps.slice(0, 7).map(([, reply]) => String(reply)) - : versionSteps.slice(0, 2).map(([, reply]) => String(reply)) + ? versionSteps.slice(0, 6).map(([, reply]) => String(reply)) + : versionSteps.slice(0, 1).map(([, reply]) => String(reply)) const error = failAfter([...replies, 'LOCKED']) - await expect(collectRelayVersions()).rejects.toBe(error) + await expect(collectVersions()).rejects.toBe(error) expect(mockExec).toHaveBeenCalledTimes(replies.length + 2) } @@ -94,92 +86,11 @@ describe('version GC termination', () => { it('stops after unconfirmed abandoned tombstone deletion', async () => { const error = failAfter([ - 'relay-0.1.0+aaa.gc-tombstone.1.1\nrelay-0.1.0+ccc.gc-tombstone.1.1\nrelay-0.1.0+ddd' + 'orcad-0.1.0+aaa.gc-tombstone.1.1\norcad-0.1.0+ccc.gc-tombstone.1.1\norcad-0.1.0+ddd' ]) - await expect(collectRelayVersions()).rejects.toBe(error) - - expect(mockExec).toHaveBeenCalledTimes(2) - }) - - it('preserves an unconfirmed native cache cleanup error for its caller', async () => { - const error = failAfter(['']) - - await expect(collectRelayVersions()).rejects.toBe(error) + await expect(collectVersions()).rejects.toBe(error) expect(mockExec).toHaveBeenCalledTimes(2) }) }) - -const caches = [ - { - name: 'native dependencies', - collect: () => gcRelayNativeDepsCache(conn, host, home), - listing: `ENTRY ${nativeKey}\nENTRY linux-x64-fedcba9876543210\n__ORCA_NATIVE_CACHE__LIST_OK`, - references: '__ORCA_NATIVE_CACHE__REFS_OK' - } -] - -describe.each(caches)('$name cache GC termination', ({ collect, listing, references }) => { - const steps = [ - ['listing', listing], - ['reference scan', references], - ['rename', 'MOVED'], - ['reference recheck', references], - ['tombstone deletion', ''] - ] as const - - it.each(steps.map(([phase], index) => ({ phase, index })))( - 'preserves unconfirmed $phase termination and stops collection', - async ({ index, phase }) => { - const error = failAfter(steps.slice(0, index).map(([, reply]) => reply)) - - await expect(collect()).rejects.toBe(error) - - expect(mockExec).toHaveBeenCalledTimes(index + (phase === 'reference recheck' ? 2 : 1)) - if (phase === 'reference recheck') { - expect(mockExec.mock.calls.at(-1)?.[1]).toContain('[ ! -e ') - } - } - ) - - it.each(steps.map(([phase], index) => ({ phase, index })))( - 'keeps a confirmed $phase failure nonfatal', - async ({ index }) => { - failAfter( - steps.slice(0, index).map(([, reply]) => reply), - true - ) - - await expect(collect()).resolves.toBeUndefined() - } - ) - - it('stops when restoring a tombstone has unconfirmed termination', async () => { - const error = failAfter([listing, references, 'MOVED', 'unreadable references']) - - await expect(collect()).rejects.toBe(error) - - expect(mockExec).toHaveBeenCalledTimes(5) - }) - - it.each([false, true])( - 'does not retry a failed restoration after an unconfirmed recheck: %s', - async (confirmed) => { - const error = failAfter([listing, references, 'MOVED']) - mockExec.mockRejectedValueOnce( - Object.assign(new Error('restore failed'), { sshChannelCloseConfirmed: confirmed }) - ) - - await expect(collect()).rejects.toBe(error) - - expect(mockExec).toHaveBeenCalledTimes(5) - } - ) - - it('keeps a confirmed restore failure nonfatal', async () => { - failAfter([listing, references, 'MOVED', 'unreadable references'], true) - - await expect(collect()).resolves.toBeUndefined() - }) -}) diff --git a/src/main/ssh/ssh-relay-gc-tombstone.ts b/src/main/ssh/remote-install-gc-tombstone.ts similarity index 71% rename from src/main/ssh/ssh-relay-gc-tombstone.ts rename to src/main/ssh/remote-install-gc-tombstone.ts index 7b0ba8dff0c..33d015feb3f 100644 --- a/src/main/ssh/ssh-relay-gc-tombstone.ts +++ b/src/main/ssh/remote-install-gc-tombstone.ts @@ -1,6 +1,7 @@ import type { RemoteInstallTransport } from './remote-install-transport' import { execCommand } from './ssh-relay-deploy-helpers' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { remoteInstallGcTombstoneRegex, type RemoteInstallModel } from './remote-install-model' import { removeRemoteTreeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform, @@ -10,18 +11,18 @@ import { } from './ssh-remote-platform' const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') -const RELAY_GC_TOMBSTONE_REGEX = - /^relay-(?:v?\d+\.\d+\.\d+(?:\+[0-9a-f]+)?)\.gc-tombstone\.\d+\.\d+$/ -export async function cleanupRelayGcTombstones( +export async function cleanupRemoteInstallGcTombstones( conn: RemoteInstallTransport, + model: RemoteInstallModel, baseDir: string, entries: string[], host: RemoteHostPlatform = DEFAULT_REMOTE_HOST ): Promise { // Why: a confirmed rename isolates these paths from any recreated install. // Strictly named leftovers are safe to retry after an interrupted GC pass. - for (const name of entries.filter((entry) => RELAY_GC_TOMBSTONE_REGEX.test(entry))) { + const tombstoneRegex = remoteInstallGcTombstoneRegex(model) + for (const name of entries.filter((entry) => tombstoneRegex.test(entry))) { const tombstone = joinRemotePath(host, baseDir, name) await execCommand(conn, removeRemoteTreeCommand(host, tombstone), { wrapCommand: !isWindowsRemoteHost(host) @@ -30,7 +31,7 @@ export async function cleanupRelayGcTombstones( throw err } console.warn( - `[ssh-relay] GC failed to remove tombstone ${tombstone}: ${err instanceof Error ? err.message : String(err)}` + `[${model.id}] GC failed to remove tombstone ${tombstone}: ${err instanceof Error ? err.message : String(err)}` ) }) } diff --git a/src/main/ssh/remote-install-gc.ts b/src/main/ssh/remote-install-gc.ts index 0cb88a0e12f..0bfa0e4acfd 100644 --- a/src/main/ssh/remote-install-gc.ts +++ b/src/main/ssh/remote-install-gc.ts @@ -1,9 +1,9 @@ /** - * The version-directory garbage collector, shared by the relay and orcad. + * The version-directory garbage collector orcad runs. A pass only ever sees, and only ever + * deletes, directories belonging to `model`. * - * It lives apart from `ssh-relay-versioned-install.ts` because it is the one piece both - * models run, and because the ownership rule below is the whole point of separating them: - * a pass only ever sees, and only ever deletes, directories belonging to `model`. + * Relay version dirs (`relay-/`) are never reclaimed by this build: nothing deletes them + * until the relay-era upgrade window closes. */ import type { RemoteInstallTransport } from './remote-install-transport' import { RELAY_REMOTE_DIR } from './relay-protocol' @@ -11,7 +11,6 @@ import { execHostCommand } from './ssh-relay-host-exec' import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' import { - RELAY_INSTALL_MODEL, remoteInstallGcPermits, remoteInstallVersionDirRegex, type RemoteInstallModel @@ -21,39 +20,25 @@ import { releaseRelayGcClaimWithRetry, tryAcquireRelayGcClaim } from './ssh-relay-gc-claim' -import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' -import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' +import { cleanupRemoteInstallGcTombstones } from './remote-install-gc-tombstone' import { listRemoteInstallBaseDirsCommand, - MAX_RELAY_GC_LISTING_ENTRIES, + MAX_REMOTE_INSTALL_GC_LISTING_ENTRIES, moveRemoteTreeCommand, probeFileExistsCommand, removeRemoteTreeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform, - isWindowsRemoteHost, joinRemotePath, remoteBasename, type RemoteHostPlatform } from './ssh-remote-platform' -import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' -import type { RelayEndpointVerdict } from './ssh-relay-endpoint-incumbent' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { findPreviousRemoteInstall } from './remote-install-previous-version' -import { - parseRelayVersionDirLiveness, - relayVersionDirLivenessCommand -} from './relay-version-dir-liveness' -// Legacy relay dirs predate `.install-complete`; they need a liveness-only GC check so they -// eventually drain. There is no orcad equivalent — orcad has never shipped without one. -const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') export type RemoteInstallGcOptions = { - windowsNodePath?: string - windowsSockNames?: string[] /** * Model-specific "someone is using this directory" probe. It must answer TRUE when * inconclusive — an unanswered probe is never evidence a tree is idle. @@ -108,9 +93,9 @@ export async function gcOldRemoteInstallVersions( .split('\n') .map((s) => s.trim()) .filter(Boolean) - .slice(0, MAX_RELAY_GC_LISTING_ENTRIES) + .slice(0, MAX_REMOTE_INSTALL_GC_LISTING_ENTRIES) - await cleanupRelayGcTombstones(conn, baseDir, entries, host) + await cleanupRemoteInstallGcTombstones(conn, model, baseDir, entries, host) const versionDirRegex = remoteInstallVersionDirRegex(model) const pinned = new Set([currentDirName, ...(options.pinnedDirNames ?? [])]) @@ -137,7 +122,7 @@ export async function gcOldRemoteInstallVersions( for (const name of survivors) { const dir = joinRemotePath(host, baseDir, name) try { - const safe = await isCandidateSafeToRemove(conn, model, dir, name, host, options) + const safe = await isCandidateSafeToRemove(conn, model, dir, host, options) if (!safe) { kept.push(name) continue @@ -152,7 +137,7 @@ export async function gcOldRemoteInstallVersions( let gcClaimReleaseNeeded = true try { // Recheck under the stable claim; installers probe it before and after creating their lock, closing both orders. - if (!(await isCandidateSafeToRemove(conn, model, dir, name, host, options))) { + if (!(await isCandidateSafeToRemove(conn, model, dir, host, options))) { kept.push(name) continue } @@ -204,12 +189,9 @@ async function isCandidateSafeToRemove( conn: RemoteInstallTransport, model: RemoteInstallModel, dir: string, - name: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, options: RemoteInstallGcOptions ): Promise { - const isLegacy = model.id === 'relay' && LEGACY_RELAY_DIR_REGEX.test(name) - const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME) let lockProbe: string try { @@ -236,114 +218,21 @@ async function isCandidateSafeToRemove( ) } - // Legacy dirs predate .install-complete; skip the sentinel and rely on the live-socket probe alone. - if (!isLegacy) { - const completePath = joinRemotePath(host, dir, model.installCompleteFilename) - const completeProbe = await execHostCommand( - conn, - host, - probeFileExistsCommand(host, completePath) - ).catch((err) => { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return 'PARTIAL' - }) - if (completeProbe.trim() !== 'COMPLETE') { - // Crashed-install partial; leave for the next deploy to recover. - return false + const completePath = joinRemotePath(host, dir, model.installCompleteFilename) + const completeProbe = await execHostCommand( + conn, + host, + probeFileExistsCommand(host, completePath) + ).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err } + return 'PARTIAL' + }) + if (completeProbe.trim() !== 'COMPLETE') { + // Crashed-install partial; leave for the next deploy to recover. + return false } return !(await options.isDirLive(dir)) } - -/** - * The relay's GC, bound to its own namespace. A version dir goes only on an `exited` verdict - * (relay-version-dir-liveness.ts), and the previous completed build is pinned (design D5). - */ -export async function gcOldRelayVersions( - conn: RemoteInstallTransport, - remoteHome: string, - currentDirAbsPath: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - /** Host Node that runs the connect probe once a recorded relay PID is dead. */ - nodePath?: string - /** - * Cache entries this connection depends on, whether or not it links to them. Also the gate: - * a caller that could not compute a key is not using the shared-cache model on this host, and - * a pass only ever collects what its own model created (see `remote-install-model.ts`). - */ - nativeDepsCacheKeys?: readonly string[] - } -): Promise { - await gcOldRemoteInstallVersions(conn, RELAY_INSTALL_MODEL, remoteHome, currentDirAbsPath, host, { - ...options, - resolveExtraPinnedDirNames: async () => { - const previous = await findPreviousRemoteInstall( - conn, - host, - joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR), - RELAY_INSTALL_MODEL, - remoteBasename(currentDirAbsPath, host) - ) - // Why null rather than a guess: without the order, any candidate could be the previous build. - if (previous.state !== 'ok') { - return null - } - return previous.dirName ? [previous.dirName] : [] - }, - isDirLive: async (dir) => - (await probeRelayVersionDirLiveness(conn, dir, host, options)) !== 'exited' - }) - // Why after and not before: version-dir removal is what turns a cache entry unreferenced, so - // running it second lets one pass reclaim both instead of leaving the tree for the next connect. - if (options?.nativeDepsCacheKeys?.length) { - await gcRelayNativeDepsCache(conn, host, remoteHome, { - pinnedKeys: options.nativeDepsCacheKeys - }).catch((err) => { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - }) - } -} - -/** GC deletes a relay version dir only on `exited`; see relay-version-dir-liveness.ts. */ -export async function probeRelayVersionDirLiveness( - conn: RemoteInstallTransport, - dir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - nodePath?: string - } -): Promise { - try { - const windows = - isWindowsRemoteHost(host) && options?.windowsNodePath - ? { - nodePath: options.windowsNodePath, - pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) => - windowsRelayPipePathsForSocketName(host, dir, sockName) - ) - } - : undefined - const out = await execHostCommand( - conn, - host, - relayVersionDirLivenessCommand(host, dir, { nodePath: options?.nodePath, windows }) - ) - return parseRelayVersionDirLiveness(out) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - // Why: an unanswered probe observes nothing; it never authorizes deletion. - return 'unverifiable' - } -} diff --git a/src/main/ssh/remote-install-model.ts b/src/main/ssh/remote-install-model.ts index f547c02dd10..2d012414371 100644 --- a/src/main/ssh/remote-install-model.ts +++ b/src/main/ssh/remote-install-model.ts @@ -100,6 +100,12 @@ export function remoteInstallVersionDirRegex(model: RemoteInstallModel): RegExp return new RegExp(`^${model.dirPrefix}-(${VERSION_PATTERN})$`) } +/** A strictly named leftover of one model's interrupted GC rename; safe to delete on sight. */ +export function remoteInstallGcTombstoneRegex(model: RemoteInstallModel): RegExp { + assertSafeDirPrefix(model.dirPrefix) + return new RegExp(`^${model.dirPrefix}-(${VERSION_PATTERN})${TOMBSTONE_PATTERN}$`) +} + /** What the remote listing is allowed to return: live dirs plus their tombstones. */ export function remoteInstallListingRegexSource(model: RemoteInstallModel): string { assertSafeDirPrefix(model.dirPrefix) diff --git a/src/main/ssh/remote-install-previous-version.test.ts b/src/main/ssh/remote-install-previous-version.test.ts deleted file mode 100644 index e64fdb72ede..00000000000 --- a/src/main/ssh/remote-install-previous-version.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn() -})) - -import type { SshConnection } from './ssh-connection' -import { gcOldRelayVersions } from './remote-install-gc' -import { ORCAD_INSTALL_MODEL, RELAY_INSTALL_MODEL } from './remote-install-model' -import { - listCompletedInstallsNewestFirstCommand, - parseCompletedInstallsNewestFirst, - REMOTE_INSTALL_ORDER_OK -} from './remote-install-previous-version' -import { execCommand } from './ssh-relay-deploy-helpers' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all connection access is replaced by execCommand's mock. -const conn = {} as SshConnection -const host = getRemoteHostPlatform('linux-x64') -const mockExec = vi.mocked(execCommand) - -describe('parseCompletedInstallsNewestFirst', () => { - it('keeps only this model’s version dirs, in host order', () => { - const output = [ - '/h/.orca-remote/relay-0.2.0+bbb/.install-complete', - '/h/.orca-remote/orcad-0.2.0+bbb/.install-complete', - '/h/.orca-remote/relay-0.1.0+aaa/.install-complete', - REMOTE_INSTALL_ORDER_OK - ].join('\n') - expect(parseCompletedInstallsNewestFirst(output, RELAY_INSTALL_MODEL)).toEqual([ - 'relay-0.2.0+bbb', - 'relay-0.1.0+aaa' - ]) - }) - - it('is null when the host did not finish the listing', () => { - expect( - parseCompletedInstallsNewestFirst( - '/h/.orca-remote/relay-0.1.0+aaa/.install-complete', - RELAY_INSTALL_MODEL - ) - ).toBeNull() - }) -}) - -const posixOnly = process.platform === 'win32' ? describe.skip : describe - -posixOnly('listCompletedInstallsNewestFirstCommand (real shell)', () => { - let base: string - beforeEach(() => { - base = mkdtempSync(join(tmpdir(), 'install-order-')) - }) - afterEach(() => { - rmSync(base, { recursive: true, force: true }) - }) - - function install(name: string, mtimeSeconds: number, complete = true): void { - mkdirSync(join(base, name)) - if (complete) { - const marker = join(base, name, '.install-complete') - writeFileSync(marker, '') - utimesSync(marker, mtimeSeconds, mtimeSeconds) - } - } - - function run(): string[] | null { - const out = execFileSync( - '/bin/sh', - ['-c', listCompletedInstallsNewestFirstCommand(host, base, RELAY_INSTALL_MODEL)], - { encoding: 'utf8' } - ) - return parseCompletedInstallsNewestFirst(out, RELAY_INSTALL_MODEL) - } - - it('orders completed installs by marker mtime and skips torn ones', () => { - install('relay-0.1.0+aaa', 1_000) - install('relay-0.3.0+ccc', 3_000) - install('relay-0.2.0+bbb', 2_000) - install('relay-0.4.0+ddd', 4_000, false) - install('orcad-0.9.0+eee', 9_000) - - expect(run()).toEqual(['relay-0.3.0+ccc', 'relay-0.2.0+bbb', 'relay-0.1.0+aaa']) - }) - - it('answers an empty order for a base without completed installs', () => { - expect(run()).toEqual([]) - }) - - it('is scoped to the model prefix', () => { - install('orcad-0.9.0+eee', 9_000) - const out = execFileSync( - '/bin/sh', - ['-c', listCompletedInstallsNewestFirstCommand(host, base, ORCAD_INSTALL_MODEL)], - { encoding: 'utf8' } - ) - expect(parseCompletedInstallsNewestFirst(out, ORCAD_INSTALL_MODEL)).toEqual(['orcad-0.9.0+eee']) - }) -}) - -describe('relay GC keeps the previous build', () => { - beforeEach(() => { - mockExec.mockReset() - mockExec.mockResolvedValue('') - }) - - it('never probes or removes the most recent other completed install', async () => { - mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\n') - .mockResolvedValueOnce( - [ - '/home/u/.orca-remote/relay-0.2.0+bbb/.install-complete', - '/home/u/.orca-remote/relay-0.1.0+aaa/.install-complete', - REMOTE_INSTALL_ORDER_OK - ].join('\n') - ) - - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.2.0+bbb', host) - - expect(mockExec).toHaveBeenCalledTimes(2) - }) - - it('deletes nothing when the host cannot report install order', async () => { - mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.0.9+zzz\n') - .mockRejectedValueOnce( - Object.assign(new Error('ls failed'), { sshChannelCloseConfirmed: true }) - ) - - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.2.0+bbb', host) - - expect(mockExec).toHaveBeenCalledTimes(2) - }) - - it('keeps an old build whose liveness probe times out', async () => { - mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\n') - .mockResolvedValueOnce(`relay-0.1.5+fff\n${REMOTE_INSTALL_ORDER_OK}`) - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('COMPLETE') - .mockRejectedValueOnce( - Object.assign(new Error('SSH command timed out'), { sshChannelCloseConfirmed: true }) - ) - - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.2.0+bbb', host, { - nodePath: '/usr/bin/node' - }) - - const commands = mockExec.mock.calls.map(([, command]) => command) - expect(commands[4]).toContain('.relay-pid') - expect(commands.some((command) => command.includes('gc-claim'))).toBe(false) - expect(mockExec).toHaveBeenCalledTimes(5) - }) -}) diff --git a/src/main/ssh/remote-install-previous-version.ts b/src/main/ssh/remote-install-previous-version.ts deleted file mode 100644 index c66e0a5c1a8..00000000000 --- a/src/main/ssh/remote-install-previous-version.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * The version dir a model ran before the current one, pinned against GC like orcad's - * rollback target (design D5 "plus the relay's previous version as a pin"). - * - * The relay keeps no activation record, so "previous" is the most recently completed install - * of the same model other than the current one, by `.install-complete` mtime. - */ -import type { RemoteInstallTransport } from './remote-install-transport' -import { shellEscape } from './ssh-connection-utils' -import { execCommand } from './ssh-relay-deploy-helpers' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { - remoteInstallGcPermits, - remoteInstallVersionDirRegex, - type RemoteInstallModel -} from './remote-install-model' -import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' - -export const REMOTE_INSTALL_ORDER_OK = 'ORCA_INSTALL_ORDER_OK' - -export function listCompletedInstallsNewestFirstCommand( - host: RemoteHostPlatform, - baseDir: string, - model: RemoteInstallModel -): string { - const prefix = `${model.dirPrefix}-` - if (isWindowsRemoteHost(host)) { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - `$base = ${powerShellLiteral(baseDir)}`, - 'if (Test-Path -LiteralPath $base -PathType Container) {', - `Get-ChildItem -LiteralPath $base -Directory -Filter '${prefix}*' | ForEach-Object { ` + - `$marker = Join-Path $_.FullName ${powerShellLiteral(model.installCompleteFilename)}; ` + - 'if (Test-Path -LiteralPath $marker -PathType Leaf) { Get-Item -LiteralPath $marker } ' + - '} | Sort-Object LastWriteTimeUtc -Descending | ForEach-Object { $_.Directory.Name }', - '}', - `'${REMOTE_INSTALL_ORDER_OK}'` - ].join('\n') - ) - } - return [ - `base=${shellEscape(baseDir)}`, - `set -- "$base"/${prefix}*/${shellEscape(model.installCompleteFilename)}`, - `[ -e "$1" ] || { echo ${REMOTE_INSTALL_ORDER_OK}; exit 0; }`, - 'ls -1t -- "$@" || exit 1', - `echo ${REMOTE_INSTALL_ORDER_OK}` - ].join('\n') -} - -/** Dir names newest first, or null when the host could not answer. */ -export function parseCompletedInstallsNewestFirst( - output: string, - model: RemoteInstallModel -): string[] | null { - const lines = output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - if (!lines.includes(REMOTE_INSTALL_ORDER_OK)) { - return null - } - const versionDirRegex = remoteInstallVersionDirRegex(model) - const names: string[] = [] - for (const line of lines) { - // POSIX prints `//.install-complete`; PowerShell prints ``. - const segments = line.split(/[\\/]/) - const name = segments.length > 1 ? segments.at(-2) : segments[0] - if (name && versionDirRegex.test(name) && remoteInstallGcPermits(model, name)) { - names.push(name) - } - } - return names -} - -export type PreviousInstallResult = { state: 'ok'; dirName: string | null } | { state: 'unknown' } - -export async function findPreviousRemoteInstall( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - baseDir: string, - model: RemoteInstallModel, - currentDirName: string -): Promise { - let output: string - try { - output = await execCommand( - conn, - listCompletedInstallsNewestFirstCommand(host, baseDir, model), - { - wrapCommand: host.commandDialect !== 'powershell' - } - ) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return { state: 'unknown' } - } - const ordered = parseCompletedInstallsNewestFirst(output, model) - if (!ordered) { - return { state: 'unknown' } - } - return { state: 'ok', dirName: ordered.find((name) => name !== currentDirName) ?? null } -} diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/remote-versioned-install.test.ts similarity index 74% rename from src/main/ssh/ssh-relay-versioned-install.test.ts rename to src/main/ssh/remote-versioned-install.test.ts index a3b697ec083..1b7953246e9 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/remote-versioned-install.test.ts @@ -15,20 +15,16 @@ vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -// The previous-build pin has its own tests; here it names a build that is never a candidate. -vi.mock('./remote-install-previous-version', () => ({ - findPreviousRemoteInstall: vi.fn().mockResolvedValue({ state: 'ok', dirName: 'relay-0.1.0+fff' }) -})) - import { existsSync, readFileSync } from 'node:fs' import { readLocalFullVersion, - computeRemoteRelayDir, - isRelayAlreadyInstalled, + isRemoteInstallComplete, finalizeInstall, - abandonInstall, - gcOldRelayVersions -} from './ssh-relay-versioned-install' + abandonInstall +} from './remote-versioned-install' +import { gcOldRemoteInstallVersions } from './remote-install-gc' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { acquireInstallLock } from './ssh-relay-install-lock' import { isRelayGcClaimed, @@ -38,7 +34,7 @@ import { waitForRelayGcClaimRelease } from './ssh-relay-gc-claim' import { execCommand } from './ssh-relay-deploy-helpers' -import { getRemoteHostPlatform } from './ssh-remote-platform' +import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' const conn = {} as SshConnection @@ -74,32 +70,24 @@ describe('readLocalFullVersion', () => { }) }) -describe('computeRemoteRelayDir', () => { - it('joins remoteHome with .orca-remote and the version-keyed dir name', () => { - expect(computeRemoteRelayDir('/home/u', '0.1.0+abc')).toBe( - '/home/u/.orca-remote/relay-0.1.0+abc' - ) - }) -}) - -describe('isRelayAlreadyInstalled', () => { +describe('isRemoteInstallComplete', () => { beforeEach(() => { vi.clearAllMocks() }) it('returns true only when the OK probe succeeds', async () => { mockExec.mockResolvedValueOnce('OK') - expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(true) + expect(await isRemoteInstallComplete(conn, ORCAD_INSTALL_MODEL, '/r')).toBe(true) }) it('returns false when the probe reports MISSING', async () => { mockExec.mockResolvedValueOnce('MISSING') - expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) + expect(await isRemoteInstallComplete(conn, ORCAD_INSTALL_MODEL, '/r')).toBe(false) }) it('returns false on exec error', async () => { mockExec.mockRejectedValueOnce(new Error('boom')) - expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) + expect(await isRemoteInstallComplete(conn, ORCAD_INSTALL_MODEL, '/r')).toBe(false) }) it('does not convert an aborted install probe into a missing install', async () => { @@ -110,7 +98,9 @@ describe('isRelayAlreadyInstalled', () => { }) await expect( - isRelayAlreadyInstalled(conn, '/r', undefined, { signal: controller.signal }) + isRemoteInstallComplete(conn, ORCAD_INSTALL_MODEL, '/r', undefined, { + signal: controller.signal + }) ).rejects.toMatchObject({ name: 'AbortError' }) }) @@ -120,7 +110,7 @@ describe('isRelayAlreadyInstalled', () => { }) mockExec.mockRejectedValueOnce(sessionLimitError) - await expect(isRelayAlreadyInstalled(conn, '/r')).resolves.toBe(false) + await expect(isRemoteInstallComplete(conn, ORCAD_INSTALL_MODEL, '/r')).resolves.toBe(false) }) it('rethrows SSH session-limit errors in strict mode', async () => { @@ -130,19 +120,22 @@ describe('isRelayAlreadyInstalled', () => { mockExec.mockRejectedValueOnce(sessionLimitError) await expect( - isRelayAlreadyInstalled(conn, '/r', undefined, { rethrowSessionLimitErrors: true }) + isRemoteInstallComplete(conn, ORCAD_INSTALL_MODEL, '/r', undefined, { + rethrowSessionLimitErrors: true + }) ).rejects.toBe(sessionLimitError) }) - it('checks every relay runtime artifact and .install-complete', async () => { + it('remote completeness requires every model artifact and the install sentinel', async () => { mockExec.mockResolvedValueOnce('OK') - await isRelayAlreadyInstalled(conn, '/r') + const host = getRemoteHostPlatform('linux-x64') + await isRemoteInstallComplete(conn, ORCAD_INSTALL_MODEL, '/r', host) const cmd = mockExec.mock.calls.at(-1)?.[1] ?? '' - expect(cmd).toContain('relay.js') - expect(cmd).toContain('relay-watcher.js') - expect(cmd).toContain('relay-ai-vault-service.js') - expect(cmd).toContain('managed-hook-runtime.js') - expect(cmd).toContain('.install-complete') + expect(ORCAD_INSTALL_MODEL.requiredArtifacts(host).length).toBeGreaterThan(0) + for (const artifact of ORCAD_INSTALL_MODEL.requiredArtifacts(host)) { + expect(cmd).toContain(`/r/${artifact}`) + } + expect(cmd).toContain(ORCAD_INSTALL_MODEL.installCompleteFilename) }) }) @@ -464,7 +457,25 @@ describe('relay GC claim', () => { }) }) -describe('gcOldRelayVersions', () => { +describe('gcOldRemoteInstallVersions', () => { + // Stands in for orcad's liveness probe: only DEAD frees a dir, and an unanswered probe keeps it. + const collectOldVersions = ( + remoteHome = '/home/u', + currentDir = '/home/u/.orca-remote/orcad-0.1.0+bbb', + host?: RemoteHostPlatform + ): Promise => + gcOldRemoteInstallVersions(conn, ORCAD_INSTALL_MODEL, remoteHome, currentDir, host, { + isDirLive: async (dir) => { + const answer = await mockExec(conn, `liveness ${dir}`).catch((err: unknown) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return 'UNVERIFIABLE' + }) + return answer.trim() !== 'DEAD' + } + }) + beforeEach(() => { vi.clearAllMocks() mockExec.mockReset() @@ -472,7 +483,7 @@ describe('gcOldRelayVersions', () => { it('removes a sibling that is complete, unlocked, and has no live socket', async () => { // ls listing - mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n') + mockExec.mockResolvedValueOnce('orcad-0.1.0+aaa\norcad-0.1.0+bbb\n') // For sibling "aaa": safety probes pass, then GC claims the install lock before removal. mockExec .mockResolvedValueOnce('OPEN') @@ -488,66 +499,66 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCmd).toContain('rm -rf') - expect(lastCmd).toContain('relay-0.1.0+aaa.gc-tombstone') + expect(lastCmd).toContain('orcad-0.1.0+aaa.gc-tombstone') const commands = mockExec.mock.calls.map(([, command]) => command) expect( - commands.some((command) => command === "rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") + commands.some((command) => command === "rm -rf '/home/u/.orca-remote/orcad-0.1.0+aaa'") ).toBe(false) }) - it('cleans only strict POSIX orphan tombstones even with no relay candidates', async () => { + it('cleans only strict POSIX orphan tombstones even with no candidates', async () => { mockExec .mockResolvedValueOnce( [ - 'relay-0.1.0+abc.gc-tombstone.123.456', - 'relay-0.1.0+abc.gc-tombstone.bad.456', - 'xrelay-0.1.0+abc.gc-tombstone.123.456', - 'relay-0.1.0+abc.gc-tombstone.123.456.extra', - 'relay-0.1.0+abc.gc-tombstone.123.456/child', + 'orcad-0.1.0+abc.gc-tombstone.123.456', + 'orcad-0.1.0+abc.gc-tombstone.bad.456', + 'xorcad-0.1.0+abc.gc-tombstone.123.456', + 'orcad-0.1.0+abc.gc-tombstone.123.456.extra', + 'orcad-0.1.0+abc.gc-tombstone.123.456/child', 'logs' ].join('\n') ) .mockResolvedValueOnce('') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const removeCommands = mockExec.mock.calls .map(([, command]) => command) .filter((command) => command.startsWith('rm -rf')) expect(removeCommands).toEqual([ - "rm -rf '/home/u/.orca-remote/relay-0.1.0+abc.gc-tombstone.123.456'" + "rm -rf '/home/u/.orca-remote/orcad-0.1.0+abc.gc-tombstone.123.456'" ]) }) - it('cleans only strict Windows orphan tombstones even with no relay candidates', async () => { + it('cleans only strict Windows orphan tombstones even with no candidates', async () => { const windows = getRemoteHostPlatform('win32-x64') mockExec .mockResolvedValueOnce( - 'relay-v0.1.0.gc-tombstone.123.456\nrelay-v0.1.0.gc-tombstone.latest.456\n' + 'orcad-v0.1.0.gc-tombstone.123.456\norcad-v0.1.0.gc-tombstone.latest.456\n' ) .mockResolvedValueOnce('') - await gcOldRelayVersions(conn, 'C:/Users/u', 'C:/Users/u/.orca-remote/relay-0.1.0+bbb', windows) + await collectOldVersions('C:/Users/u', 'C:/Users/u/.orca-remote/orcad-0.1.0+bbb', windows) const removeScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '') - expect(removeScript).toContain('relay-v0.1.0.gc-tombstone.123.456') - expect(removeScript).not.toContain('relay-v0.1.0.gc-tombstone.latest.456') + expect(removeScript).toContain('orcad-v0.1.0.gc-tombstone.123.456') + expect(removeScript).not.toContain('orcad-v0.1.0.gc-tombstone.latest.456') }) it('retries orphan tombstone cleanup on a later GC pass', async () => { - const tombstone = 'relay-0.1.0+abc.gc-tombstone.123.456' + const tombstone = 'orcad-0.1.0+abc.gc-tombstone.123.456' mockExec .mockResolvedValueOnce(tombstone) .mockRejectedValueOnce(new Error('remove failed')) .mockResolvedValueOnce(tombstone) .mockResolvedValueOnce('') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() + await collectOldVersions() const removeCommands = mockExec.mock.calls .map(([, command]) => command) @@ -557,7 +568,7 @@ describe('gcOldRelayVersions', () => { it('retries only an unknown claim release and stops after observing a lost generation', async () => { mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('orcad-0.1.0+aaa\n') .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('DEAD') @@ -572,7 +583,7 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('LOST') .mockResolvedValueOnce('') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const releaseCommands = mockExec.mock.calls .map(([, command]) => command) @@ -581,32 +592,32 @@ describe('gcOldRelayVersions', () => { }) it('skips siblings that are missing .install-complete (mid-install or partial)', async () => { - mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + mockExec.mockResolvedValueOnce('orcad-0.1.0+aaa\n') mockExec .mockResolvedValueOnce('OPEN') // not locked .mockResolvedValueOnce('PARTIAL') // missing .install-complete - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) }) it('skips siblings whose .install-lock is held and fresh', async () => { - mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + mockExec.mockResolvedValueOnce('orcad-0.1.0+aaa\n') mockExec.mockResolvedValueOnce('LOCKED') // isLockStale: age ~now → not stale. mockExec.mockResolvedValueOnce('0\n') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) }) it('removes a sibling with a stale lock + .install-complete (rm-lock failed mid-finalize)', async () => { - mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + mockExec.mockResolvedValueOnce('orcad-0.1.0+aaa\n') mockExec.mockResolvedValueOnce('LOCKED') // isLockStale: age well above the stale window → stale. mockExec.mockResolvedValueOnce(`${21 * 60}\n`) mockExec.mockResolvedValueOnce('COMPLETE') // .install-complete present - mockExec.mockResolvedValueOnce('DEAD') // socket probe + mockExec.mockResolvedValueOnce('DEAD') // liveness probe mockExec.mockResolvedValueOnce('OK') // stable sibling GC claim mockExec.mockResolvedValueOnce('') // write claim ownership token mockExec.mockResolvedValueOnce('LOCKED') @@ -617,65 +628,37 @@ describe('gcOldRelayVersions', () => { mockExec.mockResolvedValueOnce('MOVED') mockExec.mockResolvedValueOnce('RELEASED') // release sibling claim mockExec.mockResolvedValueOnce('') // remove tombstone - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCmd).toContain('rm -rf') - expect(lastCmd).toContain('relay-0.1.0+aaa') + expect(lastCmd).toContain('orcad-0.1.0+aaa') }) - it('GCs a legacy relay-v0.1.0 dir whose daemon is dead (no .install-complete required)', async () => { - mockExec.mockResolvedValueOnce('relay-v0.1.0\n') - mockExec.mockResolvedValueOnce('OPEN') // not locked - mockExec.mockResolvedValueOnce('DEAD') // socket probe (no completeProbe — legacy) - mockExec.mockResolvedValueOnce('OK') // GC claims candidate - mockExec.mockResolvedValueOnce('') - mockExec.mockResolvedValueOnce('OPEN') - mockExec.mockResolvedValueOnce('DEAD') - mockExec.mockResolvedValueOnce('OWNED') - mockExec.mockResolvedValueOnce('MOVED') - mockExec.mockResolvedValueOnce('RELEASED') - mockExec.mockResolvedValueOnce('') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') - const cmds = mockExec.mock.calls.map(([, c]) => c) - expect(cmds.some((c) => c.includes('rm -rf') && c.includes('relay-v0.1.0'))).toBe(true) - // critically: no .install-complete probe on legacy dirs - expect(cmds.some((c) => c.includes('.install-complete'))).toBe(false) - }) - - it('keeps a legacy relay-v0.1.0 dir whose daemon is still serving', async () => { - mockExec.mockResolvedValueOnce('relay-v0.1.0\n') - mockExec.mockResolvedValueOnce('OPEN') - mockExec.mockResolvedValueOnce('ALIVE') // socket alive → keep - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') - const cmds = mockExec.mock.calls.map(([, c]) => c) - expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) - }) - - it('skips siblings with a live relay-*.sock', async () => { - mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + it('skips siblings whose liveness probe reports them live', async () => { + mockExec.mockResolvedValueOnce('orcad-0.1.0+aaa\n') mockExec .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('ALIVE') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) }) it('keeps a sibling when the install-lock probe fails or returns unexpected output', async () => { mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + .mockResolvedValueOnce('orcad-0.1.0+aaa\norcad-0.1.0+ccc\n') .mockRejectedValueOnce(new Error('lock probe failed')) .mockResolvedValueOnce('INCONCLUSIVE') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() expect(mockExec).toHaveBeenCalledTimes(3) }) - it('keeps a sibling when the relay-liveness probe fails or returns unexpected output', async () => { + it('keeps a sibling when the liveness probe fails or returns unexpected output', async () => { mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + .mockResolvedValueOnce('orcad-0.1.0+aaa\norcad-0.1.0+ccc\n') .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockRejectedValueOnce(new Error('liveness probe failed')) @@ -683,55 +666,19 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('INCONCLUSIVE') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() expect(mockExec).toHaveBeenCalledTimes(7) }) - it('probes Windows GC liveness by connecting to named pipes, not process command lines', async () => { - const windows = getRemoteHostPlatform('win32-x64') - mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('COMPLETE') - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('OK') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('COMPLETE') - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('OWNED') - .mockResolvedValueOnce('MOVED') - .mockResolvedValueOnce('RELEASED') - .mockResolvedValueOnce('') - - await gcOldRelayVersions( - conn, - 'C:/Users/u', - 'C:/Users/u/.orca-remote/relay-0.1.0+bbb', - windows, - { - windowsNodePath: 'C:/Program Files/nodejs/node.exe', - windowsSockNames: ['relay-target.sock'] - } - ) - - const livenessCommand = mockExec.mock.calls[3]?.[1] ?? '' - const script = decodePowerShellCommand(livenessCommand ?? '') - expect(script).toContain('net.connect(pipe)') - expect(script).toContain('.windows-active-pipe-') - expect(script).toContain('\\\\.\\pipe\\orca-relay-') - expect(script).not.toContain('Win32_Process') - }) - it('does not consider the current dir as a GC candidate', async () => { - mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+aaa') + mockExec.mockResolvedValueOnce('orcad-0.1.0+aaa\n') + await collectOldVersions('/home/u', '/home/u/.orca-remote/orcad-0.1.0+aaa') expect(mockExec.mock.calls.length).toBe(1) // only the listing }) - it('ignores entries that do not match the relay version dir regex (allowlist)', async () => { - mockExec.mockResolvedValueOnce('logs\nbackup\nrelay-0.1.0+aaa\n') + it('ignores entries that do not match the model version dir regex (allowlist)', async () => { + mockExec.mockResolvedValueOnce('logs\nbackup\norcad-0.1.0+aaa\n') mockExec .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') @@ -745,34 +692,34 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('MOVED') .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const cmds = mockExec.mock.calls.map(([, c]) => c) const rmCmds = cmds.filter((c) => c.startsWith('rm') && c.includes('gc-tombstone')) expect(rmCmds).toHaveLength(1) - expect(rmCmds[0]).toContain('relay-0.1.0+aaa') + expect(rmCmds[0]).toContain('orcad-0.1.0+aaa') expect(rmCmds[0]).not.toContain('logs') expect(rmCmds[0]).not.toContain('backup') }) it('does not remove a candidate claimed by repair after the initial lock probe', async () => { mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('orcad-0.1.0+aaa\n') .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') - .mockResolvedValueOnce('DEAD') // socket probe + .mockResolvedValueOnce('DEAD') // liveness probe .mockResolvedValueOnce('OK') // GC sibling claim .mockResolvedValueOnce('') // write claim ownership token .mockResolvedValueOnce('LOCKED') // repair won before the safety recheck .mockResolvedValueOnce('0') .mockResolvedValueOnce('RELEASED') // release GC sibling claim - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const commands = mockExec.mock.calls.map(([, command]) => command) expect( commands.some( (command) => - command.startsWith("rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") && + command.startsWith("rm -rf '/home/u/.orca-remote/orcad-0.1.0+aaa'") && !command.includes('.install-lock') ) ).toBe(false) @@ -780,7 +727,7 @@ describe('gcOldRelayVersions', () => { it('does not move a candidate after losing the sibling claim generation', async () => { mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('orcad-0.1.0+aaa\n') .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('DEAD') @@ -792,7 +739,7 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('LOST') .mockResolvedValueOnce('LOST') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const commands = mockExec.mock.calls.map(([, command]) => command) expect(commands.some((command) => command.startsWith('mv '))).toBe(false) @@ -800,7 +747,7 @@ describe('gcOldRelayVersions', () => { it('releases the GC claim after a confirmed move failure', async () => { mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('orcad-0.1.0+aaa\n') .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('DEAD') @@ -813,11 +760,11 @@ describe('gcOldRelayVersions', () => { .mockRejectedValueOnce(new Error('move failed')) .mockResolvedValueOnce('RELEASED') - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await collectOldVersions() const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCommand).toContain('rm -rf') - expect(lastCommand).toContain('relay-0.1.0+aaa.gc-claim') + expect(lastCommand).toContain('orcad-0.1.0+aaa.gc-claim') }) it('keeps the GC claim when remote move termination is unconfirmed', async () => { @@ -825,7 +772,7 @@ describe('gcOldRelayVersions', () => { sshChannelCloseConfirmed: false }) mockExec - .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('orcad-0.1.0+aaa\n') .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('DEAD') @@ -837,13 +784,11 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('OWNED') .mockRejectedValueOnce(unconfirmed) - await expect( - gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') - ).rejects.toBe(unconfirmed) + await expect(collectOldVersions()).rejects.toBe(unconfirmed) const releaseCommands = mockExec.mock.calls .map(([, command]) => command) - .filter((command) => command.includes('relay-0.1.0+aaa.gc-claim') && command.startsWith('rm')) + .filter((command) => command.includes('orcad-0.1.0+aaa.gc-claim') && command.startsWith('rm')) expect(releaseCommands).toHaveLength(0) }) }) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/remote-versioned-install.ts similarity index 66% rename from src/main/ssh/ssh-relay-versioned-install.ts rename to src/main/ssh/remote-versioned-install.ts index 6fa045ee48b..f1a7f716033 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/remote-versioned-install.ts @@ -1,8 +1,6 @@ -// Versioned-install plumbing for the remote relay: each (RELAY_VERSION + content-hash) -// bundle installs into its own immutable dir (like VS Code's ~/.vscode-server/bin//) -// so an in-memory daemon never serves new clients off overwritten on-disk code. -// -// See: docs/ssh-relay-versioned-install-dirs.md +// Versioned-install plumbing for orcad on a remote host: each content-hashed bundle installs +// into its own immutable dir, so an in-memory daemon never serves new clients off overwritten +// on-disk code. import { join } from 'node:path' import { existsSync, readFileSync } from 'node:fs' @@ -10,14 +8,15 @@ import type { RemoteInstallTransport } from './remote-install-transport' import { execHostCommand } from './ssh-relay-host-exec' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { remoteInstallDirSegments } from './ssh-relay-install-namespace' -import { RELAY_INSTALL_MODEL, type RemoteInstallModel } from './remote-install-model' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { remoteInstallDirName, type RemoteInstallModel } from './remote-install-model' import { probeRemoteInstallCompleteCommand, removeRemoteTreeCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' import { + assertSafeRemotePathSegment, getRemoteHostPlatform, joinRemotePath, type RemoteHostPlatform, @@ -28,7 +27,7 @@ import { isSshSessionLimitError } from './ssh-session-limit-error' const INSTALL_COMPLETE_NAME = '.install-complete' const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') -type RelayInstalledProbeOptions = { +type RemoteInstallProbeOptions = { rethrowSessionLimitErrors?: boolean signal?: AbortSignal } @@ -56,20 +55,7 @@ export function readLocalFullVersion(localRelayDir: string): string { return v } -/** - * Compute the absolute remote install directory for a given content-hashed - * version. The format is `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}`. - */ -export function computeRemoteRelayDir( - remoteHome: string, - fullVersion: string, - pathFlavor: RemotePathFlavor = 'posix' -): string { - // Why: shell and SFTP-relative builders must derive the same validated segments or the namespaces diverge. - return computeRemoteInstallDir(RELAY_INSTALL_MODEL, remoteHome, fullVersion, pathFlavor) -} - -/** The model-parameterized form of `computeRemoteRelayDir`. */ +/** `${remoteHome}/.orca-remote/-` for one install model. */ export function computeRemoteInstallDir( model: RemoteInstallModel, remoteHome: string, @@ -87,33 +73,19 @@ export function computeRemoteInstallDir( ) } -/** - * Probe for relay.js, its watcher, the managed-hook runtime, and the - * completion sentinel. Any missing artifact forces a complete re-deploy. - */ -export async function isRelayAlreadyInstalled( - conn: RemoteInstallTransport, - remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: RelayInstalledProbeOptions -): Promise { - return isRemoteInstallComplete(conn, RELAY_INSTALL_MODEL, remoteRelayDir, host, options) -} - -/** The model-parameterized form: each model probes for its own artifact list. */ +/** Every artifact the model ships plus its completion sentinel; anything missing forces a re-deploy. */ export async function isRemoteInstallComplete( conn: RemoteInstallTransport, model: RemoteInstallModel, remoteInstallDir: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: RelayInstalledProbeOptions + options?: RemoteInstallProbeOptions ): Promise { - const remoteRelayDir = remoteInstallDir try { const probe = await execHostCommand( conn, host, - probeRemoteInstallCompleteCommand(host, remoteRelayDir, [ + probeRemoteInstallCompleteCommand(host, remoteInstallDir, [ ...model.requiredArtifacts(host), model.installCompleteFilename ]), @@ -182,15 +154,19 @@ export async function abandonInstall( ) } -/** - * Garbage-collect old version directories: remove an idle, fully-installed, - * unlocked sibling version dir (never the current one). Confirmed failures are - * best-effort; unconfirmed command termination stops later deployment commands. - */ -// Why re-exported rather than moved outright: deploy and the relay tests import the whole -// versioned-install surface from here, and the split exists for file size, not to redraw an API. -export { - gcOldRelayVersions, - gcOldRemoteInstallVersions, - type RemoteInstallGcOptions -} from './remote-install-gc' +/** Shell and SFTP-relative builders both go through here so their namespaces cannot drift. */ +function remoteInstallDirSegments( + model: RemoteInstallModel, + fullVersion: string, + pathFlavor: RemotePathFlavor +): string[] { + const segments = [RELAY_REMOTE_DIR, remoteInstallDirName(model, fullVersion)] + for (const segment of segments) { + assertSafeRemotePathSegment(segment, pathFlavor) + // Why: the version reaches logs and diagnostics, where an embedded CR/LF can forge lines. + if (segment.includes('\r') || segment.includes('\n')) { + throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`) + } + } + return segments +} diff --git a/src/main/ssh/sftp-stream-late-error.test.ts b/src/main/ssh/sftp-stream-late-error.test.ts index a05dc3bb174..71e7ecf9e81 100644 --- a/src/main/ssh/sftp-stream-late-error.test.ts +++ b/src/main/ssh/sftp-stream-late-error.test.ts @@ -5,7 +5,7 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import type { SFTPWrapper } from 'ssh2' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { uploadBuffer, uploadFile, writeStringViaSftp, writeStringsViaSftp } from './sftp-upload' +import { uploadBuffer, uploadFile, writeStringViaSftp } from './sftp-upload' import { writeRelayFile } from './ssh-relay-install-transfers' import type { SshConnection } from './ssh-connection' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -66,46 +66,6 @@ describe('late SFTP stream errors', () => { expect(() => stream.emit('error', sftpNoSuchFileError())).not.toThrow() }) - // The failure mode a per-file loop reintroduces: writeStringViaSftp removes its own - // session listener at each settle, so a session that ran N transfers ends up with zero - // listeners while it is still open and still able to deliver a STATUS reply. - it('does not throw when a session error arrives after a multi-file write settles', async () => { - const sftp = Object.assign(new EventEmitter(), { - createWriteStream: () => { - const stream = new PassThrough() - stream.resume() - return stream - }, - end: () => {} - }) as unknown as SFTPWrapper - - await writeStringsViaSftp({ sftp: () => Promise.resolve(sftp) }, [ - { path: '/home/user/.local/bin/orca', contents: '#!/bin/sh\n' }, - { path: '/home/user/.local/bin/orca.mjs', contents: 'export {}\n' } - ]) - - expect(() => sftp.emit('error', sftpNoSuchFileError())).not.toThrow() - }) - - it('still rejects a multi-file write with a session error raised during it', async () => { - const sftp = Object.assign(new EventEmitter(), { - createWriteStream: () => { - const stream = new PassThrough() - queueMicrotask(() => sftp.emit('error', sftpNoSuchFileError())) - return stream - }, - end: () => {} - }) as unknown as SFTPWrapper - - // The latch must sit behind the transfer's own prepended listener, or a real - // mid-transfer failure would be swallowed into a hang. - await expect( - writeStringsViaSftp({ sftp: () => Promise.resolve(sftp) }, [ - { path: '/home/user/.local/bin/orca', contents: '#!/bin/sh\n' } - ]) - ).rejects.toThrow('file does not exist') - }) - it('still rejects with the SFTP error when it arrives during the transfer', async () => { const stream = new PassThrough() stream.resume() diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index 89f6666fb28..9cb16bd82ff 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -5,11 +5,7 @@ import { isAbsolute, join as pathJoin, relative, sep } from 'node:path' import { finished } from 'node:stream/promises' import { promisify } from 'node:util' import type { SFTPWrapper } from 'ssh2' -import { - latchLateSftpSessionErrors, - latchLateSftpStreamErrors, - type SftpStreamErrorLatch -} from './sftp-stream-late-error' +import { latchLateSftpStreamErrors, type SftpStreamErrorLatch } from './sftp-stream-late-error' export function mkdirSftp( sftp: SFTPWrapper, @@ -187,29 +183,6 @@ export function writeStringViaSftp( }) } -/** - * Write several files over one SFTP session, ending it when they are all done. - * - * Owns the session's late-error latch, which is why a caller must not hand-roll this - * loop: `writeStringViaSftp` drops its own session listener at each settle, so between - * files and after the last one the emitter would carry none, and a late STATUS reply - * throws synchronously out of ssh2's parser into main (#15479). - */ -export async function writeStringsViaSftp( - conn: { sftp(): Promise }, - files: readonly { path: string; contents: string }[] -): Promise { - const sftp = await conn.sftp() - latchLateSftpSessionErrors(sftp) - try { - for (const file of files) { - await writeStringViaSftp(sftp, file.path, file.contents) - } - } finally { - sftp.end() - } -} - export async function uploadDirectory( sftp: SFTPWrapper, localDir: string, diff --git a/src/main/ssh/ssh-host-key-decision.test.ts b/src/main/ssh/ssh-host-key-decision.test.ts index 19472eb27a1..1724b420584 100644 --- a/src/main/ssh/ssh-host-key-decision.test.ts +++ b/src/main/ssh/ssh-host-key-decision.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - decideHostKey, - strictestHostKeyChecking, - type HostKeyDecisionInput -} from './ssh-host-key-decision' +import { decideHostKey, type HostKeyDecisionInput } from './ssh-host-key-decision' function input(overrides: Partial = {}): HostKeyDecisionInput { return { @@ -395,39 +391,3 @@ describe('deciding what to do with a presented host key', () => { } }) }) - -/** - * Merging a user-resolved policy with a separately-probed site policy. - * - * Only reachable on the `-F` path, where OpenSSH ignores /etc/ssh/ssh_config so the per-user - * resolution cannot represent a site policy at all. Before this, being unable to see the site policy - * meant refusing every unknown host — which locked out anyone whose HOME diverges from their passwd - * home (devcontainers, `su`, Nix shells) with no override. - */ -describe('merging the user and site host key policies', () => { - it('keeps the user value when the site could not be read', () => { - // The blind case that still fails strict: a null probe must not invent a policy. - expect(strictestHostKeyChecking('ask', null)).toBe('ask') - expect(strictestHostKeyChecking(undefined, null)).toBe('ask') - }) - - it('takes a strict site policy over a laxer user value', () => { - expect(strictestHostKeyChecking('ask', 'yes')).toBe('yes') - expect(strictestHostKeyChecking('no', 'true')).toBe('true') - }) - - it('does not let a lax site policy loosen the user', () => { - // The direction that must never relax: a permissive site config cannot downgrade a user who - // asked for strict, or we end up laxer than ssh itself. - expect(strictestHostKeyChecking('yes', 'no')).toBe('yes') - expect(strictestHostKeyChecking('ask', 'off')).toBe('ask') - }) - - it('lets the site raise ask to accept-new', () => { - expect(strictestHostKeyChecking('ask', 'accept-new')).toBe('accept-new') - }) - - it('keeps a strict user value against accept-new', () => { - expect(strictestHostKeyChecking('yes', 'accept-new')).toBe('yes') - }) -}) diff --git a/src/main/ssh/ssh-host-key-decision.ts b/src/main/ssh/ssh-host-key-decision.ts index f0eb1afd2f5..a42f0463269 100644 --- a/src/main/ssh/ssh-host-key-decision.ts +++ b/src/main/ssh/ssh-host-key-decision.ts @@ -86,30 +86,6 @@ function keygenRemoveTarget(displayHost: string, port: number): string { const STRICT_VALUES = new Set(['true', 'yes', 'always']) const LAX_VALUES = new Set(['false', 'no', 'off']) -/** - * The stricter of a user-resolved and a site-resolved StrictHostKeyChecking. - * - * Needed only where the two were read separately: `ssh -F` makes OpenSSH ignore /etc/ssh/ssh_config, - * so the per-user resolution cannot represent a site policy and the site has to be probed on its - * own. Ordering is strict > accept-new > ask > lax, and anything unrecognised is left to the - * caller's own handling by falling through to the user value. - */ -export function strictestHostKeyChecking( - userValue: string | undefined, - siteValue: string | null -): string { - const user = userValue ?? 'ask' - if (siteValue === null) { - return user - } - const site = siteValue.trim().toLowerCase() - if (STRICT_VALUES.has(site) && !STRICT_VALUES.has(user.trim().toLowerCase())) { - return site - } - // A site that is lax never loosens a user who is not: refusing to write trust is the safe side. - return LAX_VALUES.has(site) ? user : site === 'accept-new' && user === 'ask' ? site : user -} - const CHANGED_KEY_HINT = 'If you rebuilt or reprovisioned this machine, remove the saved key and reconnect.' diff --git a/src/main/ssh/ssh-host-relay-endpoint-census.test.ts b/src/main/ssh/ssh-host-relay-endpoint-census.test.ts index 1c81bddec10..e1f05ef81ba 100644 --- a/src/main/ssh/ssh-host-relay-endpoint-census.test.ts +++ b/src/main/ssh/ssh-host-relay-endpoint-census.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshConnection } from './ssh-connection' -import type { RelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' +import { + parseRelayEndpointIncumbentProbe, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' import { getRemoteHostPlatform } from './ssh-remote-platform' const { execCommand, probeRelayEndpointIncumbent, countRelayEndpointPtys } = vi.hoisted(() => ({ @@ -16,7 +19,7 @@ vi.mock('./ssh-relay-endpoint-incumbent', async (importOriginal) => ({ probeRelayEndpointIncumbent })) -import { censusHostRelayEndpoints } from './ssh-host-relay-endpoint-census' +import { censusHostRelayEndpoints, readRelayEndpointProbe } from './ssh-host-relay-endpoint-census' import { RELAY_DAEMON_ARGV_COMMAND } from './ssh-relay-endpoint-runtime' let daemonArgv = '' @@ -167,3 +170,49 @@ describe('the host-side relay endpoint census', () => { expect(noPathNode).not.toHaveBeenCalled() }) }) + +describe('readRelayEndpointProbe', () => { + const probed = (lines: string[]) => + parseRelayEndpointIncumbentProbe( + sock(1), + ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') + ) + + it('reads a live relay that still owns PTYs as holding work', () => { + expect( + readRelayEndpointProbe( + probed([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=3669803 yes 13 11' + ]) + ) + ).toBe('holds-work') + }) + + it('reads a proven empty relay as idle', () => { + expect( + readRelayEndpointProbe( + probed(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) + ) + ).toBe('idle') + }) + + it('reads a socket proven to have no holder as idle', () => { + expect( + readRelayEndpointProbe(probed(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'])) + ).toBe('idle') + }) + + it('never reads an unanswered endpoint as idle', () => { + expect( + readRelayEndpointProbe( + probed(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + ).toBe('unverifiable') + expect( + readRelayEndpointProbe(probed(['PRESENT=no', 'LISTEN=absent', 'HOLDERS_SOURCE=lsof'])) + ).toBe('unverifiable') + }) +}) diff --git a/src/main/ssh/ssh-host-relay-endpoint-census.ts b/src/main/ssh/ssh-host-relay-endpoint-census.ts index 48d52ee2045..ff4de224960 100644 --- a/src/main/ssh/ssh-host-relay-endpoint-census.ts +++ b/src/main/ssh/ssh-host-relay-endpoint-census.ts @@ -11,8 +11,11 @@ import { shellEscape } from './ssh-connection-utils' import { RELAY_REMOTE_DIR } from './relay-protocol' import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit' import { execCommand } from './ssh-relay-deploy-helpers' -import { probeRelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' -import { classifySupersededRelay } from './ssh-relay-superseded-endpoints' +import { + isReapableRelayHusk, + probeRelayEndpointIncumbent, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' import { countRelayEndpointPtys } from './ssh-relay-endpoint-pty-count' import { readRelayDaemonRuntimes } from './ssh-relay-endpoint-runtime' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' @@ -111,20 +114,35 @@ async function classifyEndpoint( endpoint: string, signal: AbortSignal | undefined ): Promise<'idle' | 'live' | 'unverifiable'> { - let outcome: ReturnType + let reading: RelayEndpointProbeReading try { - outcome = classifySupersededRelay( + reading = readRelayEndpointProbe( await probeRelayEndpointIncumbent(conn, host, nodePath, endpoint, { signal }) ) } catch { - outcome = 'unverifiable' + reading = 'unverifiable' } - if (outcome === 'reap-candidate' || outcome === 'stale-endpoint-removed') { + if (reading === 'idle') { return 'idle' } const ptys = await countRelayEndpointPtys(conn, nodePath, endpoint, signal) if (ptys !== null) { return ptys > 0 ? 'live' : 'idle' } - return outcome === 'retained-live-work' ? 'live' : 'unverifiable' + return reading === 'holds-work' ? 'live' : 'unverifiable' +} + +type RelayEndpointProbeReading = 'idle' | 'holds-work' | 'unverifiable' + +/** `idle`: a socket nothing holds, or a relay proven to hold nothing but its own services. */ +export function readRelayEndpointProbe( + incumbent: RelayEndpointIncumbent +): RelayEndpointProbeReading { + if (incumbent.verdict === 'exited') { + return incumbent.socketPresent ? 'idle' : 'unverifiable' + } + if (incumbent.verdict !== 'live') { + return 'unverifiable' + } + return isReapableRelayHusk(incumbent) ? 'idle' : 'holds-work' } diff --git a/src/main/ssh/ssh-known-hosts-source.test.ts b/src/main/ssh/ssh-known-hosts-source.test.ts index 2515e7d511d..b7cbdf805c9 100644 --- a/src/main/ssh/ssh-known-hosts-source.test.ts +++ b/src/main/ssh/ssh-known-hosts-source.test.ts @@ -6,7 +6,6 @@ import { parseSshGOutput, type SshResolvedConfig } from './ssh-g-config-resoluti import { matchKnownHosts, readHostKeyType, type KnownHostsEntry } from './ssh-known-hosts' import { defaultKnownHostsFiles, - loadKnownHostsEntries, loadKnownHostsEvidence, resolveKnownHostsFiles, resolveKnownHostsLookupHost @@ -202,7 +201,11 @@ describe('parsing real ssh -G output', () => { }) }) -describe('loadKnownHostsEntries', () => { +// The caller runs `matchKnownHosts` once over the union, so a hit in any file wins. +const loadKnownHostsEntries = async (files: readonly string[]): Promise => + (await loadKnownHostsEvidence(files)).entries + +describe('the union of known_hosts entries', () => { it('unions the entries of every file', async () => { const root = await createRoot() const userFile = join(root, 'known_hosts') diff --git a/src/main/ssh/ssh-known-hosts-source.ts b/src/main/ssh/ssh-known-hosts-source.ts index f87b67660d4..68d9b6061ed 100644 --- a/src/main/ssh/ssh-known-hosts-source.ts +++ b/src/main/ssh/ssh-known-hosts-source.ts @@ -92,14 +92,6 @@ async function exists(path: string): Promise { ) } -/** - * The union across every file. The caller runs `matchKnownHosts` once over the result, so an exact - * hit in any file wins and a disagreeing entry in another file is not a mismatch. - */ -export async function loadKnownHostsEntries(files: readonly string[]): Promise { - return (await loadKnownHostsEvidence(files)).entries -} - export type KnownHostsEvidence = { entries: KnownHostsEntry[] /** diff --git a/src/main/ssh/ssh-relay-credential-mismatch-error.ts b/src/main/ssh/ssh-relay-credential-mismatch-error.ts index ab39f8b909d..0b6e2fcc22e 100644 --- a/src/main/ssh/ssh-relay-credential-mismatch-error.ts +++ b/src/main/ssh/ssh-relay-credential-mismatch-error.ts @@ -18,7 +18,3 @@ export class RelayCredentialMismatchError extends Error { ) } } - -export function isRelayCredentialMismatchError(err: unknown): err is RelayCredentialMismatchError { - return err instanceof RelayCredentialMismatchError -} diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts index 5885f7b7602..bf593a9c58a 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts @@ -1,21 +1,19 @@ /** - * The probe and reap scripts run on someone else's machine and decide whether a process is - * signalled, so the shell itself is the part worth testing for real. These cases run the + * The probe script runs on someone else's machine and decides whether a relay holds work, + * so the shell itself is the part worth testing for real. These cases run the * generated scripts through /bin/sh against real unix sockets and real processes. */ import { execFile, spawn, type ChildProcess } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { isReapableRelayHusk, - mayLaunchOverRelayEndpoint, parseRelayEndpointIncumbentProbe, relayEndpointIncumbentProbeCommand, type RelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' -import { reapEmptyRelayHuskCommand } from './ssh-relay-endpoint-takeover' import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' const posixOnly = process.platform === 'win32' ? describe.skip : describe @@ -39,7 +37,7 @@ net.createServer(() => {}).listen(sock, () => process.stdout.write('READY\\n')) process.on('SIGTERM', () => process.exit(0)) ` -// Self-limiting: these are orphaned when the relay under test is reaped. +// Self-limiting: these outlive a SIGKILLed relay under test. const IDLE_SERVICE_SOURCE = 'setTimeout(() => {}, 60000)\n' function sh(script: string): Promise { @@ -55,7 +53,6 @@ function sh(script: string): Promise { } let workDir: string -let pgreplessBinDir: string let hasLsof = false const running: ChildProcess[] = [] @@ -107,11 +104,6 @@ beforeAll(async () => { writeFileSync(join(workDir, filename), IDLE_SERVICE_SOURCE) } writeFileSync(join(workDir, 'looks-like-relay-watcher.js'), IDLE_SERVICE_SOURCE) - pgreplessBinDir = join(workDir, 'pgrepless-bin') - mkdirSync(pgreplessBinDir) - for (const tool of ['ps', 'tr']) { - symlinkSync((await sh(`command -v ${tool}`)).trim(), join(pgreplessBinDir, tool)) - } hasLsof = await sh('command -v lsof >/dev/null 2>&1 && echo yes || echo no').then( (out) => out.trim() === 'yes' ) @@ -212,64 +204,12 @@ posixOnly('relay endpoint probe against a real socket', () => { expect(incumbent.verdict).toBe(hasLsof ? 'exited' : 'unverifiable') }) - it('permits guarded launch when lsof cannot stat a never-bound path', async () => { + it('answers unverifiable when lsof cannot stat a never-bound path', async () => { const incumbent = await probe(join(workDir, 'never-existed.sock')) expect(incumbent.socketPresent).toBe(false) expect(incumbent.verdict).toBe('unverifiable') expect(incumbent.holdersEnumerable).toBe(false) expect(incumbent.holders).toEqual([]) - expect(mayLaunchOverRelayEndpoint(incumbent)).toBe(true) expect(isReapableRelayHusk(incumbent)).toBe(false) }) }) - -posixOnly('empty relay husk reap against a real process', () => { - it('terminates a proven-empty relay and confirms the pid is gone', async () => { - const sockPath = join(workDir, 'husk.sock') - const relay = await startFakeRelay(sockPath) - const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) - expect(output.trim()).toBe('GONE') - }) - - it('refuses to signal a relay that acquired a child after it was probed', async () => { - const sockPath = join(workDir, 'raced.sock') - const relay = await startFakeRelay(sockPath, { withChild: true }) - const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) - expect(output.trim()).toBe('BUSY') - expect(relay.killed).toBe(false) - }) - - it('terminates a relay whose only children are its own service processes (#13614)', async () => { - const sockPath = join(workDir, 'service-husk.sock') - const relay = await startFakeRelay(sockPath, { - serviceChildren: RELAY_DAEMON_SERVICE_ENTRY_FILENAMES - }) - await waitForChildCount(sockPath, RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length) - const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) - expect(output.trim()).toBe('GONE') - }) - - it('refuses to signal when the host cannot enumerate children at all', async () => { - const sockPath = join(workDir, 'no-pgrep.sock') - const relay = await startFakeRelay(sockPath) - // A PATH carrying every tool the script needs except `pgrep`: the census answers - // `unknown`, which must reach BUSY rather than the zero a missing tool would imply. - const output = await sh( - `PATH=${pgreplessBinDir}\n${reapEmptyRelayHuskCommand(relay.pid!, sockPath)}` - ) - expect(output.trim()).toBe('BUSY') - expect(relay.killed).toBe(false) - }) - - it('refuses to signal a pid whose argv is not this relay at this socket', async () => { - const sockPath = join(workDir, 'mismatch.sock') - await startFakeRelay(sockPath) - const bystander = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: 'ignore' - }) - running.push(bystander) - const output = await sh(reapEmptyRelayHuskCommand(bystander.pid!, sockPath)) - expect(output.trim()).toBe('MISMATCH') - expect(bystander.killed).toBe(false) - }) -}) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts index 2d602f98b32..fe109fd7834 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts @@ -9,14 +9,10 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ })) import { - describeRelayEndpointIncumbent, isReapableRelayHusk, - mayLaunchOverRelayEndpoint, parseRelayEndpointIncumbentProbe, probeRelayEndpointIncumbent, - relayEndpointIncumbentProbeCommand, - withHandshakeRefusalEvidence, - type RelayEndpointIncumbent + relayEndpointIncumbentProbeCommand } from './ssh-relay-endpoint-incumbent' import type { SshConnection } from './ssh-connection' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -198,35 +194,6 @@ describe('relayEndpointIncumbentProbeCommand', () => { }) }) -describe('withHandshakeRefusalEvidence', () => { - it('upgrades an unenumerable endpoint to live when the daemon answered the handshake', () => { - const probed = parseRelayEndpointIncumbentProbe( - SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) - ) - const incumbent = withHandshakeRefusalEvidence(probed) - expect(incumbent.verdict).toBe('live') - expect(incumbent.evidence).toBe('handshake-refusal') - expect(mayLaunchOverRelayEndpoint(incumbent)).toBe(false) - }) - - it('leaves stronger evidence in place', () => { - const probed = parseRelayEndpointIncumbentProbe( - SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof']) - ) - expect(withHandshakeRefusalEvidence(probed).evidence).toBe('accepted-connection') - }) -}) - -describe('mayLaunchOverRelayEndpoint', () => { - const verdicts: RelayEndpointIncumbent['verdict'][] = ['live', 'unverifiable', 'exited'] - it.each(verdicts)('permits a relaunch for %s only when it is not live', (verdict) => { - const incumbent = { ...parseRelayEndpointIncumbentProbe(SOCK, ''), verdict } - expect(mayLaunchOverRelayEndpoint(incumbent)).toBe(verdict !== 'live') - }) -}) - describe('isReapableRelayHusk', () => { const husk = parseRelayEndpointIncumbentProbe( SOCK, @@ -290,18 +257,3 @@ describe('isReapableRelayHusk', () => { expect(isReapableRelayHusk({ ...husk, holdersEnumerable: false })).toBe(false) }) }) - -describe('describeRelayEndpointIncumbent', () => { - it('distinguishes "no holders" from "could not enumerate holders"', () => { - const none = parseRelayEndpointIncumbentProbe( - SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof']) - ) - const unknown = parseRelayEndpointIncumbentProbe( - SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=unavailable']) - ) - expect(describeRelayEndpointIncumbent(none)).toContain('holders=none') - expect(describeRelayEndpointIncumbent(unknown)).toContain('holders=unenumerable') - }) -}) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.ts index 668ed4e6a20..ee9a0e93bd1 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.ts @@ -14,10 +14,9 @@ * - `exited` is a claim about **this endpoint**, not about every relay on the host. It means * nothing holds this socket path, established positively (a connect that was refused *and* * an enumeration that found no holder). A relay whose socket was already unlinked is - * invisible to this probe by construction — that is what the superseded sweep is for. + * invisible to this probe by construction. * - a probe that could not run, a host without `lsof`, or a connect that failed for any other - * reason is `unverifiable`. It cannot authorize client cleanup; guarded launch still - * delegates socket takeover checks to the daemon. + * reason is `unverifiable`. It never authorizes client cleanup. */ import { RELAY_LSOF_PROBE_JS } from '@orca/process-host/posix-lsof-probe' import type { SshConnection } from './ssh-connection' @@ -30,11 +29,10 @@ import { import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -export type RelayEndpointVerdict = 'live' | 'unverifiable' | 'exited' +type RelayEndpointVerdict = 'live' | 'unverifiable' | 'exited' export type RelayEndpointEvidence = | 'accepted-connection' - | 'handshake-refusal' | 'holder-process' | 'no-holder' | 'inconclusive' @@ -69,7 +67,7 @@ const PROBE_END = 'ORCA-INCUMBENT-END' const CONNECT_PROBE_TIMEOUT_MS = 1000 // Why ES5 syntax: nodePath may be a host-resolved system node, not the bundled one. -export const RELAY_CONNECT_PROBE_JS = [ +const RELAY_CONNECT_PROBE_JS = [ 'var s=require("net").connect(process.argv[1]);', 'var done=false;', 'function say(v){if(done)return;done=true;try{s.destroy()}catch(e){};', @@ -254,31 +252,6 @@ export async function probeRelayEndpointIncumbent( } } -/** - * A relay that told us its version over the wire is `live` by positive host evidence, even on - * a host where nothing can enumerate socket holders. - */ -export function withHandshakeRefusalEvidence( - incumbent: RelayEndpointIncumbent -): RelayEndpointIncumbent { - if (incumbent.verdict === 'live') { - return incumbent - } - return { ...incumbent, verdict: 'live', evidence: 'handshake-refusal' } -} - -/** - * May a fresh relay be launched onto this path? - * - * Only `live` forbids it. `unverifiable` is permitted because the *daemon* — not the client — - * performs the takeover: `RelaySocketOwnership.listen` re-probes on EADDRINUSE, refuses to - * steal a path that accepts connections, and only unlinks an inode whose identity is - * unchanged. That check is atomic with the bind, which a client-side `rm -f` can never be. - */ -export function mayLaunchOverRelayEndpoint(incumbent: RelayEndpointIncumbent): boolean { - return incumbent.verdict !== 'live' -} - /** * A live relay that provably holds nothing: identity confirmed against its argv, exactly one * holder, and no child the host could not account for as one of the daemon's own service @@ -298,61 +271,3 @@ export function isReapableRelayHusk(incumbent: RelayEndpointIncumbent): boolean const [holder] = incumbent.holders return holder.matchesRelayArgv && holder.unrecognizedChildCount === 0 } - -export function describeRelayEndpointIncumbent(incumbent: RelayEndpointIncumbent): string { - const holders = incumbent.holders - .map( - (holder) => - `${holder.pid}(children=${holder.childCount ?? 'unknown'},` + - `unrecognized=${holder.unrecognizedChildCount ?? 'unknown'})` - ) - .join(',') - return ( - `${incumbent.sockPath} verdict=${incumbent.verdict} evidence=${incumbent.evidence} ` + - `holders=${incumbent.holdersEnumerable ? holders || 'none' : 'unenumerable'}` - ) -} - -/** - * Thrown instead of orphaning: a live relay owns the endpoint and refused us, so the path is - * not ours to rebind. Terminal for this attempt — the user resolves it with Reset Relay, - * which signals the incumbent deliberately and with consent. - */ -export class RelayEndpointHeldError extends Error { - readonly name = 'RelayEndpointHeldError' - constructor(readonly incumbent: RelayEndpointIncumbent) { - super( - `A live relay still owns ${incumbent.sockPath} and refused this connection ` + - `(${describeRelayEndpointIncumbent(incumbent)}). Orca will not replace it, because ` + - 'unlinking its socket would strand its terminals. Use Reset Relay for this host to ' + - 'stop it, then reconnect.' - ) - } -} - -export function isRelayEndpointHeldError(err: unknown): err is RelayEndpointHeldError { - return err instanceof RelayEndpointHeldError -} - -/** - * Thrown when a relay holds the endpoint but never refused us: it accepted a connection or is - * enumerated as the holder, yet our --connect got no handshake answer. That is a stalled or - * overloaded relay, not a decision — so unlike `RelayEndpointHeldError` this is retryable, and - * the session routes it through the relay-lost backoff rather than the terminal error path. - */ -export class RelayEndpointUnresponsiveError extends Error { - readonly name = 'RelayEndpointUnresponsiveError' - constructor(readonly incumbent: RelayEndpointIncumbent) { - super( - `A relay still owns ${incumbent.sockPath} but did not answer the handshake ` + - `(${describeRelayEndpointIncumbent(incumbent)}). Orca will retry rather than replace it; ` + - 'if it never recovers, use Reset Relay for this host.' - ) - } -} - -export function isRelayEndpointUnresponsiveError( - err: unknown -): err is RelayEndpointUnresponsiveError { - return err instanceof RelayEndpointUnresponsiveError -} diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts deleted file mode 100644 index af0a8b4b452..00000000000 --- a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const execCommand = vi.fn() -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), - isUnconfirmedSshCommandTermination: (error: unknown) => - (error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false -})) - -import { - isRelayEndpointHeldError, - isRelayEndpointUnresponsiveError -} from './ssh-relay-endpoint-incumbent' -import { RelayCredentialMismatchError } from './ssh-relay-credential-mismatch-error' -import { - interpretRelayHuskReapOutput, - reapEmptyRelayHuskCommand, - resolveRelayEndpointBeforeRelaunch -} from './ssh-relay-endpoint-takeover' -import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' -import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const SOCK = '/home/u/.orca-remote/relay-0.1.0+aaaa/relay-deadbeef.sock' -const HOST = getRemoteHostPlatform('linux-x64') -const CONN = {} as SshConnection - -function probe(lines: string[]): string { - return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') -} - -function issuedCommands(): string[] { - return execCommand.mock.calls.map((call) => String(call[1])) -} - -function resolve(reconnectError: unknown = new Error('connect failed')): Promise { - return resolveRelayEndpointBeforeRelaunch(CONN, HOST, '/usr/bin/node', SOCK, reconnectError) -} - -beforeEach(() => { - execCommand.mockReset() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) -}) - -const REFUSED = new RelayCredentialMismatchError('') - -describe('incumbent alive and refusing', () => { - it('refuses to rebind a live relay holding PTYs, and signals nothing', async () => { - execCommand.mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) - ) - await expect(resolve(REFUSED)).rejects.toSatisfy(isRelayEndpointHeldError) - // The whole point of #8585: the incumbent's socket must survive so it is not orphaned. - expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) - expect(issuedCommands().some((command) => /\bkill\s/.test(command))).toBe(false) - }) - - it('names the incumbent pid and the Reset Relay escape hatch in the error', async () => { - execCommand.mockResolvedValue( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) - ) - await expect(resolve(REFUSED)).rejects.toThrow(/3669803\(children=13,unrecognized=11\)/) - await expect(resolve(REFUSED)).rejects.toThrow(/Reset Relay/) - }) - - it('treats a refused credential as live even where holders cannot be enumerated', async () => { - execCommand.mockResolvedValue( - probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) - ) - await expect(resolve(REFUSED)).rejects.toSatisfy(isRelayEndpointHeldError) - expect(issuedCommands().some((command) => /\bkill\s/.test(command))).toBe(false) - }) - - it('treats a version mismatch as live even where holders cannot be enumerated', async () => { - execCommand.mockResolvedValue( - probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) - ) - const mismatch = new RelayVersionMismatchError('0.1.0+new', '0.1.0+old', '') - await expect(resolve(mismatch)).rejects.toSatisfy(isRelayEndpointHeldError) - expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) - }) - - it('reaps a live relay only when it provably holds nothing, and confirms it is gone', async () => { - execCommand - .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) - ) - .mockResolvedValueOnce('GONE\n') - await expect(resolve()).resolves.toMatchObject({ verdict: 'live' }) - expect(issuedCommands()[1]).toContain('kill -TERM "$pid"') - }) - - it('does not launch over an empty relay whose death could not be confirmed', async () => { - execCommand - .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) - ) - .mockResolvedValueOnce('LIVE\n') - await expect(resolve(REFUSED)).rejects.toSatisfy(isRelayEndpointHeldError) - }) - - it('does not launch over a relay the host refused to signal on its own re-check', async () => { - execCommand - .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) - ) - .mockResolvedValueOnce('BUSY\n') - await expect(resolve(REFUSED)).rejects.toSatisfy(isRelayEndpointHeldError) - }) -}) - -describe('incumbent alive but silent', () => { - // The stalled-host shape: the kernel backlog accepts the probe's connect, the daemon never - // answers the handshake. Nothing refused us, so this must stay retryable — never terminal, - // never a rebind, never a signal. - it('reports an unresponsive holder as retryable, not as a held endpoint', async () => { - execCommand.mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=unavailable']) - ) - const outcome = resolve(new Error('Relay failed to start within 10s.')) - await expect(outcome).rejects.toSatisfy(isRelayEndpointUnresponsiveError) - await expect(outcome).rejects.not.toSatisfy(isRelayEndpointHeldError) - expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) - expect(issuedCommands().some((command) => /\bkill\s/.test(command))).toBe(false) - }) - - it('stays retryable when a silent holder is enumerated with live work', async () => { - execCommand.mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) - ) - await expect(resolve()).rejects.toSatisfy(isRelayEndpointUnresponsiveError) - }) -}) - -describe('incumbent genuinely gone', () => { - it('permits the relaunch without unlinking anything itself', async () => { - execCommand.mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof']) - ) - await expect(resolve()).resolves.toMatchObject({ verdict: 'exited', evidence: 'no-holder' }) - // The daemon unlinks under an identity check that is atomic with its bind; the client - // cannot be, which is what created the orphan in the first place. - expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) - }) -}) - -describe('incumbent unverifiable', () => { - it('permits the relaunch but never claims the incumbent exited', async () => { - execCommand.mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) - ) - await expect(resolve()).resolves.toMatchObject({ verdict: 'unverifiable' }) - expect(issuedCommands()).toHaveLength(1) - }) - - it('stays unverifiable when the probe command itself fails', async () => { - execCommand.mockRejectedValueOnce(new Error('exec timeout')) - await expect(resolve()).resolves.toMatchObject({ verdict: 'unverifiable' }) - }) - - it('rethrows an unconfirmed probe termination without relaunching, unlinking, or killing', async () => { - const unconfirmed = Object.assign(new Error('remote channel close was not confirmed'), { - sshChannelCloseConfirmed: false - }) - execCommand.mockRejectedValueOnce(unconfirmed) - - await expect(resolve()).rejects.toBe(unconfirmed) - expect(issuedCommands()).toHaveLength(1) - expect(issuedCommands().some((command) => /--detached|\brm -f\b|\bkill\s/.test(command))).toBe( - false - ) - }) -}) - -describe('reapEmptyRelayHuskCommand', () => { - it('re-verifies argv and emptiness on the host immediately before signalling', () => { - const command = reapEmptyRelayHuskCommand(4242, SOCK) - expect(command.indexOf('MISMATCH')).toBeLessThan(command.indexOf('kill -TERM')) - expect(command.indexOf('BUSY')).toBeLessThan(command.indexOf('kill -TERM')) - }) - - it('sends SIGTERM only, so the relay runs its own socket cleanup', () => { - const command = reapEmptyRelayHuskCommand(4242, SOCK) - expect(command).toContain('kill -TERM') - expect(command).not.toContain('kill -KILL') - expect(command).not.toContain('-9') - }) - - it('aborts without signalling when the host cannot count children', () => { - const command = reapEmptyRelayHuskCommand(4242, SOCK) - // The census leaves both counters at `unknown` without pgrep, and the gate demands "0". - expect(command).toContain('unrecognized_kids=unknown') - expect(command).toContain('command -v pgrep >/dev/null 2>&1') - expect(command).toContain('[ "$unrecognized_kids" = "0" ] ||') - }) - - it('subtracts only the daemon service children it can name from the reap gate', () => { - const command = reapEmptyRelayHuskCommand(4242, SOCK) - for (const filename of RELAY_DAEMON_SERVICE_ENTRY_FILENAMES) { - expect(command).toContain(`*'/${filename}'`) - } - expect(command).toContain('unrecognized_kids=$((unrecognized_kids+1))') - }) -}) - -describe('interpretRelayHuskReapOutput', () => { - it('claims reaped only for a post-signal liveness check that failed', () => { - expect(interpretRelayHuskReapOutput('GONE\n')).toBe('reaped') - expect(interpretRelayHuskReapOutput('LIVE\n')).toBe('reap-unconfirmed') - expect(interpretRelayHuskReapOutput('')).toBe('reap-unconfirmed') - expect(interpretRelayHuskReapOutput('unexpected noise')).toBe('reap-unconfirmed') - }) - - it('reports a host-side refusal as retained rather than as a failed kill', () => { - expect(interpretRelayHuskReapOutput('MISMATCH\n')).toBe('retained-live-work') - expect(interpretRelayHuskReapOutput('BUSY\n')).toBe('retained-live-work') - }) -}) diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.ts b/src/main/ssh/ssh-relay-endpoint-takeover.ts deleted file mode 100644 index 60760865875..00000000000 --- a/src/main/ssh/ssh-relay-endpoint-takeover.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Deciding whether a relay socket path is ours to take, and acting on the answer. - * - * The only destructive action available here is a SIGTERM to a relay that has been proven — - * by argv, by socket-holder enumeration, and by a child census re-run on the host immediately - * before the signal — to hold nothing at all. Everything else is left running. - * Per docs/reference/ssh-execution-boundary.md, a relay we merely failed to reach is - * `unverifiable`, and `unverifiable` never authorizes a kill or a rebind. - */ -import type { SshConnection } from './ssh-connection' -import { shellEscape } from './ssh-connection-utils' -import { - RELAY_UNRECOGNIZED_CHILD_COUNT_VAR, - relayDaemonChildCensusShell -} from './relay-daemon-service-children' -import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { - describeRelayEndpointIncumbent, - isReapableRelayHusk, - mayLaunchOverRelayEndpoint, - probeRelayEndpointIncumbent, - RelayEndpointHeldError, - RelayEndpointUnresponsiveError, - withHandshakeRefusalEvidence, - type RelayEndpointIncumbent -} from './ssh-relay-endpoint-incumbent' -import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' -import { isRelayCredentialMismatchError } from './ssh-relay-credential-mismatch-error' -import type { RemoteHostPlatform } from './ssh-remote-platform' - -/** `reaped` is only reachable from a post-signal `kill -0` that failed. Nothing else claims it. */ -export type RelayHuskReapResult = 'reaped' | 'reap-unconfirmed' | 'retained-live-work' - -const REAP_CONFIRM_ATTEMPTS = 15 - -/** - * Signal one relay, re-verifying identity and emptiness inside the same command. - * - * The re-verification is not belt-and-braces: a client can attach and spawn a PTY between the - * probe and the signal, and pids are reused. `MISMATCH`/`BUSY` abort without signalling. - */ -export function reapEmptyRelayHuskCommand(pid: number, sockPath: string): string { - return [ - `pid=${shellEscape(String(pid))}`, - `sock=${shellEscape(sockPath)}`, - 'args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', - 'case "$args" in *relay.js*"$sock"*) ;; *) printf \'MISMATCH\\n\'; exit 0 ;; esac', - // Why the same census as the probe: `unknown` (no pgrep) and any child this host could - // not account for as a relay service both land on BUSY, so nothing is signalled. - ...relayDaemonChildCensusShell(), - `[ "$${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}" = "0" ] || { printf 'BUSY\\n'; exit 0; }`, - // SIGTERM only: the relay's own handler disposes and unlinks. SIGKILL would leave the - // socket inode behind and skip that shutdown path for no gain on an empty daemon. - 'kill -TERM "$pid" 2>/dev/null || true', - 'i=0', - `while [ $i -lt ${REAP_CONFIRM_ATTEMPTS} ]; do`, - ' kill -0 "$pid" 2>/dev/null || { printf \'GONE\\n\'; exit 0; }', - ' sleep 0.2', - ' i=$((i+1))', - 'done', - "printf 'LIVE\\n'" - ].join('\n') -} - -export function interpretRelayHuskReapOutput(output: string): RelayHuskReapResult { - const state = output.trim().split('\n').pop()?.trim() - if (state === 'GONE') { - return 'reaped' - } - // The host refused on its own re-check: what is there is not the empty relay we probed, so - // nothing was signalled and nothing is claimed about it. - if (state === 'MISMATCH' || state === 'BUSY') { - return 'retained-live-work' - } - return 'reap-unconfirmed' -} - -export async function reapEmptyRelayHusk( - conn: SshConnection, - incumbent: RelayEndpointIncumbent, - options?: { signal?: AbortSignal } -): Promise { - const holder = incumbent.holders[0] - if (!holder) { - return 'retained-live-work' - } - try { - const output = await execCommand( - conn, - reapEmptyRelayHuskCommand(holder.pid, incumbent.sockPath), - { wrapCommand: true, signal: options?.signal } - ) - return interpretRelayHuskReapOutput(output) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return 'reap-unconfirmed' - } -} - -/** - * Called when `--connect` to an existing socket failed and the caller is about to launch a - * replacement at the same path. Resolves when the launch may proceed; throws - * `RelayEndpointHeldError` when a live relay refused us and holds work, and - * `RelayEndpointUnresponsiveError` when a live relay holds work but never answered — the - * second is retryable, because silence is not a decision. - * - * `unverifiable` deliberately permits the launch: the daemon, not the client, performs the - * takeover. `RelaySocketOwnership.listen` re-probes on EADDRINUSE, refuses a path that accepts - * connections, and only unlinks an inode whose identity is unchanged — a check that is atomic - * with the bind, which a client-side `rm -f` can never be. - */ -export async function resolveRelayEndpointBeforeRelaunch( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - nodePath: string, - sockPath: string, - reconnectError: unknown, - options?: { signal?: AbortSignal } -): Promise { - const probed = await probeRelayEndpointIncumbent(conn, hostPlatform, nodePath, sockPath, options) - // A daemon that answered the handshake with its own version is live by positive host - // evidence, even where nothing can enumerate socket holders. - // A refused credential is the same positive evidence: the daemon answered. - const refused = - isRelayVersionMismatchError(reconnectError) || isRelayCredentialMismatchError(reconnectError) - const incumbent = refused ? withHandshakeRefusalEvidence(probed) : probed - console.warn(`[ssh-relay] Relay endpoint incumbent: ${describeRelayEndpointIncumbent(incumbent)}`) - - if (mayLaunchOverRelayEndpoint(incumbent)) { - return incumbent - } - if (!isReapableRelayHusk(incumbent)) { - throw refused - ? new RelayEndpointHeldError(incumbent) - : new RelayEndpointUnresponsiveError(incumbent) - } - const result = await reapEmptyRelayHusk(conn, incumbent, options) - if (result !== 'reaped') { - throw refused - ? new RelayEndpointHeldError(incumbent) - : new RelayEndpointUnresponsiveError(incumbent) - } - console.log(`[ssh-relay] Reaped empty relay husk holding ${sockPath}`) - return incumbent -} diff --git a/src/main/ssh/ssh-relay-endpoints.ts b/src/main/ssh/ssh-relay-endpoints.ts index bcf1f86aae9..e653cb4d5c2 100644 --- a/src/main/ssh/ssh-relay-endpoints.ts +++ b/src/main/ssh/ssh-relay-endpoints.ts @@ -1,14 +1,7 @@ import { createHash } from 'node:crypto' -import { - isWindowsRemoteHost, - joinRemotePath, - remoteBasename, - type RemoteHostPlatform -} from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -export const WINDOWS_ACTIVE_PIPE_MARKER_PREFIX = '.windows-active-pipe-' - -export function relayEndpointForHost( +function relayEndpointForHost( hostPlatform: RemoteHostPlatform, remoteDir: string, sockName: string @@ -23,23 +16,6 @@ export function relayEndpointForHost( return `\\\\.\\pipe\\orca-relay-${endpointHash}` } -export function relayHookEndpointDirForHost( - hostPlatform: RemoteHostPlatform, - remoteDir: string, - sockPath: string -): string { - return joinRemotePath( - hostPlatform, - remoteDir, - 'agent-hooks', - remoteBasename(sockPath, hostPlatform) - ) -} - -export function windowsRelayFallbackSocketName(sockName: string): string { - return `${sockName}-fallback` -} - export function windowsRelayPipePathsForSocketName( hostPlatform: RemoteHostPlatform, remoteDir: string, @@ -47,22 +23,6 @@ export function windowsRelayPipePathsForSocketName( ): string[] { return [ relayEndpointForHost(hostPlatform, remoteDir, sockName), - relayEndpointForHost(hostPlatform, remoteDir, windowsRelayFallbackSocketName(sockName)) + relayEndpointForHost(hostPlatform, remoteDir, `${sockName}-fallback`) ] } - -export function windowsActivePipeMarkerPath( - hostPlatform: RemoteHostPlatform, - remoteDir: string, - sockName: string -): string { - return joinRemotePath( - hostPlatform, - remoteDir, - `${WINDOWS_ACTIVE_PIPE_MARKER_PREFIX}${sockName.replace(/[^a-zA-Z0-9.-]/g, '_')}` - ) -} - -export function isWindowsRelayPipePath(value: string): boolean { - return /^\\\\[.?]\\pipe\\orca-relay-[0-9a-f]{20}$/i.test(value) -} diff --git a/src/main/ssh/ssh-relay-incumbent-process.test.ts b/src/main/ssh/ssh-relay-incumbent-process.test.ts index 8758d06fa3a..6584c2b3239 100644 --- a/src/main/ssh/ssh-relay-incumbent-process.test.ts +++ b/src/main/ssh/ssh-relay-incumbent-process.test.ts @@ -11,7 +11,6 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ import { relayEndpointIncumbentProbeCommand, parseRelayEndpointIncumbentProbe, - mayLaunchOverRelayEndpoint, isReapableRelayHusk } from './ssh-relay-endpoint-incumbent' @@ -100,7 +99,6 @@ describe.skipIf(process.platform === 'win32').concurrent('real generated incumbe holdersEnumerable: false }) expect(p.verdict.holders.map((holder) => holder.pid)).toContain(process.pid) - expect(mayLaunchOverRelayEndpoint(p.verdict)).toBe(false) expect(isReapableRelayHusk(p.verdict)).toBe(false) } ) diff --git a/src/main/ssh/ssh-relay-install-marker.ts b/src/main/ssh/ssh-relay-install-marker.ts index a5ff95bd4e4..aaf7d1ff401 100644 --- a/src/main/ssh/ssh-relay-install-marker.ts +++ b/src/main/ssh/ssh-relay-install-marker.ts @@ -1,12 +1,5 @@ -import { randomBytes } from 'node:crypto' - const SFTP_NAMESPACE_MARKER_PREFIX = '.sftp-namespace-' const SFTP_NAMESPACE_MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/giu -const MARKER_TOKEN_BYTES = 16 - -export function createRelayInstallMarkerFileName(): string { - return `${SFTP_NAMESPACE_MARKER_PREFIX}${randomBytes(MARKER_TOKEN_BYTES).toString('hex')}` -} export function redactRelayInstallMarkerTokens(value: string): string { return value.replace(SFTP_NAMESPACE_MARKER_PATTERN, `${SFTP_NAMESPACE_MARKER_PREFIX}[redacted]`) diff --git a/src/main/ssh/ssh-relay-install-namespace.test.ts b/src/main/ssh/ssh-relay-install-namespace.test.ts deleted file mode 100644 index a16c5005aa2..00000000000 --- a/src/main/ssh/ssh-relay-install-namespace.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Why: the shell path and the SFTP-relative path must be built from the same -// validated segments, and the install-owner marker is what proves a redirected -// directory belongs to this install rather than an unrelated same-version one. - -import { describe, expect, it } from 'vitest' -import { - createRelayInstallMarkerCommand, - createRelayInstallNamespace, - createRelayUploadStageNamespace, - makeRelayInstallDirectoryCommand, - makeRelayUploadStageDirectoryCommand, - relayHomeRelativeDir, - relayInstallMarkerShellPath, - relayRemoteDirSegments, - relaySftpNamespaceMapping, - relayUploadStageSftpNamespaceMapping -} from './ssh-relay-install-namespace' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { computeRemoteRelayDir } from './ssh-relay-versioned-install' - -const LINUX = getRemoteHostPlatform('linux-x64') -const WINDOWS = getRemoteHostPlatform('win32-x64') -const VERSION = '0.1.0+abc123' -const SHELL_RELAY_DIR = `/var/services/homes/alice/.orca-remote/relay-${VERSION}` - -describe('relayRemoteDirSegments', () => { - it('builds the two segments every relay path shares', () => { - expect(relayRemoteDirSegments(VERSION, 'posix')).toEqual(['.orca-remote', `relay-${VERSION}`]) - }) - - it('produces a home-relative dir that matches the shell dir suffix', () => { - expect(SHELL_RELAY_DIR.endsWith(`/${relayHomeRelativeDir(VERSION)}`)).toBe(true) - }) - - it.each([ - ['a path separator', '1.0/../etc'], - ['a NUL byte', '1.0\0'], - ['a carriage return', '1.0\rmalicious'], - ['a line feed', '1.0\nmalicious'] - ])('rejects %s in the version segment', (_label, version) => { - expect(() => relayRemoteDirSegments(version, 'posix')).toThrow('Unsafe remote path segment') - }) - - it('applies Windows-specific segment rules on the windows flavor', () => { - expect(() => relayRemoteDirSegments('1.0 ', 'windows')).toThrow('Unsafe remote path segment') - expect(() => relayRemoteDirSegments('1.0 ', 'posix')).not.toThrow() - }) -}) - -describe('computeRemoteRelayDir agreement', () => { - it('ends with the suffix the SFTP-relative builder produces', () => { - // Why: a split namespace rebuilds the path from the home-relative suffix, so the two must agree. - expect(computeRemoteRelayDir('/var/services/homes/alice', VERSION)).toBe(SHELL_RELAY_DIR) - expect(SHELL_RELAY_DIR.endsWith(`/${relayHomeRelativeDir(VERSION)}`)).toBe(true) - }) - - it.each([ - ['a path separator', '0.1.0/../etc'], - ['a NUL byte', '0.1.0\0'], - ['a line feed', '0.1.0\nrm -rf /'] - ])('rejects %s in the version rather than building a path', (_label, version) => { - expect(() => computeRemoteRelayDir('/home/u', version)).toThrow('Unsafe remote path segment') - }) - - it('applies Windows segment rules on the windows flavor', () => { - expect(computeRemoteRelayDir('C:\\Users\\u', VERSION, 'windows')).toBe( - `C:/Users/u/.orca-remote/relay-${VERSION}` - ) - expect(() => computeRemoteRelayDir('C:\\Users\\u', '0.1.0 ', 'windows')).toThrow( - 'Unsafe remote path segment' - ) - }) -}) - -describe('createRelayInstallNamespace', () => { - it('mints an unguessable 128-bit marker name per install', () => { - const first = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) - const second = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) - - expect(first.markerFileName).toMatch(/^\.sftp-namespace-[0-9a-f]{32}$/) - expect(first.markerFileName).not.toBe(second.markerFileName) - expect(first.homeRelativeRelayDir).toBe(`.orca-remote/relay-${VERSION}`) - }) -}) - -describe('relaySftpNamespaceMapping', () => { - const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) - - it('maps the bundle directory itself when no file name is given', () => { - const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR) - - expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}`) - expect(mapping.homeRelativeNamespaceRoot).toBe(`.orca-remote/relay-${VERSION}`) - expect(mapping.homeRelativeProbePath).toBe( - `.orca-remote/relay-${VERSION}/.install-lock/${namespace.markerFileName}` - ) - expect(mapping.shellProbePath).toBe( - `${SHELL_RELAY_DIR}/.install-lock/${namespace.markerFileName}` - ) - }) - - it('maps a file inside the bundle directory', () => { - const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, 'package.json') - - expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}/package.json`) - }) - - it('shares one marker across every write of an install', () => { - const bundle = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR) - const version = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, '.version') - - expect(version.shellProbePath).toBe(bundle.shellProbePath) - expect(version.homeRelativeProbePath).toBe(bundle.homeRelativeProbePath) - }) - - it.each(['nested/file', '..', '', 'line\nbreak'])( - 'rejects an unsafe relative file name %j at mapping construction', - (relativeFileName) => { - expect(() => - relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, relativeFileName) - ).toThrow('Unsafe remote path segment') - } - ) -}) - -describe('relay upload stage namespace', () => { - const stageSuffix = `.orca-remote/relay-${VERSION}.upload-123e4567-e89b-12d3-a456-426614174000` - const shellStageDir = `/var/services/homes/alice/${stageSuffix}` - const namespace = createRelayUploadStageNamespace(stageSuffix) - - it('maps only the attempt-owned payload subtree', () => { - const payload = relayUploadStageSftpNamespaceMapping(namespace, LINUX, shellStageDir) - const version = relayUploadStageSftpNamespaceMapping( - namespace, - LINUX, - shellStageDir, - '.version' - ) - - expect(payload.homeRelativeNamespaceRoot).toBe(stageSuffix) - expect(payload.homeRelativePath).toBe(`${stageSuffix}/payload`) - expect(version.homeRelativePath).toBe(`${stageSuffix}/payload/.version`) - expect(payload.shellProbePath).toBe(`${shellStageDir}/${namespace.markerFileName}`) - expect(payload.shellProbePath).toBe(version.shellProbePath) - }) - - it('creates the payload and marker without touching the shared install lock', () => { - const command = makeRelayUploadStageDirectoryCommand(namespace, LINUX, shellStageDir) - - expect(command).toContain(`${shellStageDir}/payload`) - expect(command).toContain(`${shellStageDir}/${namespace.markerFileName}`) - expect(command).toContain('umask 077') - expect(command).not.toContain('.install-lock') - }) -}) - -describe('install directory command', () => { - const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) - - it('folds marker creation into the first-install mkdir', () => { - const command = makeRelayInstallDirectoryCommand(LINUX, SHELL_RELAY_DIR, namespace) - - expect(command).toContain(SHELL_RELAY_DIR) - expect(command).toContain(`${SHELL_RELAY_DIR}/.install-lock`) - expect(command).toContain('umask 077') - expect(command).toContain(`touch `) - expect(command).toContain(namespace.markerFileName) - }) - - it('is the plain directory command when no namespace applies', () => { - expect(makeRelayInstallDirectoryCommand(WINDOWS, 'C:\\Users\\alice\\relay')).not.toContain( - '.sftp-namespace-' - ) - expect(makeRelayInstallDirectoryCommand(LINUX, SHELL_RELAY_DIR)).not.toContain('touch ') - }) - - it('creates the lock directory before touching the marker inside it', () => { - const command = createRelayInstallMarkerCommand(namespace, LINUX, SHELL_RELAY_DIR) - const markerPath = relayInstallMarkerShellPath(namespace, LINUX, SHELL_RELAY_DIR) - - expect(command.indexOf('.install-lock')).toBeLessThan(command.indexOf('touch ')) - expect(command.indexOf('umask 077')).toBeLessThan(command.indexOf('touch ')) - expect(markerPath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${namespace.markerFileName}`) - expect(command).toContain(markerPath) - }) -}) diff --git a/src/main/ssh/ssh-relay-install-namespace.ts b/src/main/ssh/ssh-relay-install-namespace.ts deleted file mode 100644 index 6fd3c2aa89f..00000000000 --- a/src/main/ssh/ssh-relay-install-namespace.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Transfer-owner identity for relay installs that cross a split shell/SFTP namespace. -// -// The shell and SFTP paths share one validated home-relative suffix -// (`.orca-remote/relay-`); a random marker inside the shared install -// lock or attempt stage proves which shell-owned directory the SFTP session sees. -// -// See: docs/ssh-relay-sftp-namespace.md - -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { - RELAY_INSTALL_MODEL, - remoteInstallDirName, - type RemoteInstallModel -} from './remote-install-model' -import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' -import { shellEscape } from './ssh-connection-utils' -import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' -import { makeRemoteDirectoryCommand } from './ssh-remote-commands' -import { - assertSafeRemotePathSegment, - joinRemotePath, - type RemoteHostPlatform, - type RemotePathFlavor -} from './ssh-remote-platform' - -export type RelayInstallNamespace = { - homeRelativeRelayDir: string - markerFileName: string -} - -export type RelayUploadStageNamespace = { - homeRelativeStageDir: string - markerFileName: string -} - -/** - * The two validated segments every relay path is built from. Both the shell - * builder and the SFTP-relative builder go through here so they cannot drift. - */ -export function relayRemoteDirSegments( - fullVersion: string, - pathFlavor: RemotePathFlavor -): string[] { - return remoteInstallDirSegments(RELAY_INSTALL_MODEL, fullVersion, pathFlavor) -} - -/** - * The model-parameterized form. `relay-` and `orcad-` are permanent siblings under - * one `.orca-remote/` (see remote-install-model.ts), so the prefix is an argument. - */ -export function remoteInstallDirSegments( - model: RemoteInstallModel, - fullVersion: string, - pathFlavor: RemotePathFlavor -): string[] { - const segments = [RELAY_REMOTE_DIR, remoteInstallDirName(model, fullVersion)] - for (const segment of segments) { - assertSafeRemotePathSegment(segment, pathFlavor) - // Why: the version reaches logs and diagnostics, where an embedded CR/LF can forge lines. - if (segment.includes('\r') || segment.includes('\n')) { - throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`) - } - } - return segments -} - -export function relayHomeRelativeDir(fullVersion: string): string { - return remoteInstallHomeRelativeDir(RELAY_INSTALL_MODEL, fullVersion) -} - -export function remoteInstallHomeRelativeDir( - model: RemoteInstallModel, - fullVersion: string -): string { - return remoteInstallDirSegments(model, fullVersion, 'posix').join('/') -} - -export function createRelayInstallNamespace(homeRelativeRelayDir: string): RelayInstallNamespace { - // Why: an unguessable token, not just a unique suffix — a same-version directory - // under an unrelated SFTP start directory must not qualify as ours. - return { - homeRelativeRelayDir, - markerFileName: createRelayInstallMarkerFileName() - } -} - -export function createRelayUploadStageNamespace( - homeRelativeStageDir: string, - markerFileName = createRelayInstallMarkerFileName() -): RelayUploadStageNamespace { - return { - homeRelativeStageDir, - markerFileName - } -} - -/** - * Describe one relay transfer to the SFTP namespace resolver. `relativeFileName` - * is omitted for the bundle directory upload itself. - */ -export function relaySftpNamespaceMapping( - namespace: RelayInstallNamespace, - host: RemoteHostPlatform, - shellRelayDir: string, - relativeFileName?: string -): SftpNamespacePathMapping { - if (relativeFileName !== undefined) { - assertSafeRemotePathSegment(relativeFileName, 'posix') - if (relativeFileName.includes('\r') || relativeFileName.includes('\n')) { - throw new Error('Unsafe remote path segment in relay SFTP mapping') - } - } - const homeRelativeLockDir = `${namespace.homeRelativeRelayDir}/${RELAY_INSTALL_LOCK_NAME}` - return { - homeRelativeNamespaceRoot: namespace.homeRelativeRelayDir, - homeRelativePath: - relativeFileName !== undefined - ? `${namespace.homeRelativeRelayDir}/${relativeFileName}` - : namespace.homeRelativeRelayDir, - shellProbePath: relayInstallMarkerShellPath(namespace, host, shellRelayDir), - homeRelativeProbePath: `${homeRelativeLockDir}/${namespace.markerFileName}` - } -} - -export function relayUploadStageSftpNamespaceMapping( - namespace: RelayUploadStageNamespace, - host: RemoteHostPlatform, - shellStageDir: string, - relativeFileName?: string -): SftpNamespacePathMapping { - if (relativeFileName !== undefined) { - assertSafeRemotePathSegment(relativeFileName, 'posix') - } - const homeRelativePayloadDir = `${namespace.homeRelativeStageDir}/payload` - return { - homeRelativeNamespaceRoot: namespace.homeRelativeStageDir, - homeRelativePath: - relativeFileName === undefined - ? homeRelativePayloadDir - : `${homeRelativePayloadDir}/${relativeFileName}`, - shellProbePath: joinRemotePath(host, shellStageDir, namespace.markerFileName), - homeRelativeProbePath: `${namespace.homeRelativeStageDir}/${namespace.markerFileName}` - } -} - -export function makeRelayUploadStageDirectoryCommand( - namespace: RelayUploadStageNamespace, - host: RemoteHostPlatform, - shellStageDir: string -): string { - const payloadDir = joinRemotePath(host, shellStageDir, 'payload') - const markerPath = joinRemotePath(host, shellStageDir, namespace.markerFileName) - return `${makeRemoteDirectoryCommand(host, payloadDir)} && umask 077 && touch ${shellEscape(markerPath)}` -} - -export function relayInstallMarkerShellPath( - namespace: RelayInstallNamespace, - host: RemoteHostPlatform, - shellRelayDir: string -): string { - return joinRemotePath(host, shellRelayDir, RELAY_INSTALL_LOCK_NAME, namespace.markerFileName) -} - -/** - * Create the install-owner marker inside the lock this caller already holds. - * POSIX-only: no marker is created for Windows or the system-SSH transport. - */ -export function createRelayInstallMarkerCommand( - namespace: RelayInstallNamespace, - host: RemoteHostPlatform, - shellRelayDir: string -): string { - const lockDir = joinRemotePath(host, shellRelayDir, RELAY_INSTALL_LOCK_NAME) - const markerPath = relayInstallMarkerShellPath(namespace, host, shellRelayDir) - return `${makeRemoteDirectoryCommand(host, lockDir)} && umask 077 && touch ${shellEscape(markerPath)}` -} - -/** - * First-install directory creation, folded together with marker creation so a - * standard install spends no extra exec channel on namespace discovery. - */ -export function makeRelayInstallDirectoryCommand( - host: RemoteHostPlatform, - shellRelayDir: string, - namespace?: RelayInstallNamespace -): string { - const makeDir = makeRemoteDirectoryCommand(host, shellRelayDir) - if (!namespace) { - return makeDir - } - return `${makeDir} && ${createRelayInstallMarkerCommand(namespace, host, shellRelayDir)}` -} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-commands.ts b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts deleted file mode 100644 index f2ce32a2222..00000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-commands.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * The remote shell for the shared native-deps cache (`ssh-relay-native-deps-cache.ts`). - * - * Every script here is POSIX `sh` and answers with one token, because the only alternative to a - * token is inferring success from an exit status the transport can also produce. A command that - * cannot answer is a cache miss, never a licence to delete: `MISS` and `NOT_PROMOTED` both leave - * the relay directory owning its own `node_modules`, which is exactly today's behaviour. - */ -import { shellEscape } from './ssh-connection-utils' -import { - RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME, - RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, - LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, - relayNativeDepsCacheBaseDir, - relayNativeDepsCacheEntryDir, - relayNativeDepsCacheNodeModulesPath, - remoteInstallRootDir -} from './ssh-relay-native-deps-cache' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -export const RELAY_NATIVE_CACHE_LINKED = '__ORCA_NATIVE_CACHE__LINKED' -export const RELAY_NATIVE_CACHE_SEEDED = '__ORCA_NATIVE_CACHE__SEEDED' -export const RELAY_NATIVE_CACHE_MISS = '__ORCA_NATIVE_CACHE__MISS' -export const RELAY_NATIVE_CACHE_PROMOTED = '__ORCA_NATIVE_CACHE__PROMOTED' -export const RELAY_NATIVE_CACHE_NOT_PROMOTED = '__ORCA_NATIVE_CACHE__NOT_PROMOTED' -export const RELAY_NATIVE_CACHE_LIST_OK = '__ORCA_NATIVE_CACHE__LIST_OK' -export const RELAY_NATIVE_CACHE_REFS_OK = '__ORCA_NATIVE_CACHE__REFS_OK' -export const RELAY_NATIVE_CACHE_REFS_ERR = '__ORCA_NATIVE_CACHE__REFS_ERR' - -/** - * How old an entry without `.deps-complete` must be before another deploy may reclaim it. Well - * past the 15-minute deploy ceiling, so a live installer is never mistaken for a crashed one. - * Reclaiming is safe at any age in principle — nothing links an entry until it is complete — but - * the margin is what keeps that argument from resting on a single `[ -f ]`. - */ -const CACHE_TAKEOVER_MINUTES = 120 - -/** Bounds every listing, matching `MAX_RELAY_GC_LISTING_ENTRIES`' role for version dirs. */ -export const MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES = 64 - -export type RelayNativeDepsCachePaths = { - host: RemoteHostPlatform - remoteHome: string - relayDir: string - key: string -} - -function cachePaths(paths: RelayNativeDepsCachePaths): { - base: string - entry: string - target: string - nodeModules: string - root: string -} { - const { host, remoteHome, relayDir, key } = paths - return { - base: relayNativeDepsCacheBaseDir(host, remoteHome), - entry: relayNativeDepsCacheEntryDir(host, remoteHome, key), - target: relayNativeDepsCacheNodeModulesPath(host, remoteHome, key), - nodeModules: joinRemotePath(host, relayDir, 'node_modules'), - root: remoteInstallRootDir(host, remoteHome) - } -} - -/** - * Link a complete entry into the relay directory, or seed a private tree from a sibling relay - * directory that already has a matching one. - * - * The seed exists so the first deploy after this ships does not recompile once more on a host - * that already paid for the compile. It is not trusted: the copy is a plain private install until - * the normal probe loads both addons, and only then is it promoted. - */ -export function ensureRelayNativeDepsCacheCommand( - paths: RelayNativeDepsCachePaths, - deps: Readonly> -): string { - const { entry, target, nodeModules, root } = cachePaths(paths) - // Why grep the sibling's manifest: an older Orca pinned different versions, and a - // toolchain-skip host wrote one with node-pty removed. Both must fail to qualify. - const depGuards = Object.entries(deps).map( - ([name, version]) => `grep -F -q ${shellEscape(`"${name}":"${version}"`)} "$pj" || continue` - ) - return [ - `cache=${shellEscape(entry)}`, - `target=${shellEscape(target)}`, - `nm=${shellEscape(nodeModules)}`, - `root=${shellEscape(root)}`, - `if [ -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] && [ -d "$target" ]; then`, - ' if [ -L "$nm" ]; then', - ` if [ "$(readlink "$nm" 2>/dev/null)" = "$target" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_LINKED}; exit 0; fi`, - ' rm -f "$nm" 2>/dev/null || true', - ' fi', - ` if [ ! -e "$nm" ] && ln -s "$target" "$nm" 2>/dev/null; then printf '%s\\n' ${RELAY_NATIVE_CACHE_LINKED}; exit 0; fi`, - ` printf '%s\\n' ${RELAY_NATIVE_CACHE_MISS}; exit 0`, - 'fi', - 'if [ ! -e "$nm" ] && [ ! -L "$nm" ]; then', - ' for cand in "$root"/relay-*/node_modules; do', - ' [ -d "$cand" ] || continue', - ' [ -L "$cand" ] && continue', - ' [ -d "$cand/node-pty" ] || continue', - ' [ -d "$cand/@parcel/watcher" ] || continue', - ' pj="${cand%/node_modules}/package.json"', - ' [ -f "$pj" ] || continue', - ...depGuards.map((guard) => ` ${guard}`), - ' seed="$nm.seed.$$"', - ' rm -rf "$seed" 2>/dev/null || true', - ' if cp -Rp "$cand" "$seed" 2>/dev/null && mv "$seed" "$nm" 2>/dev/null; then', - ` printf '%s\\n' ${RELAY_NATIVE_CACHE_SEEDED}; exit 0`, - ' fi', - ' rm -rf "$seed" 2>/dev/null || true', - ' break', - ' done', - 'fi', - `printf '%s\\n' ${RELAY_NATIVE_CACHE_MISS}` - ].join('\n') -} - -/** - * Publish a probe-verified private tree as the shared entry, then link the relay directory to it. - * - * `mkdir "$cache"` is the election: exactly one deploy creates the directory, and a loser keeps - * its own tree rather than writing into someone else's. `.deps-complete` is written last, after - * the symlink exists, so an entry is never linkable before it is referenced. - */ -export function promoteRelayNativeDepsCacheCommand(paths: RelayNativeDepsCachePaths): string { - const { base, entry, target, nodeModules } = cachePaths(paths) - const notPromoted = `printf '%s\\n' ${RELAY_NATIVE_CACHE_NOT_PROMOTED}` - return [ - `base=${shellEscape(base)}`, - `cache=${shellEscape(entry)}`, - `target=${shellEscape(target)}`, - `nm=${shellEscape(nodeModules)}`, - `[ -d "$nm" ] || { ${notPromoted}; exit 0; }`, - `if [ -L "$nm" ]; then ${notPromoted}; exit 0; fi`, - `mkdir -p "$base" 2>/dev/null || { ${notPromoted}; exit 0; }`, - `if [ -d "$cache" ] && [ ! -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ]; then`, - ` if [ -n "$(find "$cache" -maxdepth 0 -mmin +${CACHE_TAKEOVER_MINUTES} 2>/dev/null)" ]; then`, - ' rm -rf "$cache" 2>/dev/null || true', - ' fi', - 'fi', - `mkdir "$cache" 2>/dev/null || { ${notPromoted}; exit 0; }`, - 'if mv "$nm" "$target" 2>/dev/null; then', - ' if ln -s "$target" "$nm" 2>/dev/null; then', - ` : > "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" 2>/dev/null || true`, - ` if [ -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_PROMOTED}; exit 0; fi`, - ' fi', - ' rm -f "$nm" 2>/dev/null || true', - // Why the rm is conditional on the move back: a failed restore leaves the only copy of the - // tree inside an incomplete entry. Deleting it there would cost the relay its native deps. - ' if mv "$target" "$nm" 2>/dev/null; then rm -rf "$cache" 2>/dev/null || true; fi', - ` ${notPromoted}; exit 0`, - 'fi', - 'rm -rf "$cache" 2>/dev/null || true', - notPromoted - ].join('\n') -} - -/** Complete entries and tombstones; deletion requires a separate reference scan. */ -export function listRelayNativeDepsCacheEntriesCommand( - host: RemoteHostPlatform, - remoteHome: string -): string { - const base = relayNativeDepsCacheBaseDir(host, remoteHome) - return [ - `base=${shellEscape(base)}`, - `[ -d "$base" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_LIST_OK}; exit 0; }`, - 'n=0', - `for d in "$base"/*/ "$base"/${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*/ "$base"/${LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*/; do`, - ' [ -d "$d" ] || continue', - ' name=${d%/}', - ' name=${name##*/}', - ` case "$name" in ${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*|${LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*) ;; *) [ -f "$d${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] || continue ;; esac`, - ` printf 'ENTRY %s\\n' "$name"`, - ' n=$((n+1))', - ` if [ "$n" -ge ${MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES} ]; then break; fi`, - 'done', - `printf '%s\\n' ${RELAY_NATIVE_CACHE_LIST_OK}` - ].join('\n') -} - -/** - * Every symlinked `node_modules` under `~/.orca-remote/`, as its raw target. - * - * The scan is deliberately wider than `relay-*`: a directory this client does not recognise still - * counts as a referrer. An unreadable link or an overrun listing answers `REFS_ERR`, which stops - * the whole pass — an incomplete reference list is not evidence that anything is unreferenced. - */ -export function listRelayNativeDepsCacheReferencesCommand( - host: RemoteHostPlatform, - remoteHome: string -): string { - const root = remoteInstallRootDir(host, remoteHome) - return [ - `root=${shellEscape(root)}`, - `[ -d "$root" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_OK}; exit 0; }`, - `[ -r "$root" ] && [ -x "$root" ] && ls -A "$root" >/dev/null 2>&1 || { printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; }`, - 'n=0', - 'for d in "$root"/*/; do', - ' [ -d "$d" ] || continue', - ` [ -r "$d" ] && [ -x "$d" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; }`, - ' d="${d}node_modules"', - ' [ -L "$d" ] || continue', - ' t=$(readlink "$d" 2>/dev/null) || t=""', - ` if [ -z "$t" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; fi`, - ` printf 'REF %s\\n' "$t"`, - ' n=$((n+1))', - ` if [ "$n" -ge ${MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES} ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; fi`, - 'done', - `printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_OK}` - ].join('\n') -} - -/** A missing completion marker means deletion began; leave that tree for later cleanup. */ -export function restoreRelayNativeDepsCacheTombstoneCommand( - tombstone: string, - entryDir: string -): string { - const source = shellEscape(tombstone) - const destination = shellEscape(entryDir) - return `if [ -f ${source}/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME} ] && [ ! -e ${destination} ] && [ ! -L ${destination} ]; then mv ${source} ${destination} && echo MOVED; else echo BUSY; fi` -} - -/** A symlinked tombstone is never followed, so only its own marker can be dropped. */ -export function dropRelayNativeDepsCacheCompletionMarkerCommand(tombstone: string): string { - const source = shellEscape(tombstone) - return `{ [ -L ${source} ] || rm -f ${source}/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}; }` -} - -/** Drops the completion marker first so an interrupted deletion can never be restored as complete. */ -export function removeRelayNativeDepsCacheTombstoneCommand(tombstone: string): string { - return `${dropRelayNativeDepsCacheCompletionMarkerCommand(tombstone)} && rm -rf ${shellEscape(tombstone)}` -} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts deleted file mode 100644 index 21a21968642..00000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - readlinkSync, - renameSync, - rmSync, - symlinkSync, - utimesSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from '@orca/process-host' - -const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execMock })) - -import type { SshConnection } from './ssh-connection' -import { shellEscape } from './ssh-connection-utils' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' -import { listRelayNativeDepsCacheEntriesCommand } from './ssh-relay-native-deps-cache-commands' -import { - relayNativeDepsCacheBaseDir, - relayNativeDepsCacheEntryDir, - LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, - RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX -} from './ssh-relay-native-deps-cache' - -const host = getRemoteHostPlatform('linux-x64') -const key = 'linux-x64-0123456789abcdef' -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand is replaced by a local shell for every connection access. -const conn = {} as SshConnection -const shells = ['/bin/sh', '/bin/dash'].filter(existsSync) - -describe.runIf(process.platform !== 'win32').each(shells)( - 'native cache GC recovery (%s)', - (shell) => { - let home: string - let cache: string - let entry: string - let relay: string - - function runShell(command: string): string { - const result = runProcessSync({ program: shell, args: ['-c', command], timeoutMs: 15_000 }) - if (result.code !== 0) { - throw new Error(result.stderr || `Shell exited ${result.code}`) - } - return result.stdout - } - - function tombstone( - timestamp = Date.now() - 60 * 60_000, - prefix = RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX - ): string { - const path = join(cache, `${prefix}${key}.123.${timestamp}`) - renameSync(entry, path) - return path - } - - function referenceEntry(): void { - symlinkSync(join(entry, 'node_modules'), join(relay, 'node_modules')) - } - - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'orca native gc ')) - cache = relayNativeDepsCacheBaseDir(host, home) - entry = relayNativeDepsCacheEntryDir(host, home, key) - relay = join(home, '.orca-remote', 'relay-0.1.0+abc') - mkdirSync(relay, { recursive: true }) - mkdirSync(join(entry, 'node_modules'), { recursive: true }) - writeFileSync(join(entry, 'node_modules', 'addon.node'), 'original') - writeFileSync(join(entry, '.deps-complete'), '') - utimesSync(entry, new Date(0), new Date(0)) - execMock - .mockReset() - .mockImplementation(async (_conn: unknown, command: string) => runShell(command)) - }) - - afterEach(() => { - chmodSync(join(home, '.orca-remote'), 0o755) - chmodSync(relay, 0o755) - rmSync(home, { recursive: true, force: true }) - }) - - function tombstones(): string[] { - return readdirSync(cache).filter((name) => - name.startsWith(RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX) - ) - } - - it('lists an ancient-mtime tombstone without deleting it and restores its live reference', async () => { - referenceEntry() - const path = tombstone() - - expect(runShell(listRelayNativeDepsCacheEntriesCommand(host, home))).toContain( - `ENTRY ${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}` - ) - expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(readFileSync(join(relay, 'node_modules', 'addon.node'), 'utf8')).toBe('original') - expect(readdirSync(cache)).toEqual([key]) - }) - - async function runLaterPass(): Promise { - const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31 * 60_000) - try { - await gcRelayNativeDepsCache(conn, host, home) - } finally { - clock.mockRestore() - } - } - - it('claims a referenced legacy tombstone out of old sweeps but never restores it', async () => { - referenceEntry() - const path = tombstone( - Date.now() - 60 * 60_000, - LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX - ) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(existsSync(path)).toBe(false) - const [claimed] = tombstones() - expect(existsSync(join(cache, claimed, 'node_modules', 'addon.node'))).toBe(true) - expect(existsSync(join(cache, claimed, '.deps-complete'))).toBe(false) - - await runLaterPass() - - expect(existsSync(entry)).toBe(false) - expect(tombstones()).toHaveLength(1) - }) - - it('keeps a legacy tree unrestorable when its claim is interrupted after unmarking', async () => { - referenceEntry() - const path = tombstone( - Date.now() - 60 * 60_000, - LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX - ) - execMock.mockImplementation(async (_conn: unknown, command: string) => { - if (command.startsWith(`mv ${shellEscape(path)}`)) { - throw new Error('claim interrupted') - } - return runShell(command) - }) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) - expect(existsSync(join(path, '.deps-complete'))).toBe(false) - - execMock.mockImplementation(async (_conn: unknown, command: string) => runShell(command)) - await runLaterPass() - await runLaterPass() - - expect(existsSync(entry)).toBe(false) - }) - - it('collects an unreferenced legacy tombstone', async () => { - tombstone(Date.now() - 60 * 60_000, LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(readdirSync(cache)).toEqual([]) - }) - - it('lets exactly one of two passes claim the same abandoned tombstone', async () => { - const path = tombstone() - let releaseClaim!: () => void - const claimHeld = new Promise((resolve) => { - releaseClaim = resolve - }) - let heldPassReachedClaim!: () => void - const reachedClaim = new Promise((resolve) => { - heldPassReachedClaim = resolve - }) - let held = false - execMock.mockImplementation(async (_conn: unknown, command: string) => { - if (!held && command.startsWith(`mv ${shellEscape(path)}`)) { - held = true - heldPassReachedClaim() - await claimHeld - } - return runShell(command) - }) - - // The held pass sees the tree as unreferenced; the other pins its key and restores it. - const heldPass = gcRelayNativeDepsCache(conn, host, home) - await reachedClaim - await gcRelayNativeDepsCache(conn, host, home, { pinnedKeys: [key] }) - releaseClaim() - await heldPass - - expect(readdirSync(cache)).toEqual([key]) - expect(readFileSync(join(entry, 'node_modules', 'addon.node'), 'utf8')).toBe('original') - expect(execMock.mock.calls.some(([, command]) => String(command).includes('rm -rf'))).toBe( - false - ) - }) - - it('does not let another pass resurrect a tombstone while its owner deletes it', async () => { - tombstone() - let otherPass: Promise | undefined - execMock.mockImplementation(async (_conn: unknown, command: string) => { - if (!otherPass && command.includes('rm -rf')) { - // Simulate the owner's deletion being midway: marker and one file already gone. - const [owned] = tombstones() - rmSync(join(cache, owned, '.deps-complete')) - rmSync(join(cache, owned, 'node_modules', 'addon.node')) - referenceEntry() - // A later pass on another client sees the owned name as stale and wants the key back. - const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31 * 60_000) - try { - otherPass = gcRelayNativeDepsCache(conn, host, home, { pinnedKeys: [key] }) - await otherPass - } finally { - clock.mockRestore() - } - } - return runShell(command) - }) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(otherPass).toBeDefined() - expect(existsSync(entry)).toBe(false) - expect( - tombstones().filter((name) => existsSync(join(cache, name, '.deps-complete'))) - ).toEqual([]) - }) - - it('never restores a tree whose deletion was interrupted, even once its name is stale', async () => { - tombstone() - const interrupted = new Error('rm interrupted') - execMock.mockImplementation(async (_conn: unknown, command: string) => { - if (command.includes('rm -rf')) { - runShell(command.slice(0, command.indexOf(' && '))) - const [owned] = tombstones() - rmSync(join(cache, owned, 'node_modules', 'addon.node')) - throw interrupted - } - return runShell(command) - }) - await gcRelayNativeDepsCache(conn, host, home) - const [owned] = tombstones() - expect(existsSync(join(cache, owned, 'node_modules'))).toBe(true) - - referenceEntry() - execMock.mockImplementation(async (_conn: unknown, command: string) => runShell(command)) - await runLaterPass() - - expect(existsSync(entry)).toBe(false) - expect(tombstones()).toHaveLength(1) - expect(existsSync(join(cache, tombstones()[0], '.deps-complete'))).toBe(false) - }) - - it('preserves a referenced tombstone when the clock moves backward during the reference scan', async () => { - referenceEntry() - const now = Date.now() - const path = tombstone(now - 30 * 60_000 - 1) - const clock = vi.spyOn(Date, 'now').mockReturnValue(now) - execMock.mockImplementation(async (_conn: unknown, command: string) => { - if (command.includes('readlink')) { - clock.mockReturnValue(now - 60_000) - } - return runShell(command) - }) - try { - await gcRelayNativeDepsCache(conn, host, home) - expect(readFileSync(join(relay, 'node_modules', 'addon.node'), 'utf8')).toBe('original') - expect(existsSync(path)).toBe(false) - } finally { - clock.mockRestore() - } - }) - - it('keeps new tombstones outside an old client mtime-only deletion sweep', () => { - const path = tombstone() - const legacy = join(cache, `${LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}${key}.123.1`) - mkdirSync(legacy) - utimesSync(legacy, new Date(0), new Date(0)) - - runShell( - `find ${shellEscape(cache)} -maxdepth 1 -name '.gc-tombstone.*' -mmin +30 -exec rm -rf {} +` - ) - - expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) - expect(existsSync(legacy)).toBe(false) - }) - - it.skipIf(process.getuid?.() === 0).each(['root', 'relay'])( - 'preserves tombstones when the %s listing is unreadable', - async (scope) => { - const path = tombstone() - referenceEntry() - chmodSync(scope === 'root' ? join(home, '.orca-remote') : relay, 0o111) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) - expect(execMock.mock.calls.some(([, command]) => String(command).includes('rm -rf'))).toBe( - false - ) - } - ) - - it('preserves tombstones when a reference cannot be attributed', async () => { - const path = tombstone() - symlinkSync('../unknown/node_modules', join(relay, 'node_modules')) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) - expect(execMock).toHaveBeenCalledTimes(2) - }) - - it('collects a stale tombstone only after two complete reference scans', async () => { - const path = tombstone() - - await gcRelayNativeDepsCache(conn, host, home) - - expect(existsSync(path)).toBe(false) - expect( - execMock.mock.calls.filter(([, command]) => String(command).includes('readlink')) - ).toHaveLength(2) - }) - - it('collects an interrupted tombstone deletion after its completion marker is gone', async () => { - const path = tombstone() - rmSync(join(path, '.deps-complete')) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(existsSync(path)).toBe(false) - }) - - it('leaves incomplete ordinary entries owned by installers alone', async () => { - rmSync(join(entry, '.deps-complete')) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(readFileSync(join(entry, 'node_modules', 'addon.node'), 'utf8')).toBe('original') - expect(execMock).toHaveBeenCalledTimes(1) - }) - - it.each([() => Date.now(), () => Date.now() + 60 * 60_000, () => Number.MAX_SAFE_INTEGER + 1])( - 'keeps tombstones with a recent, future, or unsafe name timestamp despite ancient mtime', - async (timestamp) => { - const path = tombstone(timestamp()) - - await gcRelayNativeDepsCache(conn, host, home) - - expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) - expect(execMock).toHaveBeenCalledTimes(1) - } - ) - - it('restores a link created during rename when the recheck termination is unconfirmed', async () => { - const error = Object.assign(new Error('read-only recheck timed out'), { - sshChannelCloseConfirmed: false - }) - let scans = 0 - execMock.mockImplementation(async (_conn: unknown, command: string) => { - if (command.includes('readlink') && ++scans === 2) { - throw error - } - const output = runShell(command) - if (command.startsWith('mv ')) { - referenceEntry() - } - return output - }) - - await expect(gcRelayNativeDepsCache(conn, host, home)).rejects.toBe(error) - - expect(readFileSync(join(relay, 'node_modules', 'addon.node'), 'utf8')).toBe('original') - expect(readdirSync(cache)).toEqual([key]) - expect(execMock).toHaveBeenCalledTimes(5) - }) - - it.each(['directory', 'dangling symlink'])( - 'does not overwrite or nest inside a recreated %s', - async (kind) => { - referenceEntry() - const path = tombstone() - if (kind === 'directory') { - mkdirSync(entry) - writeFileSync(join(entry, 'replacement'), 'new') - } else { - symlinkSync(join(cache, 'missing'), entry) - } - - await gcRelayNativeDepsCache(conn, host, home) - - expect(existsSync(path)).toBe(false) - const [claimed] = tombstones() - expect(readFileSync(join(cache, claimed, 'node_modules', 'addon.node'), 'utf8')).toBe( - 'original' - ) - if (kind === 'directory') { - expect(readdirSync(entry)).toEqual(['replacement']) - } else { - expect(readlinkSync(entry)).toBe(join(cache, 'missing')) - } - } - ) - } -) diff --git a/src/main/ssh/ssh-relay-native-deps-cache-gc.ts b/src/main/ssh/ssh-relay-native-deps-cache-gc.ts deleted file mode 100644 index a6f4ef4598c..00000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-gc.ts +++ /dev/null @@ -1,355 +0,0 @@ -/** - * Garbage collection for the shared native-deps cache. - * - * This is the part that can hurt: a cache entry is the only copy of node-pty for every relay - * directory that links to it, so a wrong deletion takes native modules away from a running relay. - * The discipline is `remote-install-gc.ts`': **an unanswered probe blocks deletion.** A listing - * that does not end in its own OK token, a symlink whose target will not read, a reference whose - * shape this client does not recognise — each aborts the entire pass rather than narrowing it. - * Loss of contact is never evidence that a tree is unreferenced - * (`docs/reference/ssh-execution-boundary.md`). - * - * Deletion is then the same three-step move `remote-install-gc.ts` uses for version dirs: rename - * to a tombstone, re-read the references under the rename, and only then remove. A deploy that - * linked the entry between the first listing and the rename shows up in the recheck, and its tree - * is moved back. - */ -import { randomInt } from 'node:crypto' -import type { RemoteInstallTransport } from './remote-install-transport' -import { execHostCommand } from './ssh-relay-host-exec' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { - isRelayNativeDepsCacheEntryName, - relayNativeDepsCacheBaseDir, - relayNativeDepsCacheNodeModulesPath, - supportsRelayNativeDepsCache, - RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, - LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX -} from './ssh-relay-native-deps-cache' -import { - listRelayNativeDepsCacheEntriesCommand, - listRelayNativeDepsCacheReferencesCommand, - MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES, - RELAY_NATIVE_CACHE_LIST_OK, - RELAY_NATIVE_CACHE_REFS_OK, - dropRelayNativeDepsCacheCompletionMarkerCommand, - removeRelayNativeDepsCacheTombstoneCommand, - restoreRelayNativeDepsCacheTombstoneCommand -} from './ssh-relay-native-deps-cache-commands' -import { moveRemoteTreeCommand } from './ssh-remote-commands' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -type ReferenceScan = - | { readable: true; referencedKeys: Set } - /** Anything this client could not fully account for. No entry may be deleted on it. */ - | { readable: false } - -function staleTombstoneKey(name: string): string | null { - const prefix = [ - RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, - LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX - ].find((value) => name.startsWith(value)) - if (!prefix) { - return null - } - const match = /^(.*)\.(\d+)\.(\d+)$/.exec(name.slice(prefix.length)) - if ( - !match || - !isRelayNativeDepsCacheEntryName(match[1]) || - !Number.isSafeInteger(Number(match[3])) || - Date.now() - Number(match[3]) < 30 * 60_000 - ) { - return null - } - return match[1] -} - -/** - * Remove complete cache entries that nothing links to. - * - * `pinnedKeys` is the connection's own key. The referencing symlink is written before the entry - * becomes listable, so a live entry is already protected by the reference scan; the pin is there - * so a deploy that fell back to a per-directory install cannot have its key deleted underneath a - * retry either. - */ -export async function gcRelayNativeDepsCache( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - remoteHome: string, - options?: { pinnedKeys?: readonly string[] } -): Promise { - if (!supportsRelayNativeDepsCache(host)) { - return - } - const base = relayNativeDepsCacheBaseDir(host, remoteHome) - let entries: CacheEntry[] - try { - entries = parseCacheEntryListing( - await execHostCommand(conn, host, listRelayNativeDepsCacheEntriesCommand(host, remoteHome)) - ) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return - } - if (entries.length === 0) { - return - } - const scan = await scanCacheReferences(conn, host, remoteHome) - if (!scan.readable) { - return - } - const pinned = new Set(options?.pinnedKeys ?? []) - const removed: string[] = [] - for (const { name, key } of entries) { - const keep = scan.referencedKeys.has(key) || pinned.has(key) - const collected = - name === key - ? !keep && (await removeUnreferencedCacheEntry(conn, host, remoteHome, base, key)) - : await recoverAbandonedTombstone(conn, host, remoteHome, base, name, key, keep) - if (collected) { - removed.push(key) - } - } - if (removed.length > 0) { - console.log( - `[relay] native-deps cache GC: removed ${removed.length} entry(ies): ${removed.join(', ')}` - ) - } -} - -// Why random rather than pid + clock: passes on different clients must never rename into one path. -function ownedTombstonePath(host: RemoteHostPlatform, base: string, key: string): string { - const name = `${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}${key}.${randomInt(1, 2 ** 47)}.${Date.now()}` - return joinRemotePath(host, base, name) -} - -async function moveTree( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - source: string, - destination: string -): Promise { - try { - const moved = await execHostCommand( - conn, - host, - moveRemoteTreeCommand(host, source, destination) - ) - return moved.trim() === 'MOVED' - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return false - } -} - -async function removeUnreferencedCacheEntry( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - remoteHome: string, - base: string, - key: string -): Promise { - const entryDir = joinRemotePath(host, base, key) - const tombstone = ownedTombstonePath(host, base, key) - if (!(await moveTree(conn, host, entryDir, tombstone))) { - return false - } - return collectOwnedTombstone(conn, host, remoteHome, key, tombstone, entryDir) -} - -/** - * Another pass may list the same abandoned tombstone, so only the pass whose rename wins may - * restore or delete it; the fresh name keeps every other pass off it for the stale window. - */ -async function recoverAbandonedTombstone( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - remoteHome: string, - base: string, - name: string, - key: string, - keep: boolean -): Promise { - const source = joinRemotePath(host, base, name) - // Why unmark legacy trees before claiming them: an old client's mtime sweep may have left a - // partial tree with its marker, and the marker is all a later pass checks before restoring. - if ( - name.startsWith(LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX) && - !(await runOrDecline(conn, host, dropRelayNativeDepsCacheCompletionMarkerCommand(source))) - ) { - return false - } - const tombstone = ownedTombstonePath(host, base, key) - if (!(await moveTree(conn, host, source, tombstone))) { - return false - } - const entryDir = joinRemotePath(host, base, key) - if (keep) { - await restoreCacheEntry(conn, host, tombstone, entryDir) - return false - } - return collectOwnedTombstone(conn, host, remoteHome, key, tombstone, entryDir) -} - -async function collectOwnedTombstone( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - remoteHome: string, - key: string, - tombstone: string, - entryDir: string -): Promise { - // Why recheck under the rename: a deploy that read `.deps-complete` before it moved can still - // be creating its symlink. Its reference now names a path that no longer exists, so restoring - // the tree is the only outcome that leaves that relay with working native deps. - const recheck = await scanCacheReferences(conn, host, remoteHome).catch(async (err: unknown) => { - // A read-only scan cannot conflict with restoring this pass's renamed tree. - await restoreCacheEntry(conn, host, tombstone, entryDir).catch(() => {}) - throw err - }) - if (!recheck.readable || recheck.referencedKeys.has(key)) { - await restoreCacheEntry(conn, host, tombstone, entryDir) - return false - } - // A failed removal is retried by a later pass after its own recheck. - return runOrDecline(conn, host, removeRelayNativeDepsCacheTombstoneCommand(tombstone)) -} - -async function runOrDecline( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - command: string -): Promise { - try { - await execHostCommand(conn, host, command) - return true - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return false - } -} - -async function restoreCacheEntry( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - tombstone: string, - entryDir: string -): Promise { - await execHostCommand( - conn, - host, - restoreRelayNativeDepsCacheTombstoneCommand(tombstone, entryDir) - ).catch((err) => { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - }) -} - -async function scanCacheReferences( - conn: RemoteInstallTransport, - host: RemoteHostPlatform, - remoteHome: string -): Promise { - let output: string - try { - output = await execHostCommand( - conn, - host, - listRelayNativeDepsCacheReferencesCommand(host, remoteHome) - ) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return { readable: false } - } - const lines = output.split(/\r?\n/).map((line) => line.trim()) - if (!lines.includes(RELAY_NATIVE_CACHE_REFS_OK)) { - return { readable: false } - } - const referencedKeys = new Set() - const base = relayNativeDepsCacheBaseDir(host, remoteHome) - for (const line of lines) { - if (!line.startsWith('REF ')) { - continue - } - const attribution = attributeReference(line.slice('REF '.length), base, host, remoteHome) - if (attribution.kind === 'unattributable') { - return { readable: false } - } - if (attribution.kind === 'entry') { - referencedKeys.add(attribution.key) - } - } - return { readable: true, referencedKeys } -} - -type ReferenceAttribution = - | { kind: 'entry'; key: string } - /** A link that points somewhere else entirely; it holds no cache entry alive. */ - | { kind: 'outside' } - | { kind: 'unattributable' } - -/** - * Which cache entry a symlink target names. - * - * A relative target is `unattributable` on purpose. Every link Orca writes is absolute, so a - * relative one is a tree with a history this pass cannot reconstruct, and guessing which entry it - * resolves to is exactly the inference that deletes a live relay's modules. - */ -function attributeReference( - target: string, - base: string, - host: RemoteHostPlatform, - remoteHome: string -): ReferenceAttribution { - if (!target.startsWith('/') || target.includes('/../') || target.endsWith('/..')) { - return { kind: 'unattributable' } - } - if (!target.startsWith(`${base}/`)) { - return { kind: 'outside' } - } - const rest = target.slice(base.length + 1).split('/') - if ( - rest.length !== 2 || - rest[1] !== 'node_modules' || - !isRelayNativeDepsCacheEntryName(rest[0]) - ) { - return { kind: 'unattributable' } - } - // Why rebuild the path rather than trust the split: the target must be exactly what this client - // writes for that key, not merely something that parses into two plausible segments. - return target === relayNativeDepsCacheNodeModulesPath(host, remoteHome, rest[0]) - ? { kind: 'entry', key: rest[0] } - : { kind: 'unattributable' } -} - -type CacheEntry = { name: string; key: string } - -function parseCacheEntryListing(output: string): CacheEntry[] { - const lines = output.split(/\r?\n/).map((line) => line.trim()) - if (!lines.includes(RELAY_NATIVE_CACHE_LIST_OK)) { - return [] - } - const entries: CacheEntry[] = [] - for (const line of lines) { - if (!line.startsWith('ENTRY ')) { - continue - } - const name = line.slice('ENTRY '.length) - // Why re-validate a name the host produced: it is about to be interpolated into `mv` and - // `rm -rf`. Only names this client could itself have minted are eligible. - const key = isRelayNativeDepsCacheEntryName(name) ? name : staleTombstoneKey(name) - if (key && entries.length < MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES) { - entries.push({ name, key }) - } - } - return entries -} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts deleted file mode 100644 index 530eed7238e..00000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -// The cache's safety argument is made of `sh`, not TypeScript: `mkdir` elects the publisher, -// `.deps-complete` gates linking, and a failed publish must put the tree back. Asserting on the -// command strings cannot show any of that, so these run the real scripts against a real tree. - -import { execFileSync } from 'node:child_process' -import { - mkdirSync, - mkdtempSync, - readlinkSync, - rmSync, - statSync, - symlinkSync, - writeFileSync, - existsSync, - lstatSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { - computeRelayNativeDepsCacheKey, - relayNativeDepsCacheEntryDir, - relayNativeDepsCacheNodeModulesPath -} from './ssh-relay-native-deps-cache' -import { - ensureRelayNativeDepsCacheCommand, - listRelayNativeDepsCacheEntriesCommand, - listRelayNativeDepsCacheReferencesCommand, - promoteRelayNativeDepsCacheCommand, - RELAY_NATIVE_CACHE_LINKED, - RELAY_NATIVE_CACHE_LIST_OK, - RELAY_NATIVE_CACHE_MISS, - RELAY_NATIVE_CACHE_NOT_PROMOTED, - RELAY_NATIVE_CACHE_PROMOTED, - RELAY_NATIVE_CACHE_REFS_OK, - RELAY_NATIVE_CACHE_SEEDED -} from './ssh-relay-native-deps-cache-commands' - -const HOST = getRemoteHostPlatform('linux-x64') -const DEPS = { 'node-pty': '1.1.0', '@parcel/watcher': '2.5.6' } as const -const KEY = computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS }) - -// Debian and Ubuntu point /bin/sh at dash, which is stricter than the bash-in-sh-mode that macOS -// ships; run against both when both exist so a bashism cannot pass here and fail on a host. -const SHELLS = ['/bin/sh', '/bin/dash'].filter((shell) => existsSync(shell)) - -describe.runIf(process.platform !== 'win32').each(SHELLS)( - 'relay native-deps cache shell scripts (%s)', - (shell) => { - let home: string - - const relayDir = (version: string): string => join(home, '.orca-remote', `relay-${version}`) - - function sh(command: string): string { - return execFileSync(shell, ['-c', command], { encoding: 'utf-8' }) - } - - /** A relay directory holding its own installed tree, exactly as `npm install` leaves it. */ - function makePrivateInstall(version: string, deps: Record = DEPS): string { - const dir = relayDir(version) - mkdirSync(join(dir, 'node_modules', 'node-pty', 'build'), { recursive: true }) - mkdirSync(join(dir, 'node_modules', '@parcel', 'watcher'), { recursive: true }) - writeFileSync(join(dir, 'node_modules', 'node-pty', 'build', 'pty.node'), 'binary') - writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: deps })) - return dir - } - - function ensure(version: string): string { - return sh( - ensureRelayNativeDepsCacheCommand( - { host: HOST, remoteHome: home, relayDir: relayDir(version), key: KEY }, - DEPS - ) - ).trim() - } - - function promote(version: string): string { - return sh( - promoteRelayNativeDepsCacheCommand({ - host: HOST, - remoteHome: home, - relayDir: relayDir(version), - key: KEY - }) - ).trim() - } - - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'orca-relay-cache-')) - mkdirSync(join(home, '.orca-remote'), { recursive: true }) - }) - - afterEach(() => { - rmSync(home, { recursive: true, force: true }) - }) - - it('publishes a probe-verified tree and links the relay directory to it', () => { - makePrivateInstall('0.1.0+aaa') - - expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_PROMOTED) - - const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) - expect(readlinkSync(join(relayDir('0.1.0+aaa'), 'node_modules'))).toBe(target) - expect(statSync(join(target, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - expect( - existsSync(join(relayNativeDepsCacheEntryDir(HOST, home, KEY), '.deps-complete')) - ).toBe(true) - }) - - it('links a second bundle to the published tree with no install of its own', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - - // A different bundle: fresh directory, no node_modules, nothing installed. - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_LINKED) - - const linked = join(relayDir('0.1.0+bbb'), 'node_modules') - expect(readlinkSync(linked)).toBe(relayNativeDepsCacheNodeModulesPath(HOST, home, KEY)) - expect(statSync(join(linked, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - }) - - it('will not link an entry whose completion sentinel is absent', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - rmSync(join(relayNativeDepsCacheEntryDir(HOST, home, KEY), '.deps-complete')) - - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) - expect(existsSync(join(relayDir('0.1.0+bbb'), 'node_modules'))).toBe(false) - }) - - it('seeds from a sibling install rather than recompiling once more', () => { - makePrivateInstall('0.1.0+aaa') - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_SEEDED) - - const seeded = join(relayDir('0.1.0+bbb'), 'node_modules') - expect(lstatSync(seeded).isSymbolicLink()).toBe(false) - expect(statSync(join(seeded, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - // The source is untouched, so a relay running out of it is unaffected. - expect(existsSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty'))).toBe(true) - }) - - it('refuses to seed from a sibling pinned to different versions', () => { - makePrivateInstall('0.1.0+aaa', { 'node-pty': '1.0.0', '@parcel/watcher': '2.5.6' }) - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) - expect(existsSync(join(relayDir('0.1.0+bbb'), 'node_modules'))).toBe(false) - }) - - it('refuses to seed from a sibling that had node-pty skipped', () => { - makePrivateInstall('0.1.0+aaa') - rmSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty'), { recursive: true }) - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) - }) - - it('leaves a directory that installed for itself alone', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - const own = makePrivateInstall('0.1.0+bbb') - - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) - expect(lstatSync(join(own, 'node_modules')).isSymbolicLink()).toBe(false) - }) - - it('elects exactly one publisher and leaves the loser its own tree', () => { - makePrivateInstall('0.1.0+aaa') - makePrivateInstall('0.1.0+bbb') - - expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_PROMOTED) - expect(promote('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_NOT_PROMOTED) - - // The loser must not have handed its tree to an entry it lost the race for. - const loser = join(relayDir('0.1.0+bbb'), 'node_modules') - expect(lstatSync(loser).isSymbolicLink()).toBe(false) - expect(statSync(join(loser, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - }) - - it('never republishes through a symlink it already holds', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - - expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_NOT_PROMOTED) - expect(statSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty')).isDirectory()).toBe( - true - ) - }) - - it('survives removing a linked relay directory without touching the shared tree', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - ensure('0.1.0+bbb') - - // Exactly what version GC does to an idle directory. - sh(`rm -rf ${JSON.stringify(relayDir('0.1.0+bbb'))}`) - - const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) - expect(statSync(join(target, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - }) - - it('reports the published entry and every symlink that references it', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - - const entries = sh(listRelayNativeDepsCacheEntriesCommand(HOST, home)).trim().split('\n') - expect(entries).toEqual([`ENTRY ${KEY}`, RELAY_NATIVE_CACHE_LIST_OK]) - - const refs = sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim().split('\n') - expect(refs).toEqual([ - `REF ${relayNativeDepsCacheNodeModulesPath(HOST, home, KEY)}`, - RELAY_NATIVE_CACHE_REFS_OK - ]) - }) - - it('reports a symlink no Orca version wrote, so GC can refuse the pass', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - symlinkSync('../relay-0.1.0+aaa/node_modules', join(relayDir('0.1.0+bbb'), 'node_modules')) - - const refs = sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim().split('\n') - expect(refs).toContain('REF ../relay-0.1.0+aaa/node_modules') - expect(refs.at(-1)).toBe(RELAY_NATIVE_CACHE_REFS_OK) - }) - - it('answers cleanly on a host that has never installed anything', () => { - expect(sh(listRelayNativeDepsCacheEntriesCommand(HOST, home)).trim()).toBe( - RELAY_NATIVE_CACHE_LIST_OK - ) - expect(sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim()).toBe( - RELAY_NATIVE_CACHE_REFS_OK - ) - }) - } -) diff --git a/src/main/ssh/ssh-relay-native-deps-cache.test.ts b/src/main/ssh/ssh-relay-native-deps-cache.test.ts deleted file mode 100644 index 7fee587b3ec..00000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -// The cache is shared across every relay directory on a host, so these cover the two things that -// make sharing safe: the key changes when the tree's inputs change, and GC refuses to delete on -// anything short of a complete, attributable reference listing. - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn() -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { - computeRelayNativeDepsCacheKey, - isRelayNativeDepsCacheEntryName, - relayNativeDepsCacheEntryDir, - relayNativeDepsCacheNodeModulesPath, - supportsRelayNativeDepsCache -} from './ssh-relay-native-deps-cache' -import { - ensureRelayNativeDepsCacheCommand, - promoteRelayNativeDepsCacheCommand, - RELAY_NATIVE_CACHE_LIST_OK, - RELAY_NATIVE_CACHE_REFS_ERR, - RELAY_NATIVE_CACHE_REFS_OK -} from './ssh-relay-native-deps-cache-commands' -import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' - -const POSIX = getRemoteHostPlatform('linux-x64') -const WINDOWS = getRemoteHostPlatform('win32-x64') -const HOME = '/home/u' -const DEPS = { 'node-pty': '1.1.0', '@parcel/watcher': '2.5.6' } as const -const KEY = computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS }) -const RELAY_DIR = `${HOME}/.orca-remote/relay-0.1.0+aaa` - -const conn = {} as SshConnection -const mockExec = vi.mocked(execCommand) - -function refsOk(...targets: string[]): string { - return [...targets.map((t) => `REF ${t}`), RELAY_NATIVE_CACHE_REFS_OK].join('\n') -} - -function listing(...keys: string[]): string { - return [...keys.map((k) => `ENTRY ${k}`), RELAY_NATIVE_CACHE_LIST_OK].join('\n') -} - -describe('computeRelayNativeDepsCacheKey', () => { - it('keys on the dependency set, not on the relay bundle', () => { - expect(computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS })).toBe(KEY) - expect( - computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps: { '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' } - }) - ).toBe(KEY) - }) - - it('mints a new entry when a dependency version moves', () => { - expect( - computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps: { ...DEPS, 'node-pty': '1.2.0' } - }) - ).not.toBe(KEY) - }) - - it('mints a new entry when a patch applied to the tree changes', () => { - const withPatch = computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps: DEPS, - patchSources: [{ filename: 'node-pty-1.1.0-patch.cjs', contents: 'a' }] - }) - const withChangedPatch = computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps: DEPS, - patchSources: [{ filename: 'node-pty-1.1.0-patch.cjs', contents: 'b' }] - }) - expect(withPatch).not.toBe(KEY) - expect(withChangedPatch).not.toBe(withPatch) - }) - - it('separates platforms so one host never links another architecture', () => { - expect(computeRelayNativeDepsCacheKey({ platform: 'linux-arm64', deps: DEPS })).not.toBe(KEY) - expect(KEY.startsWith('linux-x64-')).toBe(true) - }) - - it('refuses a platform it cannot recognise rather than building a path from it', () => { - expect(() => computeRelayNativeDepsCacheKey({ platform: '../../etc', deps: DEPS })).toThrow( - /Unsafe relay native-deps cache key/ - ) - expect(isRelayNativeDepsCacheEntryName('../../etc')).toBe(false) - expect(isRelayNativeDepsCacheEntryName(KEY)).toBe(true) - }) - - it('leaves Windows on the per-directory install', () => { - expect(supportsRelayNativeDepsCache(POSIX)).toBe(true) - expect(supportsRelayNativeDepsCache(WINDOWS)).toBe(false) - }) -}) - -describe('ensureRelayNativeDepsCacheCommand', () => { - const command = ensureRelayNativeDepsCacheCommand( - { host: POSIX, remoteHome: HOME, relayDir: RELAY_DIR, key: KEY }, - DEPS - ) - - it('links only an entry that carries the completion sentinel', () => { - expect(command).toContain(`[ -f "$cache/.deps-complete" ]`) - expect(command).toContain('ln -s "$target" "$nm"') - expect(command).toContain(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY)) - }) - - it('never overwrites a directory the relay installed for itself', () => { - // The link is only created on a path that does not exist; a real node_modules reads as a miss. - expect(command).toContain('if [ ! -e "$nm" ] && ln -s "$target" "$nm"') - }) - - it('seeds only from a sibling whose manifest pins the same versions', () => { - for (const [name, version] of Object.entries(DEPS)) { - expect(command).toContain(`grep -F -q '"${name}":"${version}"' "$pj"`) - } - expect(command).toContain('[ -d "$cand/node-pty" ] || continue') - }) -}) - -describe('promoteRelayNativeDepsCacheCommand', () => { - const command = promoteRelayNativeDepsCacheCommand({ - host: POSIX, - remoteHome: HOME, - relayDir: RELAY_DIR, - key: KEY - }) - - it('elects one publisher with mkdir rather than a lock', () => { - expect(command).toContain('mkdir "$cache" 2>/dev/null') - }) - - it('writes the completion sentinel after the symlink exists', () => { - expect(command.indexOf('ln -s "$target" "$nm"')).toBeLessThan( - command.indexOf(': > "$cache/.deps-complete"') - ) - }) - - it('never deletes the entry unless the tree made it back to the relay directory', () => { - expect(command).toContain('if mv "$target" "$nm" 2>/dev/null; then rm -rf "$cache"') - }) - - it('refuses to publish a directory that is already a shared symlink', () => { - expect(command).toContain('if [ -L "$nm" ]; then') - }) -}) - -describe('gcRelayNativeDepsCache', () => { - beforeEach(() => { - mockExec.mockReset().mockResolvedValue('') - }) - - it('removes an entry nothing links to', async () => { - mockExec - .mockResolvedValueOnce(listing(KEY)) - .mockResolvedValueOnce(refsOk()) - .mockResolvedValueOnce('MOVED') - .mockResolvedValueOnce(refsOk()) - .mockResolvedValueOnce('') - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - const last = mockExec.mock.calls.at(-1)?.[1] ?? '' - expect(last).toContain('rm -rf') - expect(last).toContain('.native-gc-') - }) - - it('keeps an entry a live relay depends on', async () => { - mockExec - .mockResolvedValueOnce(listing(KEY)) - .mockResolvedValueOnce(refsOk(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY))) - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - expect(mockExec).toHaveBeenCalledTimes(2) - expect(mockExec.mock.calls.some(([, c]) => c.includes('rm -rf'))).toBe(false) - }) - - it('keeps every entry when the reference listing never answers', async () => { - mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce('REF /somewhere\n') - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - expect(mockExec).toHaveBeenCalledTimes(2) - }) - - it('keeps every entry when the reference scan reports an unreadable link', async () => { - mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce(RELAY_NATIVE_CACHE_REFS_ERR) - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - expect(mockExec).toHaveBeenCalledTimes(2) - }) - - it('keeps every entry when a reference has a shape this client never writes', async () => { - // A relative target cannot be attributed to an entry without guessing what it resolves to. - mockExec - .mockResolvedValueOnce(listing(KEY)) - .mockResolvedValueOnce(refsOk('../native/x/node_modules')) - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - expect(mockExec).toHaveBeenCalledTimes(2) - }) - - it('ignores a link that points outside the cache entirely', async () => { - mockExec - .mockResolvedValueOnce(listing(KEY)) - .mockResolvedValueOnce(refsOk('/opt/shared/node_modules')) - .mockResolvedValueOnce('MOVED') - .mockResolvedValueOnce(refsOk('/opt/shared/node_modules')) - .mockResolvedValueOnce('') - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - expect(mockExec.mock.calls.some(([, c]) => c.includes('rm -rf'))).toBe(true) - }) - - it('restores the tree when a deploy links the entry after the tombstone rename', async () => { - mockExec - .mockResolvedValueOnce(listing(KEY)) - .mockResolvedValueOnce(refsOk()) - .mockResolvedValueOnce('MOVED') - .mockResolvedValueOnce(refsOk(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY))) - .mockResolvedValueOnce('MOVED') - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - const last = mockExec.mock.calls.at(-1)?.[1] ?? '' - expect(last).toContain('mv ') - expect(last).toContain(relayNativeDepsCacheEntryDir(POSIX, HOME, KEY)) - expect(mockExec.mock.calls.some(([, c]) => c.includes('rm -rf'))).toBe(false) - }) - - it('restores the tree when the recheck itself cannot answer', async () => { - mockExec - .mockResolvedValueOnce(listing(KEY)) - .mockResolvedValueOnce(refsOk()) - .mockResolvedValueOnce('MOVED') - .mockRejectedValueOnce(new Error('channel closed')) - .mockResolvedValueOnce('MOVED') - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - expect(mockExec.mock.calls.some(([, c]) => c.includes('rm -rf'))).toBe(false) - }) - - it('never removes a pinned key', async () => { - mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce(refsOk()) - - await gcRelayNativeDepsCache(conn, POSIX, HOME, { pinnedKeys: [KEY] }) - - expect(mockExec).toHaveBeenCalledTimes(2) - }) - - it('drops a listed name it could not have minted rather than interpolating it', async () => { - mockExec - .mockResolvedValueOnce(`ENTRY ../../.ssh\n${RELAY_NATIVE_CACHE_LIST_OK}`) - .mockResolvedValueOnce(refsOk()) - - await gcRelayNativeDepsCache(conn, POSIX, HOME) - - expect(mockExec).toHaveBeenCalledTimes(1) - }) - - it('does nothing on a host that never creates entries', async () => { - await gcRelayNativeDepsCache(conn, WINDOWS, 'C:\\Users\\u') - - expect(mockExec).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache.ts b/src/main/ssh/ssh-relay-native-deps-cache.ts deleted file mode 100644 index 79f04221c01..00000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Where the relay's compiled native dependencies live, and what their identity is keyed on. - * - * A relay install directory is keyed on the JS bundle hash, which moves on every commit to - * `src/relay/` or the `src/shared/` it pulls in. `node_modules` used to live inside it, so a - * dependency set that is a pinned constant (`RELAY_NATIVE_DEPS`) was reinstalled — and on Linux, - * where node-pty ships no prebuild, recompiled from source — on every new bundle (#18009). The - * directory key was coupled to the wrong quantity. - * - * The tree now lives at `~/.orca-remote/native/-/node_modules` and each - * relay directory holds a symlink to it. Three rules make one tree safe to share: - * - * 1. **A published entry is immutable.** `.deps-complete` is written last, only after a probe on - * this host loaded both addons. Nothing installs, rebuilds or resets into a published entry: a - * repair detaches the symlink and installs privately, so a host with a broken toolchain can - * never `rm -rf node_modules/node-pty` out from under a live relay that shares the tree. - * 2. **Publication elects one winner with `mkdir`.** The entry either does not exist (this deploy - * builds it privately and promotes it) or is already complete (this deploy links it). There is - * no window in which two deploys write one tree, so no client-side lock is needed. - * 3. **Every failure degrades to today's per-directory install.** A host that cannot symlink, - * cannot create the directory, or answers nothing still deploys, one bundle at a time. - * - * Windows is deliberately excluded. node-pty's npm tarball ships win32 prebuilts, so there is no - * compile to avoid there, and `node-pty-1.1.0-console-list-agent-patch.cjs` mutates the installed - * tree in place — which rule 1 forbids for a shared one. - * - * Linux's `node-pty-1.1.0-master-cloexec-patch.cjs` also mutates in place, but it stays inside rule - * 1: the deploy path runs it before promotion, and returns early on a linked entry, so it only ever - * touches a private tree. Its bytes are in the key, so a patched build never links a pre-patch - * entry -- and a tree whose patch was refused or rolled back is not promoted at all, because under - * that same key it would publish the leak to every later host on the machine. - */ -import { createHash } from 'node:crypto' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { RELAY_BUILD_PLATFORMS } from '../../shared/relay-artifacts' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -/** Sibling of `relay-` and `orcad-`; owned by neither model's version GC. */ -export const RELAY_NATIVE_DEPS_CACHE_DIR_NAME = 'native' - -/** Written last. Its presence is the only thing that makes an entry linkable. */ -export const RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME = '.deps-complete' - -/** Older clients sweep the legacy prefix by mtime without checking references. */ -export const RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.native-gc-' -export const LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.gc-tombstone.' - -/** - * Bump when the remote install starts mutating the installed tree in a way the hashed inputs - * below cannot see — a new `npm rebuild` flag, a new post-install step, a patch applied by - * something other than a shipped `node-pty-*` artifact. A published entry is never repaired in - * place; only a new key retires it. - */ -export const RELAY_NATIVE_DEPS_CACHE_EPOCH = 1 - -/** - * Shipped relay artifacts that patch the installed native tree. Their bytes go into the key, so - * changing a patch mints a new entry instead of leaving hosts on a tree built from the old one. - */ -export const RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN = /^node-pty-.*\.(cjs|js|patch)$/ - -const CACHE_KEY_HASH_LENGTH = 16 - -const CACHE_ENTRY_NAME_REGEX = new RegExp( - `^(${RELAY_BUILD_PLATFORMS.join('|')})-[0-9a-f]{${CACHE_KEY_HASH_LENGTH}}$` -) - -export type RelayNativeDepsCachePatchSource = { - filename: string - contents: string -} - -/** - * `-` over the dependency set, the epoch, and every patch the - * remote install applies. Platform and arch stay in the name rather than the hash so an operator - * reading `~/.orca-remote/native/` can tell what an entry is for. - */ -export function computeRelayNativeDepsCacheKey(input: { - platform: string - deps: Readonly> - patchSources?: readonly RelayNativeDepsCachePatchSource[] -}): string { - const hash = createHash('sha256') - hash.update(`epoch ${RELAY_NATIVE_DEPS_CACHE_EPOCH}\n`) - for (const [name, version] of Object.entries(input.deps).sort(([a], [b]) => (a < b ? -1 : 1))) { - hash.update(`dep ${name} ${version}\n`) - } - const patches = [...(input.patchSources ?? [])].sort((a, b) => (a.filename < b.filename ? -1 : 1)) - for (const patch of patches) { - hash.update( - `patch ${patch.filename} ${createHash('sha256').update(patch.contents).digest('hex')}\n` - ) - } - const key = `${input.platform}-${hash.digest('hex').slice(0, CACHE_KEY_HASH_LENGTH)}` - if (!isRelayNativeDepsCacheEntryName(key)) { - // Why: the key reaches the host inside `mv` and `rm -rf`; an unrecognized platform must - // disable the cache rather than arrive as a path fragment nobody validated. - throw new Error(`Unsafe relay native-deps cache key: ${JSON.stringify(key)}`) - } - return key -} - -/** - * Whether a name the host listed is one this client may move or delete. Every GC candidate goes - * through here before it reaches a shell. - */ -export function isRelayNativeDepsCacheEntryName(name: string): boolean { - return CACHE_ENTRY_NAME_REGEX.test(name) -} - -/** `~/.orca-remote` — the parent both relay dirs and the cache sit under. */ -export function remoteInstallRootDir(host: RemoteHostPlatform, remoteHome: string): string { - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) -} - -/** `~/.orca-remote/native` */ -export function relayNativeDepsCacheBaseDir(host: RemoteHostPlatform, remoteHome: string): string { - return joinRemotePath( - host, - remoteInstallRootDir(host, remoteHome), - RELAY_NATIVE_DEPS_CACHE_DIR_NAME - ) -} - -/** `~/.orca-remote/native/` */ -export function relayNativeDepsCacheEntryDir( - host: RemoteHostPlatform, - remoteHome: string, - key: string -): string { - if (!isRelayNativeDepsCacheEntryName(key)) { - throw new Error(`Unsafe relay native-deps cache key: ${JSON.stringify(key)}`) - } - return joinRemotePath(host, relayNativeDepsCacheBaseDir(host, remoteHome), key) -} - -/** `~/.orca-remote/native//node_modules` — the symlink target, and the reference identity. */ -export function relayNativeDepsCacheNodeModulesPath( - host: RemoteHostPlatform, - remoteHome: string, - key: string -): string { - return joinRemotePath(host, relayNativeDepsCacheEntryDir(host, remoteHome, key), 'node_modules') -} - -/** - * Windows hosts install node-pty from an npm prebuilt and then patch the tree in place, so they - * keep the per-directory install. Nothing else about their deploy changes. - */ -export function supportsRelayNativeDepsCache(host: RemoteHostPlatform): boolean { - return !isWindowsRemoteHost(host) -} diff --git a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts deleted file mode 100644 index 5cd20a03438..00000000000 --- a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts +++ /dev/null @@ -1,209 +0,0 @@ -// Why: shared by the native-deps install specs so each file stays under the max-lines cap; the -// vi.mock of ./ssh-relay-deploy-helpers in the importing spec is hoisted, so execCommand is mocked here too. -import { EventEmitter } from 'node:events' -import { vi } from 'vitest' - -import { execCommand } from './ssh-relay-deploy-helpers' -import type { SshConnection } from './ssh-connection' - -export type SftpWriteCapture = { - paths: string[] - contents: Record - // execCommand call count observed when ws.end() ran, per path — pins "package.json written before npm install". - execCallCountAtWrite: Record -} - -type SftpCallback = (err: Error | null, resolved?: string) => void -const NO_SUCH_SFTP_FILE = Object.assign(new Error('No such file'), { code: 2 }) -// Stdout of the relay-side pty-master cloexec patch; kept as a literal so the fixture states the -// wire token it is standing in for rather than importing the module under test. -const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' - -export function makeMockConnection(capture: SftpWriteCapture): SshConnection { - // Why: production attaches/removes real listeners (including prependOnceListener), so the fake must be an emitter. - const sftpCreate = (): unknown => { - const sftp = new EventEmitter() - return Object.assign(sftp, { - mkdir: vi.fn((_p: string, cb: SftpCallback) => cb(null)), - // This host's shell home and SFTP start directory agree, so no namespace redirect is possible. - realpath: vi.fn((_p: string, cb: SftpCallback) => cb(null, '/home/u')), - lstat: vi.fn((_p: string, cb: SftpCallback) => cb(NO_SUCH_SFTP_FILE)), - createWriteStream: vi.fn().mockImplementation((path: string) => { - capture.paths.push(path) - const ws = new EventEmitter() - return Object.assign(ws, { - end: vi.fn((data?: string) => { - capture.contents[path] = `${capture.contents[path] ?? ''}${data ?? ''}` - capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length - setTimeout(() => ws.emit('close'), 0) - }) - }) - }), - end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) - }) - } - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - sftp: vi.fn().mockImplementation(() => Promise.resolve(sftpCreate())) - } as unknown as SshConnection -} - -export type ExecResponse = string | { reject: string } - -// The answer a genuinely broken pair produces: a marker line naming both deps. A bare `MISSING` -// names none, so it is unverifiable and must never stand in for this. -export const BOTH_NATIVE_DEPS_MISSING_PROBE = - 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' - -const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' - -export function makeStagedFirstInstallExecPrefix(): ExecResponse[] { - return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // bounded stale-stage recovery - `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0`, - '', // chmod staged node - '', // final install namespace marker - `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED`, - // Shared native-deps cache probe; an empty answer is a miss, so the per-directory install runs. - '' - ] -} - -// Repair reconnect (isRelayAlreadyInstalled → true) where BOTH native deps are broken and the host -// cannot compile node-pty, so the caller's resets must survive into the node-pty-less reinstall. -export function makeRepairToolchainSkipExecResponses(): ExecResponse[] { - return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - BOTH_NATIVE_DEPS_MISSING_PROBE, // health probe before lock - BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the repair lock - '', // SFTP-namespace install-owner marker (repair) - { reject: 'gyp ERR! stack Error: not found: make' }, - 'PKG apk', // toolchain probe: no HAVE lines - '', // reset both deps + reinstall without node-pty - 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING\n', // watcher probe: only node-pty still absent - '', // cat probe stderr - '', // rm -f probe stderr - 'DEAD', - '', // publish the per-launch credential - 'READY' - ] -} - -export function decodePowerShellCommand(command: string): string | null { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null -} - -// Happy-path exec order ends with socket probe, credential publication, then readiness poll. -// When the probe rejects (SSH channel close or vanished install dir), the catch skips both stderr-capture and the rm. -// A failed npm install takes one of the two early branches below instead, which never reach `probe`. -export function makeExecResponses(opts: { - npmInstall: 'ok' | { reject: string } - // Only consumed when npmInstall is 'ok'; defaults to a healthy install. - // 'ok' : probe resolves with the sentinel; rm runs once - // 'missing' : probe resolves with 'MISSING'; cat stderr + rm both run - // 'dir-gone': probe rejects (cd-failure), exec rejects directly - // { reject }: probe rejects with custom error (e.g. SSH channel) - probe?: 'ok' | 'missing' | 'dir-gone' | { reject: string } - // Override probe stdout entirely for shell-noise/pollution-prefix pressure tests. - probeStdoutOverride?: string - // Result after the automatic rebuild; defaults to missing so legacy tests still exercise the degraded-mode warning. - repairProbe?: 'ok' | 'missing' - // Raw stdout for the toolchain probe in installNativeDeps' catch; defaults to a full toolchain so the original npm error propagates unchanged. - toolchainProbe?: string - // Result of the node-pty-less reinstall the catch attempts when the toolchain is missing. - // Omit for hosts that never reach it (full toolchain, or a non-build npm failure). - nodePtySkipRetry?: 'ok' | { reject: string } - // Whether @parcel/watcher loads on the skip path; node-pty is always absent there by construction. - nodePtySkipWatcher?: 'ok' | 'missing' -}): ExecResponse[] { - // A failed npm install aborts after the catch probes the toolchain, unless the node-pty-less - // reinstall succeeds; only then are the chmod/probe/launch slots reached. - if (opts.npmInstall !== 'ok' && opts.nodePtySkipRetry !== 'ok') { - return [ - ...makeStagedFirstInstallExecPrefix(), - opts.npmInstall, // npm install rejects - opts.toolchainProbe ?? 'HAVE make\nHAVE g++\nHAVE cc\nHAVE python3\nPKG apt-get', - ...(opts.nodePtySkipRetry ? [opts.nodePtySkipRetry] : []), // reinstall also rejects - '' // clean stage root - ] - } - if (opts.npmInstall !== 'ok') { - // Skip path, exactly as production runs it: no chmod-prebuilds (node-pty is gone) and no rebuild - // (it provably can't compile here). The probe still runs to catch a dead @parcel/watcher. - return [ - ...makeStagedFirstInstallExecPrefix(), - opts.npmInstall, // npm install rejects on the missing compiler - opts.toolchainProbe ?? 'HAVE python3\nPKG dnf', - '', // rm -rf node-pty + reinstall without it - // node-pty is always reported missing here; the probe never resolves OK, so cat + rm both run. - opts.nodePtySkipWatcher === 'missing' - ? `${BOTH_NATIVE_DEPS_MISSING_PROBE}\n` - : 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING\n', - '', // cat probe stderr - '', // rm -f probe stderr - '', // clean stage root - 'DEAD', - '', // publish the per-launch credential - 'READY' - ] - } - const probe = opts.probe ?? 'ok' - const probeSlot: ExecResponse = - opts.probeStdoutOverride !== undefined - ? opts.probeStdoutOverride - : probe === 'ok' - ? 'ORCA-NPTY-PROBE-OK\n' - : probe === 'missing' - ? 'MISSING\n' // shell-level `|| echo MISSING` after require throw - : probe === 'dir-gone' - ? { reject: 'cd: no such file or directory' } - : probe - const slots: ExecResponse[] = [ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install native deps - '', // chmod prebuilds - probeSlot - ] - // Cleanup execs only run when the probe resolved (not when it rejected). - const probeResolved = typeof probeSlot === 'string' - let loadable = false - if (probeResolved) { - const probeOk = probeSlot.includes('ORCA-NPTY-PROBE-OK') - loadable = probeOk - if (!probeOk) { - slots.push('') // cat stderr (graceful failure path captures detail) - } - slots.push('') // rm -f stderr (best-effort cleanup) - if (!probeOk) { - slots.push('') // npm rebuild with lifecycle scripts explicitly enabled - slots.push('') // chmod prebuilds after rebuild - const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n' - slots.push(repairProbe) - loadable = repairProbe.includes('ORCA-NPTY-PROBE-OK') - if (!loadable) { - slots.push('') // cat stderr after unsuccessful rebuild - } - slots.push('') // rm -f stderr after rebuild probe - } - } - // Publication is gated on the probe: only a tree this host actually loaded is shared. - if (loadable) { - // The cloexec patch runs first, and publication is gated on its status, so `patched` is what - // makes the promote exec below reachable at all. - slots.push(`${NODE_PTY_CLOEXEC_STATUS_PREFIX}patched\n`) - slots.push('') // promote the private tree into the shared native-deps cache - } - slots.push('', 'DEAD', '', 'READY') // clean stage root, launch, credential, readiness - return slots -} diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts deleted file mode 100644 index 63d5ec29ca1..00000000000 --- a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' - -const execCommand = vi.fn() -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), - isUnconfirmedSshCommandTermination: (error: unknown) => - (error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false -})) - -import { - RelayProbeCleanupUnconfirmedError, - parseRelayEndpointIncumbentProbe -} from './ssh-relay-endpoint-incumbent' -import { - classifySupersededRelay, - supersededRelayEndpointListCommand, - sweepSupersededRelayEndpoints -} from './ssh-relay-superseded-endpoints' -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const HOME = '/home/u' -const SOCK_NAME = 'relay-deadbeef.sock' -const CURRENT_DIR = `${HOME}/.orca-remote/relay-0.1.0+bd3ec370d21d` -const OLD_SOCK = `${HOME}/.orca-remote/relay-0.1.0+7175e0a40ea7/${SOCK_NAME}` -const HOST = getRemoteHostPlatform('linux-x64') -const WINDOWS_HOST = getRemoteHostPlatform('win32-x64') -const CONN = {} as SshConnection - -const SWEEP = { - remoteHome: HOME, - currentRelayDir: CURRENT_DIR, - sockName: SOCK_NAME, - nodePath: '/usr/bin/node' -} - -function probe(lines: string[]): string { - return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') -} - -function incumbent(lines: string[]): ReturnType { - return parseRelayEndpointIncumbentProbe(OLD_SOCK, probe(lines)) -} - -function issuedCommands(): string[] { - return execCommand.mock.calls.map((call) => String(call[1])) -} - -/** The `beforeEach` spy is reinstalled, not reset, so its calls survive the previous test. */ -function warnSpy(): MockInstance { - const spy = vi.spyOn(console, 'warn') - spy.mockClear() - return spy -} - -beforeEach(() => { - execCommand.mockReset() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) -}) - -describe('supersededRelayEndpointListCommand', () => { - it('globs sibling version dirs for this target socket and skips the current one', () => { - const command = supersededRelayEndpointListCommand(SWEEP) - expect(command).toContain('"$base"/relay-*/"$sock_name"') - expect(command).toContain('[ "$dir" = "$current" ] && continue') - expect(command).toContain(SOCK_NAME) - expect(command).toContain(CURRENT_DIR) - }) -}) - -describe('classifySupersededRelay', () => { - it('retains a live relay that still owns PTYs', () => { - expect( - classifySupersededRelay( - incumbent([ - 'PRESENT=yes', - 'LISTEN=accepted', - 'HOLDERS_SOURCE=lsof', - 'HOLDER=3669803 yes 13 11' - ]) - ) - ).toBe('retained-live-work') - }) - - it('nominates only a proven empty relay for reaping', () => { - expect( - classifySupersededRelay( - incumbent(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) - ) - ).toBe('reap-candidate') - }) - - it('removes only a socket proven to have no holder', () => { - expect( - classifySupersededRelay(incumbent(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'])) - ).toBe('stale-endpoint-removed') - }) - - it('does nothing at all for an unverifiable endpoint', () => { - expect( - classifySupersededRelay( - incumbent(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) - ) - ).toBe('unverifiable') - }) -}) - -describe('sweepSupersededRelayEndpoints', () => { - it.each(['listing', 'reap', 'removal'] as const)( - 'stops after unconfirmed %s termination without examining another endpoint', - async (phase) => { - const secondSock = `${HOME}/.orca-remote/relay-0.1.0+cafebabe1234/${SOCK_NAME}` - const error = Object.assign(new Error('Remote termination is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - if (phase !== 'listing') { - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n${secondSock}\n`) - .mockResolvedValueOnce( - probe( - phase === 'reap' - ? ['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0'] - : ['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'] - ) - ) - } - execCommand.mockRejectedValueOnce(error) - - await expect(sweepSupersededRelayEndpoints(CONN, HOST, SWEEP)).rejects.toBe(error) - - expect(issuedCommands()).toHaveLength(phase === 'listing' ? 1 : 3) - } - ) - - it.each(['reap', 'removal'] as const)( - 'keeps an ordinary %s failure nonfatal and examines later endpoints', - async (phase) => { - const secondSock = `${HOME}/.orca-remote/relay-0.1.0+cafebabe1234/${SOCK_NAME}` - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n${secondSock}\n`) - .mockResolvedValueOnce( - probe( - phase === 'reap' - ? ['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0'] - : ['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'] - ) - ) - .mockRejectedValueOnce(new Error('Operation failed')) - .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) - ) - - const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) - - expect(findings.map((finding) => finding.outcome)).toEqual([ - phase === 'reap' ? 'reap-unconfirmed' : 'unverifiable', - 'unverifiable' - ]) - expect(issuedCommands()).toHaveLength(4) - } - ) - - it('stops the sweep before cleanup when probe group termination is unconfirmed', async () => { - execCommand - .mockResolvedValueOnce(OLD_SOCK) - .mockResolvedValueOnce( - probe([ - 'PRESENT=yes', - 'LISTEN=accepted', - 'HOLDERS_SOURCE=unavailable', - 'PROBE_CLEANUP=unconfirmed' - ]) - ) - await expect(sweepSupersededRelayEndpoints(CONN, HOST, SWEEP)).rejects.toBeInstanceOf( - RelayProbeCleanupUnconfirmedError - ) - expect(issuedCommands()).toHaveLength(2) - }) - - it('leaves an upgrade-orphaned relay that still owns terminals running, untouched', async () => { - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n`) - .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) - ) - const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) - expect(findings).toHaveLength(1) - expect(findings[0]).toMatchObject({ sockPath: OLD_SOCK, outcome: 'retained-live-work' }) - expect(issuedCommands().some((command) => /\bkill\s/.test(command))).toBe(false) - expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) - }) - - it('reaps the empty husk an upgrade leaves behind, once the host confirms it is gone', async () => { - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n`) - .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) - ) - .mockResolvedValueOnce('GONE\n') - const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) - expect(findings[0].outcome).toBe('reaped') - expect(issuedCommands()[2]).toContain('kill -TERM "$pid"') - }) - - it('reports reap-unconfirmed rather than reaped when the pid is still there', async () => { - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n`) - .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) - ) - .mockResolvedValueOnce('LIVE\n') - const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) - expect(findings[0].outcome).toBe('reap-unconfirmed') - }) - - it('unlinks an orphaned socket only once nothing holds it, unpinning the dir for GC', async () => { - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n`) - .mockResolvedValueOnce(probe(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'])) - .mockResolvedValueOnce('') - const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) - expect(findings[0].outcome).toBe('stale-endpoint-removed') - expect(issuedCommands()[2]).toBe(`rm -f '${OLD_SOCK}'`) - }) - - it('touches nothing on a host it cannot interrogate', async () => { - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n`) - .mockResolvedValueOnce(probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable'])) - const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) - expect(findings[0].outcome).toBe('unverifiable') - expect(issuedCommands()).toHaveLength(2) - }) - - it('is a no-op when the listing fails, and never guesses at what was there', async () => { - execCommand.mockRejectedValueOnce(new Error('exec failed')) - await expect(sweepSupersededRelayEndpoints(CONN, HOST, SWEEP)).resolves.toEqual([]) - }) - - it('records the abandoned pass when the listing fails, so it reads apart from an empty host', async () => { - const warn = warnSpy() - execCommand.mockRejectedValueOnce(new Error('exec failed')) - await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) - expect(warn.mock.calls.flat().join('\n')).toContain('no pass ran: exec failed') - }) - - // Same defect as the two arms above, one level down. An ordinary probe failure degrades to - // `unverifiable` and the loop carries on, so the only way out of it mid-pass is the one case that - // matters most: an exec whose SSH channel never confirmed close, which may still be running - // remotely. That rethrows by design — and it used to throw past the log, losing socket 1's - // verdict and making a half-run pass read exactly like a host with nothing to sweep. - it('keeps the endpoints it already classified when a later probe cannot confirm termination', async () => { - const SECOND_SOCK = `${HOME}/.orca-remote/relay-0.1.0+cafebabe1234/${SOCK_NAME}` - const unconfirmed = Object.assign(new Error('channel close unconfirmed'), { - sshChannelCloseConfirmed: false - }) - const warn = warnSpy() - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n${SECOND_SOCK}\n`) - .mockResolvedValueOnce(probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable'])) - .mockRejectedValueOnce(unconfirmed) - - await expect(sweepSupersededRelayEndpoints(CONN, HOST, SWEEP)).rejects.toBe(unconfirmed) - - const logged = warn.mock.calls.flat().join('\n') - // Socket 1's verdict survives the abandon... - expect(logged).toContain('Superseded relay unverifiable') - expect(logged).toContain(OLD_SOCK) - // ...and the pass says how far it got, claiming nothing about the one it never reached. - expect(logged).toContain('stopped after 1 of 2 endpoints') - expect(logged).not.toContain(SECOND_SOCK) - }) - - // The loop must not stop on a probe that merely failed: that is an absence of evidence, and the - // remaining endpoints still deserve a pass. - it('carries on past an ordinary probe failure and classifies the rest', async () => { - const SECOND_SOCK = `${HOME}/.orca-remote/relay-0.1.0+cafebabe1234/${SOCK_NAME}` - execCommand - .mockResolvedValueOnce(`${OLD_SOCK}\n${SECOND_SOCK}\n`) - .mockRejectedValueOnce(new Error('probe blew up')) - .mockResolvedValueOnce(probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable'])) - - const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) - - expect(findings.map((f) => f.outcome)).toEqual(['unverifiable', 'unverifiable']) - }) - - it('does not run against Windows hosts, whose endpoints are named pipes', async () => { - const warn = warnSpy() - await expect(sweepSupersededRelayEndpoints(CONN, WINDOWS_HOST, SWEEP)).resolves.toEqual([]) - expect(execCommand).not.toHaveBeenCalled() - // The skip has to leave a trace: a Windows orphan is never listed and never reclaimed, and - // an empty return is otherwise indistinguishable from a host that had nothing to sweep. - const logged = warn.mock.calls.flat().join('\n') - expect(logged).toContain('Superseded relay sweep did not run') - expect(logged).toContain(CURRENT_DIR) - }) -}) diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.ts b/src/main/ssh/ssh-relay-superseded-endpoints.ts deleted file mode 100644 index c538988f11b..00000000000 --- a/src/main/ssh/ssh-relay-superseded-endpoints.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Relays this target left behind at a *different* version directory. - * - * Every relay build installs to `~/.orca-remote/relay-/` and binds its socket - * inside it, so the socket path moves on every app update even though the filename component - * is stable. After an update the new client binds a path the previous relay's PTYs were never - * associated with, and the previous relay is never contacted again (#13614, #13852). Nothing - * signals it and nothing reclaims it: with `--grace-time 0` it keeps its shells and agents - * alive forever. - * - * This sweep makes that population *visible and deliberate* rather than silent. It does not - * make it recoverable — the daemon handshake compares the build's content hash exactly - * (`relay-handshake.ts`), so a new client cannot speak to an old daemon at all. See the report - * on this change for what a real cross-version handoff would require. - * - * The one thing it will terminate is a relay that provably holds nothing. Everything else is - * retained, including everything it merely failed to reach. - */ -import type { SshConnection } from './ssh-connection' -import { shellEscape } from './ssh-connection-utils' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit' -import { execCommand } from './ssh-relay-deploy-helpers' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { - describeRelayEndpointIncumbent, - isReapableRelayHusk, - probeRelayEndpointIncumbent, - type RelayEndpointIncumbent -} from './ssh-relay-endpoint-incumbent' -import { reapEmptyRelayHusk } from './ssh-relay-endpoint-takeover' -import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' - -/** - * `reaped` is the only outcome that claims a process ended, and it is only reachable from a - * post-signal `kill -0` that failed. A signal we sent but could not confirm is - * `reap-unconfirmed`, which is `unverifiable` — not `exited` by another name. - */ -export type SupersededRelayOutcome = - | 'reaped' - | 'reap-unconfirmed' - | 'retained-live-work' - | 'stale-endpoint-removed' - | 'unverifiable' - -export type SupersededRelayFinding = { - sockPath: string - outcome: SupersededRelayOutcome - incumbent: RelayEndpointIncumbent -} - -export type SupersededRelaySweepOptions = { - remoteHome: string - /** Absolute path of the version directory this client just launched into; never swept. */ - currentRelayDir: string - /** Stable per-target socket filename, from `relaySocketNameForInstanceId`. */ - sockName: string - /** Set only when this launch relocated its socket; that directory is never swept. */ - currentShortSocketDir?: string - nodePath: string - signal?: AbortSignal -} - -const MAX_SWEPT_ENDPOINTS = 32 - -export function supersededRelayEndpointListCommand(options: { - remoteHome: string - currentRelayDir: string - sockName: string - currentShortSocketDir?: string -}): string { - return [ - `base=${shellEscape(`${options.remoteHome}/${RELAY_REMOTE_DIR}`)}`, - `sock_name=${shellEscape(options.sockName)}`, - `current=${shellEscape(options.currentRelayDir)}`, - // Why the second base: a host whose `$HOME` pushes the endpoint past `sun_path` binds - // under `/tmp/.orca-relay-/relay-/` instead (relay-socket-path-limit.ts). - // Those orphans are the same population this sweep exists to make visible, and the - // `$HOME` glob cannot see them. The uid is resolved on the host; the client never knows it. - `short_current=${shellEscape(options.currentShortSocketDir ?? '')}`, - `short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`, - 'for sock in "$base"/relay-*/"$sock_name" "$short_base"/relay-*/"$sock_name"; do', - ' [ -S "$sock" ] || continue', - ' dir=${sock%/*}', - ' [ "$dir" = "$current" ] && continue', - ' [ -n "$short_current" ] && [ "$dir" = "$short_current" ] && continue', - ' printf \'%s\\n\' "$sock"', - 'done' - ].join('\n') -} - -/** Remove a socket inode proven to have no holder, so version-dir GC can reclaim the tree. */ -export function removeStaleRelayEndpointCommand(sockPath: string): string { - const remove = `rm -f ${shellEscape(sockPath)}` - if (!sockPath.startsWith(SHORT_RELAY_SOCKET_DIR_PREFIX)) { - return remove - } - // `gcOldRelayVersions` only walks `$HOME/.orca-remote`, so nothing else would ever - // reclaim a relocated version segment. `rmdir` fails while another target of the same - // build still has a socket there, which is exactly the condition for keeping it. - return `${remove}; rmdir ${shellEscape(sockPath.slice(0, sockPath.lastIndexOf('/')))} 2>/dev/null || true` -} - -export function classifySupersededRelay( - incumbent: RelayEndpointIncumbent -): Exclude | 'reap-candidate' { - if (incumbent.verdict === 'exited') { - return incumbent.socketPresent ? 'stale-endpoint-removed' : 'unverifiable' - } - if (incumbent.verdict !== 'live') { - return 'unverifiable' - } - return isReapableRelayHusk(incumbent) ? 'reap-candidate' : 'retained-live-work' -} - -export async function sweepSupersededRelayEndpoints( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - options: SupersededRelaySweepOptions -): Promise { - if (isWindowsRemoteHost(hostPlatform)) { - // No pass runs here: a Windows endpoint is a named pipe with no inode to stat, so the - // `$HOME` glob cannot see it, and `probeRelayEndpointIncumbent` answers `unverifiable` for - // every Windows path anyway — nothing on this host could be classified, let alone reaped. - // The population is real all the same (`relayEndpointForHost` hashes the version dir into - // the pipe name, so an update strands the incumbent exactly as it does on POSIX), and with - // `--grace-time 0` it keeps its PTYs forever. Returning silently was the whole bug: this - // sweep exists to make that population visible, and on Windows it made it invisible. - console.warn( - `[ssh-relay] Superseded relay sweep did not run (Windows named-pipe endpoints are not enumerated); ` + - `any orphan from an earlier build would be neither listed nor reclaimed: current=${options.currentRelayDir}` - ) - return [] - } - let listing: string - try { - listing = await execCommand(conn, supersededRelayEndpointListCommand(options), { - wrapCommand: true, - signal: options.signal - }) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - // Same reason the Windows arm logs: an abandoned pass and an empty host are the same return - // value, and only the log tells them apart. - console.warn( - `[ssh-relay] Superseded relay listing failed; no pass ran: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return [] - } - const sockPaths = listing - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.startsWith('/')) - .slice(0, MAX_SWEPT_ENDPOINTS) - - const findings: SupersededRelayFinding[] = [] - try { - for (const sockPath of sockPaths) { - options.signal?.throwIfAborted() - const incumbent = await probeRelayEndpointIncumbent( - conn, - hostPlatform, - options.nodePath, - sockPath, - { signal: options.signal } - ) - findings.push({ - sockPath, - outcome: await applySupersededRelayDecision(conn, incumbent, options), - incumbent - }) - } - } catch (err) { - // Why log before rethrowing: a probe or a reap that throws on socket 2 of N already classified - // socket 1, and those lines are the whole point of this pass. Dropping them made a half-run - // sweep read exactly like a host with nothing to sweep — the same defect the Windows arm above - // has, one level down. The throw still propagates unchanged; the caller separates - // RelayProbeCleanupUnconfirmedError from the rest. The count says how much of the pass ran, and - // claims nothing about the endpoints it never reached. - logSupersededRelayFindings(findings) - console.warn( - `[ssh-relay] Superseded relay sweep stopped after ${findings.length} of ${sockPaths.length} ` + - `endpoints; the rest were not examined: ${err instanceof Error ? err.message : String(err)}` - ) - throw err - } - logSupersededRelayFindings(findings) - return findings -} - -async function applySupersededRelayDecision( - conn: SshConnection, - incumbent: RelayEndpointIncumbent, - options: SupersededRelaySweepOptions -): Promise { - const decision = classifySupersededRelay(incumbent) - if (decision === 'stale-endpoint-removed') { - try { - await execCommand(conn, removeStaleRelayEndpointCommand(incumbent.sockPath), { - wrapCommand: true, - signal: options.signal - }) - return 'stale-endpoint-removed' - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return 'unverifiable' - } - } - if (decision !== 'reap-candidate') { - return decision - } - return reapEmptyRelayHusk(conn, incumbent, { signal: options.signal }) -} - -function logSupersededRelayFindings(findings: SupersededRelayFinding[]): void { - for (const finding of findings) { - const detail = describeRelayEndpointIncumbent(finding.incumbent) - if (finding.outcome === 'retained-live-work') { - console.warn( - `[ssh-relay] Superseded relay retained (holds live work; not signalled): ${detail}` - ) - continue - } - if (finding.outcome === 'unverifiable' || finding.outcome === 'reap-unconfirmed') { - console.warn(`[ssh-relay] Superseded relay ${finding.outcome}: ${detail}`) - continue - } - console.log(`[ssh-relay] Superseded relay ${finding.outcome}: ${detail}`) - } -} diff --git a/src/main/ssh/ssh-relay-version-mismatch-error.ts b/src/main/ssh/ssh-relay-version-mismatch-error.ts index 66bc90c286a..451b18cd688 100644 --- a/src/main/ssh/ssh-relay-version-mismatch-error.ts +++ b/src/main/ssh/ssh-relay-version-mismatch-error.ts @@ -24,10 +24,6 @@ export class RelayVersionMismatchError extends Error { } } -export function isRelayVersionMismatchError(err: unknown): err is RelayVersionMismatchError { - return err instanceof RelayVersionMismatchError -} - // Why: the remote --connect process uses this exit code to signal the wire // handshake failed because of a version mismatch. The mapping daemon ⇄ exit // code 42 lives in src/relay/relay-handshake.ts (EXIT_CODE_VERSION_MISMATCH). diff --git a/src/main/ssh/ssh-relay-versioned-install-termination.test.ts b/src/main/ssh/ssh-relay-versioned-install-termination.test.ts index 35b7f60a18e..06ce0a54812 100644 --- a/src/main/ssh/ssh-relay-versioned-install-termination.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install-termination.test.ts @@ -9,7 +9,7 @@ import { abandonInstall, finalizeInstall, isRemoteInstallComplete -} from './ssh-relay-versioned-install' +} from './remote-versioned-install' import { getRemoteHostPlatform } from './ssh-remote-platform' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked execCommand never reads the connection. diff --git a/src/main/ssh/ssh-relay-windows-launch-command.test.ts b/src/main/ssh/ssh-relay-windows-launch-command.test.ts deleted file mode 100644 index 17b2e407c3b..00000000000 --- a/src/main/ssh/ssh-relay-windows-launch-command.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - formatWindowsBreakawayLaunchReport, - parseWindowsBreakawayLaunchReport, - RELAY_WINDOWS_BREAKAWAY_CONTRACT, - WINDOWS_BREAKAWAY_LAUNCH_FLAG, - type WindowsBreakawayLaunchReport -} from '../../shared/windows-breakaway-launch' - -const formatRelayWindowsLaunchReport = (report: WindowsBreakawayLaunchReport): string => - formatWindowsBreakawayLaunchReport(RELAY_WINDOWS_BREAKAWAY_CONTRACT, report) -const parseRelayWindowsLaunchReport = (output: string): WindowsBreakawayLaunchReport | null => - parseWindowsBreakawayLaunchReport(RELAY_WINDOWS_BREAKAWAY_CONTRACT, output) -import { - classifyWindowsRelayLaunchError, - WINDOWS_RELAY_LAUNCH_REFUSED_MARKER, - windowsRelayLaunchCommand -} from './ssh-relay-windows-launch-command' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' - -const host = getRemoteHostPlatform('win32-x64') -const opts = { - nodePath: 'C:/Users/me user/.orca-remote/runtimes/node-abc/node.exe', - remoteDir: 'C:/Users/me user/.orca-remote/relay-1', - sockPath: '\\\\.\\pipe\\orca-relay-1', - endpointDir: 'C:/Users/me user/.orca-remote/relay-1/agent-hooks/orca-relay-1', - graceTime: 300, - logFile: 'C:/Users/me user/.orca-remote/relay-1/relay.log', - errFile: 'C:/Users/me user/.orca-remote/relay-1/relay.err.log', - credentialFile: 'C:/Users/me user/.orca-remote/relay-1/orca-relay-1.credential' -} - -function launchScript(): string { - return decodeRemotePowerShellScript(windowsRelayLaunchCommand(host, opts)) -} - -describe('windowsRelayLaunchCommand', () => { - it('starts the relay through the breakaway launcher on the same node.exe', () => { - const script = launchScript() - const launcher = `& '${opts.nodePath}' relay.js '${WINDOWS_BREAKAWAY_LAUNCH_FLAG}' '--stdout-file' '${opts.logFile}' '--stderr-file' '${opts.errFile}' '--relay-args' '--detached' '--grace-time' '300' '--sock-path' '${opts.sockPath}'` - expect(script).toContain(launcher) - expect(script.indexOf(launcher)).toBeLessThan(script.indexOf('Invoke-CimMethod')) - }) - - it('reaches WMI only when the launcher reports itself unavailable', () => { - const script = launchScript() - const wmiBranch = script.slice(script.indexOf('if ($orcaLaunchCode -eq 3)')) - expect(wmiBranch.indexOf('Invoke-CimMethod')).toBeGreaterThan(0) - expect(wmiBranch.indexOf('Invoke-CimMethod')).toBeLessThan(wmiBranch.indexOf('elseif')) - expect(script).toContain( - `"C:/Users/me user/.orca-remote/relay-1/relay.js" --detached --grace-time 300` - ) - expect(script).toContain(`1>"${opts.logFile}" 2>"${opts.errFile}"`) - }) - - it('names a WMI refusal so a standard-user host fails with a reason', () => { - const script = launchScript() - expect(script).toContain(`throw "${WINDOWS_RELAY_LAUNCH_REFUSED_MARKER}:`) - expect(script).toContain('Invoke-CimMethod -ErrorAction Stop') - }) - - it('emits nothing an EDR scores as a launch technique', () => { - const script = launchScript() - expect(script).not.toMatch(/Add-Type|ExecutionPolicy|Register-ScheduledTask|schtasks/iu) - }) - - it('passes the uploaded ripgrep to both routes', () => { - const script = decodeRemotePowerShellScript( - windowsRelayLaunchCommand(host, { ...opts, ripgrepPath: 'C:/rg/rg.exe' }) - ) - expect(script).toContain(`'--ripgrep-path' 'C:/rg/rg.exe'`) - expect(script).toContain('--ripgrep-path "C:/rg/rg.exe"') - }) -}) - -describe('classifyWindowsRelayLaunchError', () => { - it('turns a refusal into a named error', () => { - const exec = new Error( - `Command "powershell.exe -EncodedCommand AAAA" failed (exit 1): ${WINDOWS_RELAY_LAUNCH_REFUSED_MARKER}: this account cannot start a process that outlives the SSH session: breakaway launcher unavailable (ORCA_RELAY_LAUNCH {"method":"unavailable","reason":"addon-missing"}) and WMI Win32_Process.Create denied (Access denied)\nAt line:1 char:1` - ) - const classified = classifyWindowsRelayLaunchError(exec) - if (!(classified instanceof Error)) { - throw new Error('expected an Error') - } - expect(classified.message).toMatch( - /^The Windows host refused to start Orca's relay outside the SSH session\. ORCA_RELAY_LAUNCH_REFUSED: .*addon-missing.*Access denied\)$/u - ) - expect(classified.message).not.toContain('EncodedCommand') - }) - - it('leaves every other launch failure as it was', () => { - const exec = new Error('Command "x" failed (exit 1): Relay launcher exited 1') - expect(classifyWindowsRelayLaunchError(exec)).toBe(exec) - }) -}) - -describe('parseRelayWindowsLaunchReport', () => { - it('reads the launcher and WMI reports', () => { - const breakaway = formatRelayWindowsLaunchReport({ method: 'breakaway', pid: 7, inJob: false }) - expect(parseRelayWindowsLaunchReport(`noise\r\n${breakaway}\r\n`)).toEqual({ - method: 'breakaway', - pid: 7, - inJob: false - }) - const unavailable = formatRelayWindowsLaunchReport({ - method: 'unavailable', - reason: 'addon-missing' - }) - expect( - parseRelayWindowsLaunchReport(`${unavailable}\nORCA_RELAY_LAUNCH {"method":"wmi"}`) - ).toEqual({ method: 'wmi' }) - }) - - it('reads nothing from output without a report', () => { - expect(parseRelayWindowsLaunchReport('')).toBeNull() - expect(parseRelayWindowsLaunchReport('ORCA_RELAY_LAUNCH not-json')).toBeNull() - expect(parseRelayWindowsLaunchReport('ORCA_RELAY_LAUNCH {"method":"breakaway"}')).toBeNull() - }) -}) diff --git a/src/main/ssh/ssh-relay-windows-launch-command.ts b/src/main/ssh/ssh-relay-windows-launch-command.ts index 0be5208b0f9..1d8a858c84d 100644 --- a/src/main/ssh/ssh-relay-windows-launch-command.ts +++ b/src/main/ssh/ssh-relay-windows-launch-command.ts @@ -1,131 +1,8 @@ -/** - * The PowerShell that starts a detached relay on a Windows SSH host. - * - * Win32-OpenSSH puts each session's shell in a job with KILL_ON_JOB_CLOSE, so a relay started - * inside the session dies with it. The job allows breakaway, so `relay.js`'s one-shot launcher - * mode starts the relay with CREATE_BREAKAWAY_FROM_JOB through the staged process-tree addon. That - * works for a standard user; WMI Win32_Process.Create, the old route, is refused to a standard - * user's network logon, so it runs only when the launcher is unavailable (a relay built without - * the addon, or a job that refuses breakaway), and a refusal there is reported as one. - */ -import { - RELAY_WINDOWS_BREAKAWAY_CONTRACT, - WINDOWS_BREAKAWAY_EXIT_CODES, - WINDOWS_BREAKAWAY_LAUNCH_FLAG, - WINDOWS_BREAKAWAY_STDERR_FLAG, - WINDOWS_BREAKAWAY_STDOUT_FLAG -} from '../../shared/windows-breakaway-launch' +/** How the host census reaches a running Windows relay through its own bridge. */ import { commandWithNodePath } from './ssh-remote-commands' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellLiteral } from './ssh-remote-powershell' -/** Followed by the launch report as JSON, or `null` when the script printed none. */ -export const WINDOWS_RELAY_LAUNCH_LOG_PREFIX = '[ssh-relay] Windows relay launch: ' - -/** In the launch error when neither route may start a process outside the session. */ -export const WINDOWS_RELAY_LAUNCH_REFUSED_MARKER = 'ORCA_RELAY_LAUNCH_REFUSED' - -export type WindowsRelayLaunchCommandOptions = { - nodePath: string - remoteDir: string - sockPath: string - endpointDir: string - graceTime: number - logFile: string - errFile: string - credentialFile: string - ripgrepPath?: string -} - -function relayDaemonArgs(opts: WindowsRelayLaunchCommandOptions): string[] { - return [ - '--detached', - '--grace-time', - String(opts.graceTime), - '--sock-path', - opts.sockPath, - '--credential-file', - opts.credentialFile, - '--endpoint-dir', - opts.endpointDir, - // Why: --log-file owns rotation; the stdout/stderr files still capture pre-JS boot/crash output. - '--log-file', - opts.logFile, - ...(opts.ripgrepPath ? ['--ripgrep-path', opts.ripgrepPath] : []) - ] -} - -function wmiCommandLine(opts: WindowsRelayLaunchCommandOptions, relayScript: string): string { - const quoted = (value: string): string => `"${value.replace(/"/g, '\\"')}"` - const relayCommandLine = [ - quoted(opts.nodePath), - quoted(relayScript), - ...relayDaemonArgs(opts).map((arg) => - arg.startsWith('--') || arg === String(opts.graceTime) ? arg : quoted(arg) - ), - `1>${quoted(opts.logFile)}`, - `2>${quoted(opts.errFile)}` - ].join(' ') - return `cmd.exe /d /s /c "${relayCommandLine}"` -} - -export function windowsRelayLaunchCommand( - hostPlatform: RemoteHostPlatform, - opts: WindowsRelayLaunchCommandOptions -): string { - const relayScript = joinRemotePath(hostPlatform, opts.remoteDir, 'relay.js') - const launcherArgs = [ - WINDOWS_BREAKAWAY_LAUNCH_FLAG, - WINDOWS_BREAKAWAY_STDOUT_FLAG, - opts.logFile, - WINDOWS_BREAKAWAY_STDERR_FLAG, - opts.errFile, - RELAY_WINDOWS_BREAKAWAY_CONTRACT.argsFlag, - ...relayDaemonArgs(opts) - ] - const launcher = `& ${powerShellLiteral(opts.nodePath)} relay.js ${launcherArgs.map(powerShellNativeArg).join(' ')}` - const refused = `${WINDOWS_RELAY_LAUNCH_REFUSED_MARKER}: this account cannot start a process that outlives the SSH session` - const wmi = [ - 'try {', - `$orcaWmi = Invoke-CimMethod -ErrorAction Stop -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(wmiCommandLine(opts, relayScript))}; CurrentDirectory = ${powerShellLiteral(opts.remoteDir)} }`, - `} catch { throw "${refused}: breakaway launcher unavailable ($orcaLaunch) and WMI Win32_Process.Create denied ($($_.Exception.Message))" }`, - `if ($orcaWmi.ReturnValue -ne 0) { throw "${refused}: breakaway launcher unavailable ($orcaLaunch) and Win32_Process.Create returned $($orcaWmi.ReturnValue)" }`, - `'${RELAY_WINDOWS_BREAKAWAY_CONTRACT.reportMarker} {"method":"wmi"}'` - ].join('; ') - return commandWithNodePath( - hostPlatform, - opts.nodePath, - opts.remoteDir, - [ - `$orcaLaunch = (${launcher}) -join ' '`, - '$orcaLaunchCode = $LASTEXITCODE', - '$orcaLaunch', - `if ($orcaLaunchCode -eq ${WINDOWS_BREAKAWAY_EXIT_CODES.unavailable}) { ${wmi} } ` + - `elseif ($orcaLaunchCode -ne ${WINDOWS_BREAKAWAY_EXIT_CODES.launched}) { throw "Relay launcher exited $($orcaLaunchCode): $orcaLaunch" }` - ].join('; ') - ) -} - -/** - * A launch refusal is a host policy, not a transient failure: name it rather than surface the - * encoded command the exec error carries. - */ -export function classifyWindowsRelayLaunchError(error: unknown): unknown { - const message = error instanceof Error ? error.message : String(error) - const refusal = message - .split(/\r?\n/u) - .find((line) => line.includes(WINDOWS_RELAY_LAUNCH_REFUSED_MARKER)) - if (!refusal) { - return error - } - const detail = refusal.slice(refusal.indexOf(WINDOWS_RELAY_LAUNCH_REFUSED_MARKER)).trim() - return new Error( - `The Windows host refused to start Orca's relay outside the SSH session. ${detail}`, - { cause: error } - ) -} - -/** Reaches a running Windows relay through its own bridge, as a reconnect or a census does. */ export function windowsRelayConnectCommand( hostPlatform: RemoteHostPlatform, nodePath: string, diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 96d70e46774..b0ecbbf6335 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -11,7 +11,7 @@ import { utimesSync } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { @@ -21,15 +21,14 @@ import { } from './ssh-relay-install-lock-commands' import { commandWithNodePath, - listRelayBaseDirsCommand, - MAX_RELAY_GC_LISTING_ENTRIES, - makeRemoteDirectoryCommand, + listRemoteInstallBaseDirsCommand, + MAX_REMOTE_INSTALL_GC_LISTING_ENTRIES, moveRemoteTreeCommand, - probeDirectoryExistsCommand, - probeRelayInstalledCommand, + probeRemoteInstallCompleteCommand, readRemoteHomeCommand, - relayLivenessProbeCommand + removeRemoteTreeCommand } from './ssh-remote-commands' +import { ORCAD_INSTALL_MODEL, RELAY_INSTALL_MODEL } from './remote-install-model' import { getRemoteHostPlatform } from './ssh-remote-platform' import { createRelayGcListingFixture, @@ -37,10 +36,6 @@ import { runCappedRelayGcShellCommand, SSH_EXEC_OUTPUT_CAP_CHARS } from './ssh-relay-gc-listing.test-fixture' -import { - RELAY_INSTALL_COMPLETE_FILENAME, - relayArtifactFilenames -} from '../../shared/relay-artifacts' const posix = getRemoteHostPlatform('linux-x64') const nativePosix = getRemoteHostPlatform( @@ -67,6 +62,16 @@ const powerShell51Executable = ? 'powershell.exe' : undefined +const listRelayBaseDirsCommand = (host: typeof posix, baseDir: string): string => + listRemoteInstallBaseDirsCommand(host, baseDir, RELAY_INSTALL_MODEL) + +function probeOrcadInstalledCommand(host: typeof posix, dir: string): string { + return probeRemoteInstallCompleteCommand(host, dir, [ + ...ORCAD_INSTALL_MODEL.requiredArtifacts(host), + ORCAD_INSTALL_MODEL.installCompleteFilename + ]) +} + function decodePowerShellCommand(command: string): string { return command.includes('-EncodedCommand ') ? decodeRemotePowerShellScript(command) : '' } @@ -126,67 +131,62 @@ function runPowerShellCommand(executable: string, script: string): Promise { it('keeps POSIX deploy commands POSIX-native', () => { expect(readRemoteHomeCommand(posix)).toBe('echo $HOME') - expect(makeRemoteDirectoryCommand(posix, '/home/me/.orca-remote')).toContain('mkdir -p') - const probe = probeRelayInstalledCommand(posix, '/home/me/relay') + expect(removeRemoteTreeCommand(posix, '/home/me/.orca-remote/x')).toContain('rm -rf') + const probe = probeOrcadInstalledCommand(posix, '/home/me/orcad') expect(probe).toContain('test -d') - expect(probe).toContain('managed-hook-runtime.js') - expect(probe).toContain('relay-ai-vault-service.js') + expect(probe).toContain(ORCAD_INSTALL_MODEL.installCompleteFilename) }) - it('requires every declared relay artifact before calling an install complete', () => { - const posixProbe = probeRelayInstalledCommand(posix, '/home/me/relay') - for (const filename of relayArtifactFilenames(false)) { + it('requires every declared artifact before calling an install complete', () => { + const posixProbe = probeOrcadInstalledCommand(posix, '/home/me/orcad') + for (const filename of ORCAD_INSTALL_MODEL.requiredArtifacts(posix)) { expect(posixProbe, `POSIX probe ignores ${filename}`).toContain(filename) } - expect(posixProbe).toContain(RELAY_INSTALL_COMPLETE_FILENAME) - // A POSIX relay must not be asked for the Windows-only node-pty patch. - expect(posixProbe).not.toContain('node-pty-1.1.0-console-list-agent-patch.cjs') + expect(posixProbe).toContain(ORCAD_INSTALL_MODEL.installCompleteFilename) const windowsProbe = decodePowerShellCommand( - probeRelayInstalledCommand(windows, 'C:/Users/me/relay') + probeOrcadInstalledCommand(windows, 'C:/Users/me/orcad') ) - for (const filename of relayArtifactFilenames(true)) { + for (const filename of ORCAD_INSTALL_MODEL.requiredArtifacts(windows)) { expect(windowsProbe, `Windows probe ignores ${filename}`).toContain(filename) } - expect(windowsProbe).toContain(RELAY_INSTALL_COMPLETE_FILENAME) + expect(windowsProbe).toContain(ORCAD_INSTALL_MODEL.installCompleteFilename) }) - // Stage a complete install, then remove one companion: the probe must flip. - function stageRelayInstall(isWindows: boolean): string { - const dir = mkdtempSync(join(tmpdir(), 'orca-relay-probe-')) - for (const filename of relayArtifactFilenames(isWindows)) { + // Stage a complete install, then remove one artifact: the probe must flip. + function stageOrcadInstall(host: typeof posix): { dir: string; artifact: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-install-probe-')) + const artifacts = ORCAD_INSTALL_MODEL.requiredArtifacts(host) + for (const filename of artifacts) { + mkdirSync(dirname(join(dir, filename)), { recursive: true }) writeFileSync(join(dir, filename), '') } - writeFileSync(join(dir, RELAY_INSTALL_COMPLETE_FILENAME), '') - return dir + writeFileSync(join(dir, ORCAD_INSTALL_MODEL.installCompleteFilename), '') + return { dir, artifact: artifacts.at(-1)! } } - it.skipIf(!powerShellExecutable)( - 'rejects a Windows install missing only the WSL transcript helper', - async () => { - const dir = stageRelayInstall(true) - const probe = (): string => decodePowerShellCommand(probeRelayInstalledCommand(windows, dir)) - try { - expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('OK') + it.skipIf(!powerShellExecutable)('rejects a Windows install missing one artifact', async () => { + const { dir, artifact } = stageOrcadInstall(windows) + const probe = (): string => decodePowerShellCommand(probeOrcadInstalledCommand(windows, dir)) + try { + expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('OK') - // The exact shape a pre-STA-4831 relay left behind: everything but this. - rmSync(join(dir, 'wsl-transcript-fs-process-entry.js')) - expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('MISSING') - } finally { - rmSync(dir, { recursive: true, force: true }) - } + rmSync(join(dir, artifact)) + expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('MISSING') + } finally { + rmSync(dir, { recursive: true, force: true }) } - ) + }) it.skipIf(process.platform === 'win32')( - 'rejects a POSIX install missing only the WSL transcript helper', + 'rejects a POSIX install missing one artifact', async () => { - const dir = stageRelayInstall(false) - const probe = (): string => probeRelayInstalledCommand(posix, dir) + const { dir, artifact } = stageOrcadInstall(posix) + const probe = (): string => probeOrcadInstalledCommand(posix, dir) try { expect((await runShellCommand(probe())).trim()).toBe('OK') - rmSync(join(dir, 'wsl-transcript-fs-process-entry.js')) + rmSync(join(dir, artifact)) expect((await runShellCommand(probe())).trim()).toBe('MISSING') } finally { rmSync(dir, { recursive: true, force: true }) @@ -196,13 +196,12 @@ describe('ssh remote command builders', () => { it('uses encoded PowerShell for Windows deploy commands', () => { expect(readRemoteHomeCommand(windows)).toContain('powershell.exe') - expect(makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote')).toContain( + expect(removeRemoteTreeCommand(windows, 'C:/Users/me/.orca-remote/x')).toContain( '-EncodedCommand' ) - const probe = probeRelayInstalledCommand(windows, 'C:/Users/me/relay') + const probe = probeOrcadInstalledCommand(windows, 'C:/Users/me/orcad') expect(probe).toContain('-EncodedCommand') - expect(decodePowerShellCommand(probe)).toContain('managed-hook-runtime.js') - expect(decodePowerShellCommand(probe)).toContain('relay-ai-vault-service.js') + expect(decodePowerShellCommand(probe)).toContain(ORCAD_INSTALL_MODEL.installCompleteFilename) }) it('writes a holder token inside the lock in the same command that creates it', () => { @@ -237,19 +236,14 @@ describe('ssh remote command builders', () => { }) it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => { - const mkdirScript = decodePowerShellCommand( - makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote') - ) const lockScript = decodePowerShellCommand( tryCreateInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock') ) - expect(mkdirScript).toContain('New-Item -ItemType Directory -Force -Path') expect(lockScript).toContain('[System.IO.FileMode]::CreateNew') expect(lockScript).toContain('[System.IO.FileShare]::None') expect(lockScript).toContain('New-Item -ItemType Directory -Path $lock') expect(lockScript).toContain("Join-Path $lock '.owner'") - expect(mkdirScript).not.toContain('New-Item -ItemType Directory -Force -LiteralPath') }) it('moves GC candidates to a sibling tombstone with host-native commands', () => { @@ -264,32 +258,6 @@ describe('ssh remote command builders', () => { expect(windowsScript).toContain("'MOVED'") }) - it('emits an explicit POSIX liveness result so GC can fail closed', () => { - const command = relayLivenessProbeCommand(posix, '/home/u/.orca-remote/relay-0.1.0') - - expect(command).toContain('state=DEAD') - expect(command).toContain('[ -S "$f" ] && state=ALIVE') - expect(command).toContain('echo "$state"') - }) - - it('uses named pipe try-connect liveness for Windows GC', () => { - const command = relayLivenessProbeCommand(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', { - nodePath: 'C:/Program Files/nodejs/node.exe', - pipePaths: ['\\\\.\\pipe\\orca-relay-1234567890abcdef1234'] - }) - const script = decodePowerShellCommand(command) - - expect(command).toContain('powershell.exe') - expect(script).toContain('net.connect(pipe)') - expect(script).toContain('.windows-active-pipe-') - expect(script).toContain('markerCount===0&&pipes.length===0') - expect(script).toContain('C:\\Program Files\\nodejs') - expect(script).not.toContain('Win32_Process') - expect(listRelayBaseDirsCommand(windows, 'C:/Users/me/.orca-remote')).toContain( - '-EncodedCommand' - ) - }) - it.runIf(process.platform !== 'win32')( 'bounds real POSIX GC output with more than the exec-cap stage population', async () => { @@ -312,7 +280,7 @@ describe('ssh remote command builders', () => { expect([...entries].sort()).toEqual(['relay-0.1.0+aaa', 'relay-0.1.0+bbb']) expect(Buffer.byteLength(output)).toBeLessThan(1_024) - expect(entries.length).toBeLessThanOrEqual(MAX_RELAY_GC_LISTING_ENTRIES) + expect(entries.length).toBeLessThanOrEqual(MAX_REMOTE_INSTALL_GC_LISTING_ENTRIES) fixture.fillValidEntryBoundary() const validNames = new Set(fixture.validNames) @@ -348,18 +316,6 @@ describe('ssh remote command builders', () => { } ) - it('escapes double quotes before passing JavaScript to native Windows commands', () => { - const script = decodePowerShellCommand( - relayLivenessProbeCommand(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', { - nodePath: 'C:/Program Files/nodejs/node.exe', - pipePaths: ['\\\\.\\pipe\\orca-relay-1234567890abcdef1234'] - }) - ) - - expect(script).toContain('fs=require(\\"fs\\")') - expect(script).toContain('net=require(\\"net\\")') - }) - it('prepends the Windows node bin directory to PATH with native separators', () => { const script = decodePowerShellCommand( commandWithNodePath( @@ -570,7 +526,8 @@ describe('ssh remote command builders', () => { try { const lockPath = join(root, '.install-lock') const acquire = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath)) - const legacyProbe = decodePowerShellCommand(probeDirectoryExistsCommand(windows, lockPath)) + // The directory-only lock probe older Orca builds still run against this host. + const legacyProbe = `if (Test-Path -LiteralPath '${lockPath}' -PathType Container) { 'LOCKED' } else { 'OPEN' }` await expect(runPowerShellCommand(powerShellExecutable!, acquire)).resolves.toMatch(/OK/) await expect(runPowerShellCommand(powerShellExecutable!, legacyProbe)).resolves.toMatch( diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 4cf2b53d96e..f5a01f2c3c3 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -1,16 +1,7 @@ -import { - RELAY_INSTALL_COMPLETE_FILENAME, - RELAY_PID_FILENAME, - relayArtifactFilenames -} from '../../shared/relay-artifacts' -import { - RELAY_INSTALL_MODEL, - remoteInstallListingRegexSource, - type RemoteInstallModel -} from './remote-install-model' +import { remoteInstallListingRegexSource, type RemoteInstallModel } from './remote-install-model' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' import { shellEscape } from './ssh-connection-utils' export function readRemoteHomeCommand(host: RemoteHostPlatform): string { @@ -20,23 +11,6 @@ export function readRemoteHomeCommand(host: RemoteHostPlatform): string { return powerShellCommand("Write-Output ([Environment]::GetFolderPath('UserProfile'))") } -export function makeRemoteDirectoryCommand(host: RemoteHostPlatform, remotePath: string): string { - if (!isWindowsRemoteHost(host)) { - return `mkdir -p ${shellEscape(remotePath)}` - } - // New-Item has no -LiteralPath parameter; using it breaks stock Windows PowerShell. - return powerShellCommand( - `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remotePath)}` - ) -} - -export function makeRemoteExecutableCommand(host: RemoteHostPlatform, remotePath: string): string { - if (isWindowsRemoteHost(host)) { - return powerShellCommand(`if (Test-Path -LiteralPath ${powerShellLiteral(remotePath)}) { }`) - } - return `chmod +x ${shellEscape(remotePath)} 2>/dev/null; true` -} - export function removeRemoteFileCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { return `rm -f ${shellEscape(remotePath)} 2>/dev/null; true` @@ -97,44 +71,24 @@ export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath } /** - * A partial install must read as MISSING, so every file the manifest ships is - * probed — not a hand-kept subset. A relay that advertises the AI Vault title - * service but lacks the WSL transcript helper would otherwise pass this probe - * and then answer WSL title requests with silence. - */ -export function probeRelayInstalledCommand( - host: RemoteHostPlatform, - remoteRelayDir: string -): string { - return probeRemoteInstallCompleteCommand(host, remoteRelayDir, [ - ...relayArtifactFilenames(isWindowsRemoteHost(host)), - RELAY_INSTALL_COMPLETE_FILENAME - ]) -} - -/** - * The model-agnostic form: any install is complete when its directory exists and every - * named artifact is a regular file inside it. - * - * Why the caller passes the list: orcad and the relay ship different artifacts, and a probe - * that checked a shared subset would call a torn install complete. + * An install is complete when its directory exists and every named artifact is a regular + * file inside it. A partial install must read as MISSING, so the caller passes the whole list. */ export function probeRemoteInstallCompleteCommand( host: RemoteHostPlatform, remoteInstallDir: string, requiredFilenames: readonly string[] ): string { - const remoteRelayDir = remoteInstallDir const required = requiredFilenames.map((filename) => - joinRemotePath(host, remoteRelayDir, filename) + joinRemotePath(host, remoteInstallDir, filename) ) if (!isWindowsRemoteHost(host)) { const fileTests = required.map((path) => `&& test -f ${shellEscape(path)} `).join('') - return `test -d ${shellEscape(remoteRelayDir)} ${fileTests}&& echo OK || echo MISSING` + return `test -d ${shellEscape(remoteInstallDir)} ${fileTests}&& echo OK || echo MISSING` } return powerShellCommand( [ - `$dir = ${powerShellLiteral(remoteRelayDir)}`, + `$dir = ${powerShellLiteral(remoteInstallDir)}`, `$required = @(${required.map((path) => powerShellLiteral(path)).join(', ')})`, '$ok = Test-Path -LiteralPath $dir -PathType Container', 'foreach ($f in $required) { if (-not (Test-Path -LiteralPath $f -PathType Leaf)) { $ok = $false } }', @@ -143,11 +97,7 @@ export function probeRemoteInstallCompleteCommand( ) } -export const MAX_RELAY_GC_LISTING_ENTRIES = 64 - -export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { - return listRemoteInstallBaseDirsCommand(host, baseDir, RELAY_INSTALL_MODEL) -} +export const MAX_REMOTE_INSTALL_GC_LISTING_ENTRIES = 64 /** * List one model's version dirs (and its own tombstones) under `~/.orca-remote/`. @@ -167,7 +117,7 @@ export function listRemoteInstallBaseDirsCommand( return [ `base=${shellEscape(baseDir)}; [ -d "$base" ] || exit 0;`, `{ find "$base" -mindepth 1 -maxdepth 1 -type d -name '${model.dirPrefix}-*' -print; status=$?; printf '\n${statusPrefix}%s\n' "$status"; } |`, - String.raw`awk 'BEGIN { count=0; status=-1 } /^${statusPrefix}[0-9]+$/ { status=substr($0, ${statusPrefix.length + 1}); next } { name=$0; sub(/^.*\//, "", name); if (name ~ /${namePattern}/ && count < ${MAX_RELAY_GC_LISTING_ENTRIES}) { entries[count++]=name } } END { if (status != 0) exit 1; for (i=0; i{process.stdout.write(token);process.exit(0)};', - 'let pidDead=false;', - 'let rawPid=null;', - `try{rawPid=fs.readFileSync(path.join(dir,${JSON.stringify(RELAY_PID_FILENAME)}),"utf8").trim()}catch(e){if(e.code!=="ENOENT")answer("UNVERIFIABLE")}`, - 'if(rawPid!==null){', - 'if(!/^[1-9][0-9]*$/.test(rawPid))answer("UNVERIFIABLE");', - 'try{process.kill(Number(rawPid),0)}catch(e){if(e.code!=="ESRCH")answer("UNVERIFIABLE");pidDead=true}', - 'if(!pidDead)answer("ALIVE")', - '}', - 'const valid=/^\\\\\\\\[.?]\\\\pipe\\\\orca-relay-[0-9a-f]{20}$/i;', - 'const pipes=[];', - 'let markerCount=0;', - 'for(const p of seed){if(valid.test(p)&&!pipes.includes(p))pipes.push(p)}', - 'try{for(const name of fs.readdirSync(dir)){', - 'if(!name.startsWith(".windows-active-pipe-"))continue;', - 'markerCount++;', - 'const p=fs.readFileSync(path.join(dir,name),"utf8").trim();', - 'if(valid.test(p)&&!pipes.includes(p))pipes.push(p)', - '}}catch{}', - 'if(markerCount===0&&pipes.length===0)answer(pidDead?"DEAD":"ALIVE");', - 'let i=0;', - 'function done(ok){process.stdout.write(ok?"ALIVE":"WAITING")}', - 'function next(){', - 'const pipe=pipes[i++];', - 'if(!pipe)return done(false);', - 'const s=net.connect(pipe);', - 'let settled=false;', - 'function finish(ok){if(settled)return;settled=true;s.destroy();if(ok)done(true);else next()}', - 's.setTimeout(200);', - 's.on("connect",()=>finish(true));', - 's.on("timeout",()=>finish(false));', - 's.on("error",()=>finish(false));', - '}', - 'next();' -].join('') - -export function relayLivenessProbeCommand( - host: RemoteHostPlatform, - dir: string, - windowsOptions?: WindowsRelayLivenessOptions -): string { - if (!isWindowsRemoteHost(host)) { - return ( - `state=DEAD; for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` + - `[ -S "$f" ] && state=ALIVE && break; ` + - 'done; echo "$state"' - ) - } - if (!windowsOptions) { - return powerShellCommand("'ALIVE'") - } - const js = WINDOWS_RELAY_LIVENESS_JS - return commandWithNodePath( - host, - windowsOptions.nodePath, - dir, - [ - `& ${powerShellLiteral(windowsOptions.nodePath)}`, - '-e', - powerShellNativeArg(js), - powerShellNativeArg(dir), - ...windowsOptions.pipePaths.map((pipePath) => powerShellNativeArg(pipePath)) - ].join(' ') - ) -} - export function commandWithNodePath( host: RemoteHostPlatform, nodePath: string, diff --git a/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts b/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts index cecf011fffc..64f1b82d112 100644 --- a/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts +++ b/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts @@ -2,9 +2,7 @@ import { describe, expect, it } from 'vitest' import { buildPosixNodeToolchainProbe, buildWindowsNodeToolchainProbe, - hostNodeMeetsAddonRequirements, - nodeToolchainVersionsMeetRequirements, - parseHostNodeAddonFacts + nodeToolchainVersionsMeetRequirements } from './ssh-remote-node-toolchain-probe' describe('remote Node/npm toolchain probe', () => { @@ -48,28 +46,4 @@ describe('remote Node/npm toolchain probe', () => { expect(nodeToolchainVersionsMeetRequirements('v18.0.0\n')).toBe(true) expect(nodeToolchainVersionsMeetRequirements('v16.20.2\n')).toBe(false) }) - - it('probes Node and its N-API level without npm in addon-only mode', () => { - expect(buildPosixNodeToolchainProbe('/opt/node/bin/node', 'addon-only')).toBe( - "printf '%s\\n' '__ORCA_NODE_VERSION__' && '/opt/node/bin/node' --version && " + - "printf '%s\\n' '__ORCA_NAPI_VERSION__' && '/opt/node/bin/node' -p process.versions.napi || true" - ) - }) - - it('parses addon facts and gates on the Node 18 floor and N-API level', () => { - const facts = parseHostNodeAddonFacts( - 'motd\n__ORCA_NODE_VERSION__\nv18.19.1\n__ORCA_NAPI_VERSION__\n9\n' - ) - expect(facts).toEqual({ version: { major: 18, minor: 19 }, napi: 9 }) - expect(hostNodeMeetsAddonRequirements(facts, 8)).toBe(true) - expect(hostNodeMeetsAddonRequirements(facts, 10)).toBe(false) - expect( - hostNodeMeetsAddonRequirements( - parseHostNodeAddonFacts('__ORCA_NODE_VERSION__\nv16.20.2\n__ORCA_NAPI_VERSION__\n8\n'), - 8 - ) - ).toBe(false) - // A Node that failed to run prints no version: not a candidate. - expect(parseHostNodeAddonFacts('__ORCA_NODE_VERSION__\n__ORCA_NAPI_VERSION__\n')).toBeNull() - }) }) diff --git a/src/main/ssh/ssh-remote-node-toolchain-probe.ts b/src/main/ssh/ssh-remote-node-toolchain-probe.ts index 1882b2f6760..be658996137 100644 --- a/src/main/ssh/ssh-remote-node-toolchain-probe.ts +++ b/src/main/ssh/ssh-remote-node-toolchain-probe.ts @@ -2,33 +2,14 @@ import { posix as posixPath } from 'node:path' import { shellEscape } from './ssh-connection-utils' import { powerShellLiteral } from './ssh-remote-powershell' -/** The host-Node floor for both the npm path and rung C (design D6). */ +/** The host-Node floor for the npm path. */ export const MIN_HOST_NODE_MAJOR = 18 const NODE_VERSION_MARKER = '__ORCA_NODE_VERSION__' const NPM_VERSION_MARKER = '__ORCA_NPM_VERSION__' -const NAPI_VERSION_MARKER = '__ORCA_NAPI_VERSION__' -/** - * `npm` installs native deps on the host; `addon-only` loads Orca's prebuilt N-API addons, - * so it needs a Node and its N-API level but never npm (design D6 rung C). - */ -export type NodeToolchainRequirement = 'npm' | 'addon-only' - -export function buildPosixNodeToolchainProbe( - nodePath: string, - requirement: NodeToolchainRequirement = 'npm' -): string { +export function buildPosixNodeToolchainProbe(nodePath: string): string { const nodeBinDir = posixPath.dirname(nodePath) const node = shellEscape(nodePath) - if (requirement === 'addon-only') { - // Why `|| true`: a Node that cannot run is an answer ("not this one"), not a lost channel. - return `${[ - `printf '%s\\n' '${NODE_VERSION_MARKER}'`, - `${node} --version`, - `printf '%s\\n' '${NAPI_VERSION_MARKER}'`, - `${node} -p process.versions.napi` - ].join(' && ')} || true` - } return [ `printf '%s\\n' '${NODE_VERSION_MARKER}'`, `${node} --version`, @@ -68,23 +49,7 @@ export function nodeToolchainVersionsMeetRequirements(versionOutput: string): bo return legacyMatch ? Number.parseInt(legacyMatch[1]!, 10) >= MIN_HOST_NODE_MAJOR : false } -export type HostNodeVersion = { major: number; minor: number } - -export type HostNodeAddonFacts = { version: HostNodeVersion; napi: number } - -/** The addon-only probe's answer, or null when the Node did not print both facts. */ -export function parseHostNodeAddonFacts(output: string): HostNodeAddonFacts | null { - const version = markedVersion(output, NODE_VERSION_MARKER) - const napi = markedInteger(output, NAPI_VERSION_MARKER) - return version && napi !== null ? { version, napi } : null -} - -export function hostNodeMeetsAddonRequirements( - facts: HostNodeAddonFacts | null, - requiredNapi: number -): boolean { - return facts !== null && facts.version.major >= MIN_HOST_NODE_MAJOR && facts.napi >= requiredNapi -} +type HostNodeVersion = { major: number; minor: number } function linesAfterMarker(output: string, marker: string): string[] | null { const lines = output.split(/\r?\n/) @@ -106,13 +71,3 @@ function markedVersion(output: string, marker: string): HostNodeVersion | null { } return null } - -function markedInteger(output: string, marker: string): number | null { - for (const line of linesAfterMarker(output, marker) ?? []) { - const match = line.trim().match(/^(\d+)$/) - if (match) { - return Number.parseInt(match[1]!, 10) - } - } - return null -} diff --git a/src/packages/process-host/src/posix-lsof-probe.test.ts b/src/packages/process-host/src/posix-lsof-probe.test.ts index baf94be94b8..8b931587157 100644 --- a/src/packages/process-host/src/posix-lsof-probe.test.ts +++ b/src/packages/process-host/src/posix-lsof-probe.test.ts @@ -171,8 +171,8 @@ describe.skipIf(process.platform === 'win32')('lsof supervisor lifecycle', () => * exits 1 with no stdout and no stderr. Measured on Debian 12: a live relay owned by root, * probed as `nobody`, produced `exit=1 stdout=[] stderr=[]` — byte-identical to a genuinely * stale socket, so no amount of inspecting lsof's answer can separate the two. That answer - * reaches `verdict: exited / evidence: no-holder`, which `classifySupersededRelay` turns into - * `rm -f` on an inode a live relay still holds. + * reaches `verdict: exited / evidence: no-holder`, which the host census reads as an idle + * endpoint a live relay still holds. * * The stub reproduces that exact shape rather than switching uid, which needs root and two * accounts. What separates the cases is /proc/net/unix, which is world-readable. diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts index 77477d725e7..208c8a06553 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts @@ -31,8 +31,7 @@ type Status = SshConnectionState['managedServer'] const offer: Status = { kind: 'relay', reason: 'relay_terminals_live', - terminals: 3, - offerMove: true + terminals: 3 } const moveToManagedServer = vi.fn() diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts index 66d62652483..3d1706cc6b9 100644 --- a/src/shared/relay-artifacts.ts +++ b/src/shared/relay-artifacts.ts @@ -90,9 +90,6 @@ export const RELAY_VERSION_FILENAME = '.version' /** Written last by the installer; its absence means a torn install. */ export const RELAY_INSTALL_COMPLETE_FILENAME = '.install-complete' -/** PID of the last relay daemon that bound a socket from this version dir; GC liveness evidence. */ -export const RELAY_PID_FILENAME = '.relay-pid' - /** Artifacts every relay must have; the remote install probe requires each one. */ export function relayArtifactFilenames(isWindows: boolean): string[] { return RELAY_ARTIFACTS.filter( diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts index d55aa3b36c3..ac90d006ff2 100644 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -103,14 +103,14 @@ describe('SSH retained payload admission', () => { expect(malformed).not.toHaveProperty('hostNodeRuntime') }) - it('admits only a literal move offer on a relay server status', () => { + it("drops an older host's move offer and admits only a literal elsewhere flag", () => { const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 } const relay = { kind: 'relay', reason: 'relay_terminals_live', terminals: 2 } expect( admitSshConnectionState({ ...state, managedServer: { ...relay, offerMove: true } }, 'ssh-a') ?.managedServer - ).toEqual({ ...relay, offerMove: true }) + ).toEqual(relay) expect( admitSshConnectionState( { ...state, managedServer: { ...relay, terminalsElsewhere: true } }, @@ -118,8 +118,10 @@ describe('SSH retained payload admission', () => { )?.managedServer ).toEqual({ ...relay, terminalsElsewhere: true }) expect( - admitSshConnectionState({ ...state, managedServer: { ...relay, offerMove: 'yes' } }, 'ssh-a') - ?.managedServer + admitSshConnectionState( + { ...state, managedServer: { ...relay, terminalsElsewhere: 'yes' } }, + 'ssh-a' + )?.managedServer ).toEqual(relay) }) diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts index 5bbea400393..461466057fb 100644 --- a/src/shared/ssh-retained-payload-admission.ts +++ b/src/shared/ssh-retained-payload-admission.ts @@ -158,7 +158,6 @@ function admitSshManagedServerStatus(value: unknown): { managedServer?: SshManag ? { detail: clampUtf8TextPrefix(detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) } : {}), ...(isNonNegativeSafeInteger(terminals) ? { terminals } : {}), - ...('offerMove' in value && value.offerMove === true ? { offerMove: true } : {}), ...('terminalsElsewhere' in value && value.terminalsElsewhere === true ? { terminalsElsewhere: true } : {}) diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 592f0cd7beb..1add7af3dfb 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -6,9 +6,6 @@ import type { OrcadSshProvisioningIntent } from './orcad-ssh-provisioning' export const MIN_SSH_RELAY_GRACE_PERIOD_SECONDS = 60 export const MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 7 * 24 * 60 * 60 export const LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60 -export const DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS = 24 * 60 * 60 -export const DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 0 -export const SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD = 'relay.configureGraceTime' /** * Which runtime executes the SSH relay. `pinned-node` runs the D6 ladder on Orca's uploaded Node and @@ -314,8 +311,6 @@ export type SshManagedServerStatus = detail?: string /** Relay terminals still running, when that is what keeps the host on the relay. */ terminals?: number - /** Show the one-time offer to move now, restarting those terminals. */ - offerMove?: boolean /** Those terminals belong to another Orca desktop or session, so this one offers no move. */ terminalsElsewhere?: boolean } diff --git a/src/shared/unix-socket-path-limit.ts b/src/shared/unix-socket-path-limit.ts index 3b4fc20fad7..4ca58882fb2 100644 --- a/src/shared/unix-socket-path-limit.ts +++ b/src/shared/unix-socket-path-limit.ts @@ -6,7 +6,7 @@ const SUN_PATH_SIZE: Record<'linux' | 'darwin', number> = { linux: 108, darwin: 104 } /** Longest socket path, in UTF-8 bytes, that fits `sun_path` beside its NUL. */ -export function unixSocketPathByteLimit(os: 'linux' | 'darwin'): number { +function unixSocketPathByteLimit(os: 'linux' | 'darwin'): number { return SUN_PATH_SIZE[os] - 1 } diff --git a/src/shared/windows-breakaway-launch.ts b/src/shared/windows-breakaway-launch.ts index 1287c96501a..b81e53eafed 100644 --- a/src/shared/windows-breakaway-launch.ts +++ b/src/shared/windows-breakaway-launch.ts @@ -21,11 +21,6 @@ export type WindowsBreakawayLaunchContract = { reportMarker: string } -export const RELAY_WINDOWS_BREAKAWAY_CONTRACT: WindowsBreakawayLaunchContract = { - argsFlag: '--relay-args', - reportMarker: 'ORCA_RELAY_LAUNCH' -} - export const ORCAD_WINDOWS_BREAKAWAY_CONTRACT: WindowsBreakawayLaunchContract = { argsFlag: '--orcad-args', reportMarker: 'ORCA_ORCAD_LAUNCH' diff --git a/src/shared/windows-breakaway-launcher.test.ts b/src/shared/windows-breakaway-launcher.test.ts index 86a8c590a83..82994c2d3a9 100644 --- a/src/shared/windows-breakaway-launcher.test.ts +++ b/src/shared/windows-breakaway-launcher.test.ts @@ -3,9 +3,9 @@ import { quoteWindowsArgument } from '@orca/process-host/windows-command-line' import { ORCAD_WINDOWS_BREAKAWAY_CONTRACT, parseWindowsBreakawayLaunchReport, - RELAY_WINDOWS_BREAKAWAY_CONTRACT, WINDOWS_BREAKAWAY_EXIT_CODES as RELAY_WINDOWS_BREAKAWAY_EXIT_CODES, - WINDOWS_BREAKAWAY_LAUNCH_FLAG as RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG + WINDOWS_BREAKAWAY_LAUNCH_FLAG as RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG, + type WindowsBreakawayLaunchContract } from './windows-breakaway-launch' import { launchOutsideJob, @@ -15,6 +15,12 @@ import { type WindowsBreakawayLauncher } from './windows-breakaway-launcher' +// The launcher is contract-agnostic; this stands in for the retired relay's contract. +const RELAY_WINDOWS_BREAKAWAY_CONTRACT: WindowsBreakawayLaunchContract = { + argsFlag: '--relay-args', + reportMarker: 'ORCA_RELAY_LAUNCH' +} + const parseRelayWindowsBreakawayLaunch = (args: readonly string[]) => parseWindowsBreakawayLaunchRequest(RELAY_WINDOWS_BREAKAWAY_CONTRACT, args)