From e2b6af8916d29cefb0d6d49de940ef4ca85597db Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 17:08:19 -0700 Subject: [PATCH 1/2] feat(runtime): isolate the server behind a compatibility launcher --- config/scripts/build-orcad.mjs | 19 ++- ...heck-runtime-launcher-protocol-ratchet.mjs | 2 + .../headless-detector-compiler-cache.test.mjs | 5 + config/scripts/node-server-change-scope.mjs | 12 +- config/scripts/node-server-test-paths.mjs | 1 + config/scripts/orcad-entry-build.mjs | 25 ++- .../scripts/orcad-prebuild-slot-contents.mjs | 4 +- .../orcad-prebuild-slot-contents.test.mjs | 2 +- .../orcad-terminal-smoke-change-scope.mjs | 7 +- config/scripts/profile-state-worker-smoke.mjs | 3 +- config/scripts/run-node-server-tests.mjs | 7 +- .../scripts/runtime-serve-terminal-smoke.mjs | 8 +- src/main/orcad/launcher.ts | 23 +++ src/main/orcad/main-preflight-order.test.ts | 2 +- src/main/orcad/main.ts | 57 +++---- src/main/orcad/orcad-bundled-runtime.test.ts | 39 ++++- src/main/orcad/orcad-bundled-runtime.ts | 17 ++ src/main/orcad/orcad-health.ts | 9 +- ...cad-launcher-isolation.integration.test.ts | 159 ++++++++++++++++++ .../orcad/orcad-local-serve-selection.test.ts | 3 +- src/main/orcad/orcad-local-serve-selection.ts | 8 +- src/main/orcad/orcad-managed-stop.test.ts | 33 +++- .../orcad/orcad-profile-preflight.test.ts | 2 +- src/main/orcad/orcad-profile-preflight.ts | 4 +- src/main/ssh/orcad-activation-gate.ts | 2 +- src/main/ssh/orcad-local-build-hash.ts | 22 +-- ...rcad-remote-build-hash.integration.test.ts | 42 +++++ src/main/ssh/orcad-remote-build-hash.ts | 8 +- src/main/ssh/orcad-remote-launch-windows.ts | 24 ++- src/main/ssh/orcad-remote-launch.ts | 13 +- src/main/ssh/orcad-remote-preflight.ts | 27 ++- src/main/ssh/orcad-remote-runtime-control.ts | 14 +- src/main/ssh/orcad-remote-runtime.ts | 10 ++ ...-remote-shell-commands.integration.test.ts | 89 ++++++---- .../ssh/orcad-remote-windows-commands.test.ts | 21 ++- .../ssh/orcad-remote-windows-scripts.test.ts | 24 +++ src/main/ssh/orcad-windows-host-script.ts | 11 +- src/shared/orcad-artifacts.ts | 5 +- src/shared/orcad-build-identity.test.ts | 34 ++++ src/shared/orcad-build-identity.ts | 19 +++ 40 files changed, 670 insertions(+), 146 deletions(-) create mode 100644 src/main/orcad/launcher.ts create mode 100644 src/main/orcad/orcad-launcher-isolation.integration.test.ts create mode 100644 src/main/ssh/orcad-remote-build-hash.integration.test.ts create mode 100644 src/shared/orcad-build-identity.test.ts create mode 100644 src/shared/orcad-build-identity.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index a45c5fb960a2..96a46677626f 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node -// Package orcad for the pinned Node; keep module loading compatible with legacy Node launchers. +// Package the server separately from its compatibility launcher. import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' import { buildOrcadEntry, + buildOrcadLauncher, externalNativeAddons, ORCAD_EXTERNAL_MODULES, ORCAD_CHILD_ENTRY_POINTS @@ -30,6 +31,8 @@ import { ORCAD_EMOJI_SHORTCODE_DATASET, ORCAD_FOREIGN_SQLITE_READER_ENTRY, ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY, + ORCAD_LAUNCHER_FILENAME, + ORCAD_SERVER_ENTRY_FILENAME, ORCAD_NODE_PTY_DIR, ORCAD_NODE_PTY_JS_ARTIFACTS, ORCAD_NODE_RUNTIME_MARKER_FILENAME, @@ -67,7 +70,8 @@ const FOREIGN_SQLITE_READER_OUT_FILE = join(OUT_DIR, ORCAD_FOREIGN_SQLITE_READER // and `resolveWorkerEntryPath` looks for it next to the running bundle. const PORT_SCAN_WORKER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.portScanCommandWorker) const PORT_SCAN_WORKER_OUT_FILE = join(OUT_DIR, ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY) -const OUT_FILE = join(OUT_DIR, 'orcad.js') +const OUT_FILE = join(OUT_DIR, ORCAD_LAUNCHER_FILENAME) +const SERVER_OUT_FILE = join(OUT_DIR, ORCAD_SERVER_ENTRY_FILENAME) const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET if (!BUILD_TARGET) { throw new Error('ORCAD_BUILD_TARGET is required; run `pnpm build:orcad`') @@ -229,7 +233,8 @@ const childResults = await Promise.all([ ) ]) -const result = await buildOrcadEntry(OUT_FILE) +const result = await buildOrcadEntry(SERVER_OUT_FILE) +const launcherResult = await buildOrcadLauncher(OUT_FILE) const output = Object.values(result.metafile.outputs).find( (o) => o.entryPoint === 'src/main/orcad/main.ts' @@ -254,7 +259,11 @@ function collectImporters(metafiles, matches) { return importers } -const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] +const metafiles = [ + launcherResult.metafile, + result.metafile, + ...childResults.map((child) => child.metafile) +] const electronImporters = collectImporters( metafiles, (specifier) => specifier === 'electron' || specifier.startsWith('electron/') @@ -294,7 +303,7 @@ if (graphErrors.length > 0) { // Node's uncaught-exception report echoes that whole line — which contains every string // literal in the bundle. A crash therefore "matches" any expected message, and a textual // assertion passes against a bundle that never loaded. - const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], { + const smoke = spawnSync(process.execPath, [SERVER_OUT_FILE, '--orcad-smoke-load-check'], { encoding: 'utf8', timeout: 60_000 }) diff --git a/config/scripts/check-runtime-launcher-protocol-ratchet.mjs b/config/scripts/check-runtime-launcher-protocol-ratchet.mjs index a26a5d87aa31..d289819fe67b 100644 --- a/config/scripts/check-runtime-launcher-protocol-ratchet.mjs +++ b/config/scripts/check-runtime-launcher-protocol-ratchet.mjs @@ -12,6 +12,8 @@ import { DAEMON_PROTOCOL_SOURCE_PATH, parseDaemonProtocolFacts } from './daemon- export const RUNTIME_LAUNCHER_PATHS = [ // orcad handoff to its bundled runtime, and the pinned runtimes it can hand off to. 'src/main/orcad/orcad-bundled-runtime.ts', + 'src/main/orcad/launcher.ts', + 'config/scripts/orcad-entry-build.mjs', 'src/shared/node-runtime-pin.ts', 'src/main/ssh/pinned-runtime-materializer.ts', 'src/main/ssh/runtime-archive-download.ts', diff --git a/config/scripts/headless-detector-compiler-cache.test.mjs b/config/scripts/headless-detector-compiler-cache.test.mjs index 484807159a75..be7f7d2ba8ae 100644 --- a/config/scripts/headless-detector-compiler-cache.test.mjs +++ b/config/scripts/headless-detector-compiler-cache.test.mjs @@ -82,6 +82,11 @@ it('activates only the actual compiler packages and preserves import graph behav join(root, 'config', 'scripts', `${name}.mjs`) ) } + mkdirSync(join(root, 'src', 'shared'), { recursive: true }) + cpSync( + new URL('../../src/shared/orcad-artifacts.ts', import.meta.url), + join(root, 'src', 'shared', 'orcad-artifacts.ts') + ) writeFileSync( join(root, 'entry.ts'), "import './first'; export * from './exports'; import('./dynamic'); require('./required'); import 'external-package'; import './native.node'" diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 71e788867e1b..0499796eab17 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -65,10 +65,18 @@ export function discoverNodeServerTests(root = ROOT) { } export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) { - const [{ build }, { externalNativeAddons, ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT }] = - await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')]) + const [ + { build }, + { + externalNativeAddons, + ORCAD_CHILD_ENTRY_POINTS, + ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT + } + ] = await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')]) const entries = entryPoints ?? [ ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), ...BUILD_SCRIPTS, ...discoverNodeServerTests(root) diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 8d9e8eb67102..9e51b23af9ce 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -31,6 +31,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = 'src/main/orcad/orcad-packaged-node-pty.integration.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', 'src/main/orcad/orcad-node-launcher.integration.test.ts', + 'src/main/orcad/orcad-launcher-isolation.integration.test.ts', 'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts', 'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts', 'src/main/orcad/orcad-serve-parity.integration.test.ts', diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index 90767764d21c..a746ac4bb5b2 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -1,9 +1,13 @@ import { build } from 'esbuild' -import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts' const root = join(import.meta.dirname, '..', '..') export const ORCAD_ENTRY_POINT = 'src/main/orcad/main.ts' +export const ORCAD_LAUNCHER_ENTRY_POINT = 'src/main/orcad/launcher.ts' export const ORCAD_CHILD_ENTRY_POINTS = { watcher: 'src/main/ipc/parcel-watcher-process-entry.ts', daemon: 'src/main/daemon/daemon-entry.ts', @@ -50,3 +54,22 @@ export function buildOrcadEntry(outfile) { logLevel: 'error' }) } + +export function buildOrcadLauncher(outfile) { + const serverSha256 = createHash('sha256') + .update(readFileSync(join(dirname(outfile), ORCAD_SERVER_ENTRY_FILENAME))) + .digest('hex') + return build({ + entryPoints: [join(root, ORCAD_LAUNCHER_ENTRY_POINT)], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + metafile: true, + minify: true, + sourcemap: false, + define: { ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) }, + logLevel: 'error' + }) +} diff --git a/config/scripts/orcad-prebuild-slot-contents.mjs b/config/scripts/orcad-prebuild-slot-contents.mjs index 4084d9fcc20e..8a6482e6e854 100644 --- a/config/scripts/orcad-prebuild-slot-contents.mjs +++ b/config/scripts/orcad-prebuild-slot-contents.mjs @@ -17,8 +17,8 @@ const require = createRequire(import.meta.url) export const MANIFEST_SCHEMA_VERSION = 2 /** - * Node 18, the rung C host floor, supports N-API 8 (9 only from 18.17). Pinned here rather - * than left to the headers' default so a header bump cannot silently raise it. + * Keep N-API 8 stable across bundled and host runtimes rather than using the headers' + * default, so a header bump cannot silently change the shipped addon ABI. */ export const SLOT_NAPI_VERSION = 8 diff --git a/config/scripts/orcad-prebuild-slot-contents.test.mjs b/config/scripts/orcad-prebuild-slot-contents.test.mjs index 720a0436c3ef..af6f3b45f5e4 100644 --- a/config/scripts/orcad-prebuild-slot-contents.test.mjs +++ b/config/scripts/orcad-prebuild-slot-contents.test.mjs @@ -57,7 +57,7 @@ const next = (slot, overrides = {}) => ({ }) describe('N-API pinning', () => { - it('pins N-API 8 so a host Node 18 (rung C) can load every slot', () => { + it('keeps the N-API 8 addon ABI stable across runtime upgrades', () => { expect(SLOT_NAPI_VERSION).toBe(8) // The client's rung C host-Node gate must ask for exactly what the slots are built against. expect(ORCAD_ADDON_NAPI_VERSION).toBe(SLOT_NAPI_VERSION) diff --git a/config/scripts/orcad-terminal-smoke-change-scope.mjs b/config/scripts/orcad-terminal-smoke-change-scope.mjs index 3ece0aa5b3a9..45df389a372e 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.mjs @@ -1,10 +1,15 @@ import { readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' import { classifyNodeServerChanges, collectNodeServerInputs } from './node-server-change-scope.mjs' -import { ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT } from './orcad-entry-build.mjs' +import { + ORCAD_CHILD_ENTRY_POINTS, + ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT +} from './orcad-entry-build.mjs' const ENTRY_POINTS = [ ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), 'src/cli/index.ts', 'config/scripts/build-orcad-node.mjs', diff --git a/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs index 9071417145dd..18ac442ddca6 100644 --- a/config/scripts/profile-state-worker-smoke.mjs +++ b/config/scripts/profile-state-worker-smoke.mjs @@ -11,6 +11,7 @@ import { ORCAD_PROFILE_PREFLIGHT_FLAG, parseOrcadProfilePreflight } from '../../src/shared/orcad-profile-preflight.ts' +import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' async function initializeFixture(directory, databasePath, profileId) { @@ -88,7 +89,7 @@ export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000, run const nonce = randomUUID() const result = runProcessSync({ program: runtimePath, - args: [join(outDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], + args: [join(outDir, ORCAD_SERVER_ENTRY_FILENAME), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, timeoutMs, maxOutputBytes: 64 * 1024 diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs index 2183b9665503..948635f1c187 100644 --- a/config/scripts/run-node-server-tests.mjs +++ b/config/scripts/run-node-server-tests.mjs @@ -2,7 +2,10 @@ import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { globSync, readFileSync } from 'node:fs' -import { ORCAD_VERSION_FILENAME } from '../../src/shared/orcad-artifacts.ts' +import { + ORCAD_SERVER_ENTRY_FILENAME, + ORCAD_VERSION_FILENAME +} from '../../src/shared/orcad-artifacts.ts' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { ORCAD_PROFILE_PREFLIGHT_FLAG, @@ -60,7 +63,7 @@ if (artifact) { const nonce = randomUUID() const result = runProcessSync({ program: runtimePath, - args: [join(packageDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], + args: [join(packageDir, ORCAD_SERVER_ENTRY_FILENAME), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], cwd: root, env, timeoutMs: 90_000 diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index 2b23115859ca..0c43f7d60c1e 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -28,10 +28,14 @@ import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { randomBytes } from 'node:crypto' import process from 'node:process' +import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts' +import { packagedNodeRuntimePath } from './build-orcad-node.mjs' +import { currentTarget } from './server-build-target.mjs' const projectDir = resolve(import.meta.dirname, '../..') const serveEntry = join(projectDir, 'out', 'main', 'index.js') -const ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js') +const ORCAD_DIR = join(projectDir, 'out', 'orcad') +const ORCAD_ENTRY = join(ORCAD_DIR, ORCAD_SERVER_ENTRY_FILENAME) const READY_TIMEOUT_MS = 120_000 const OUTPUT_TIMEOUT_MS = 30_000 const SHUTDOWN_TIMEOUT_MS = 15_000 @@ -175,7 +179,7 @@ function resolveLaunch(userDataDir) { if (target === 'orcad') { return { label: `orcad (${ORCAD_ENTRY})`, - command: process.execPath, + command: packagedNodeRuntimePath(ORCAD_DIR, currentTarget()), args: [ORCAD_ENTRY, '--port', String(PORT), '--json'], env: { ORCA_USER_DATA: userDataDir } } diff --git a/src/main/orcad/launcher.ts b/src/main/orcad/launcher.ts new file mode 100644 index 000000000000..05003eba857b --- /dev/null +++ b/src/main/orcad/launcher.ts @@ -0,0 +1,23 @@ +import { createHash } from 'node:crypto' +import { readFileSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' +import { assertOrcadServerRuntime, handoffToBundledOrcad } from './orcad-bundled-runtime' + +declare const ORCAD_SERVER_SHA256: string + +try { + if (!handoffToBundledOrcad()) { + assertOrcadServerRuntime() + const entry = realpathSync(process.argv[1] ?? __filename) + const server = join(dirname(entry), ORCAD_SERVER_ENTRY_FILENAME) + if (createHash('sha256').update(readFileSync(server)).digest('hex') !== ORCAD_SERVER_SHA256) { + throw new Error('The Orca server does not match its launcher') + } + createRequire(entry)(server) + } +} catch (error) { + console.error('orcad: failed to launch:', error) + process.exit(78) +} diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index d6744cdcb10b..5a77d2c5cca3 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -18,7 +18,7 @@ const { order, profileProbe } = vi.hoisted(() => { return { order, profileProbe: vi.fn(async () => {}) } }) -vi.mock('./orcad-bundled-runtime', () => ({ handoffToBundledOrcad: () => false })) +vi.mock('./orcad-bundled-runtime', () => ({ assertOrcadServerRuntime: () => {} })) vi.mock('./orcad-profile-preflight', () => ({ preflightBundledOrcadStartup: async () => { order.push('profile-admission') diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 325ada702d68..49490ffedf37 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -8,7 +8,7 @@ import { ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' -import { handoffToBundledOrcad } from './orcad-bundled-runtime' +import { assertOrcadServerRuntime } from './orcad-bundled-runtime' import { formatOrcadNativePreflightReport, ORCAD_NATIVE_PREFLIGHT_FLAG @@ -44,7 +44,7 @@ function failStartup(error: unknown): void { process.exit(resolveOrcadExitCode(error)) } -// Why before the bundled handoff and preflights: launching or completing a stop must not start a runtime. +// One-shot launch and stop commands must skip server startup and preflights. if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { runWindowsBreakawayLaunchIfRequested(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, process.argv) } else if ( @@ -60,34 +60,33 @@ if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { function startOrcadProcess(): void { try { - if (!handoffToBundledOrcad()) { - const flag = process.argv[2] - if (flag === ORCAD_NATIVE_PREFLIGHT_FLAG && process.argv.length === 3) { - void import('./node-pty-precondition').then(({ checkNodePtyPrecondition }) => { - const verdict = checkNodePtyPrecondition() - const report = formatOrcadNativePreflightReport(verdict.status, verdict.reason ?? null) - process.stdout.write(`${report}\n`, () => process.exit(0)) - }, failStartup) - } else if ( - (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && - process.argv.length === 4 - ) { - void runOrcadProfilePreflight(process.argv[3], { - nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + assertOrcadServerRuntime() + const flag = process.argv[2] + if (flag === ORCAD_NATIVE_PREFLIGHT_FLAG && process.argv.length === 3) { + void import('./node-pty-precondition').then(({ checkNodePtyPrecondition }) => { + const verdict = checkNodePtyPrecondition() + const report = formatOrcadNativePreflightReport(verdict.status, verdict.reason ?? null) + process.stdout.write(`${report}\n`, () => process.exit(0)) + }, failStartup) + } else if ( + (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && + process.argv.length === 4 + ) { + void runOrcadProfilePreflight(process.argv[3], { + nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + }) + // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. + .then(() => process.stdout.write('', () => process.exit(0))) + .catch(failStartup) + } else { + // Why: stdout is the serve readiness API; incidental diagnostics go to stderr. + reserveServeStdoutForReadiness() + void preflightBundledOrcadStartup() + .then(() => { + runOrcadNativePreflight() + return main() }) - // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. - .then(() => process.stdout.write('', () => process.exit(0))) - .catch(failStartup) - } else { - // Why: stdout is the serve readiness API; incidental diagnostics go to stderr. - reserveServeStdoutForReadiness() - void preflightBundledOrcadStartup() - .then(() => { - runOrcadNativePreflight() - return main() - }) - .catch(failStartup) - } + .catch(failStartup) } } catch (error) { failStartup(error) diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts index 9fe9b96afb78..3cbfa8546c3e 100644 --- a/src/main/orcad/orcad-bundled-runtime.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -1,7 +1,11 @@ import { EventEmitter } from 'node:events' import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { handoffToBundledOrcad, resolveBundledOrcadSlot } from './orcad-bundled-runtime' +import { + assertOrcadServerRuntime, + handoffToBundledOrcad, + resolveBundledOrcadSlot +} from './orcad-bundled-runtime' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS, @@ -238,3 +242,36 @@ describe('bundled Orca runtime handoff', () => { expect(process.kill).not.toHaveBeenCalled() }) }) + +describe('server runtime admission', () => { + it.each(['14.21.3', '16.20.2'])('refuses host Node %s before loading profile state', (node) => { + fixture.exists.mockReturnValue(false) + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, node }) + expect(() => assertOrcadServerRuntime()).toThrow('requires Node.js 18') + }) + + it.each(['18.20.8', '20.19.0', '22.14.0', '23.0.0', '24.0.0', '26.0.0'])( + 'accepts unpackaged host Node %s', + (node) => { + fixture.exists.mockReturnValue(false) + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, node }) + expect(() => assertOrcadServerRuntime()).not.toThrow() + } + ) + + it('refuses host Node 24 for a packaged slot that owns a bundled runtime', () => { + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, node: '24.0.0' }) + expect(() => assertOrcadServerRuntime()).toThrow('Start the Orca server with its bundled') + }) + + it('accepts the packaged runtime only at its pinned version', () => { + fixture.realpath.mockImplementation((path) => + path === '/slot/orcad.js' ? path : '/real/runtime' + ) + const versions = vi.spyOn(process, 'versions', 'get') + versions.mockReturnValue({ ...process.versions, node: '24.0.0' }) + expect(() => assertOrcadServerRuntime()).toThrow(`must be Node ${NODE_RUNTIME_PIN.version}`) + versions.mockReturnValue({ ...process.versions, node: NODE_RUNTIME_PIN.version }) + expect(() => assertOrcadServerRuntime()).not.toThrow() + }) +}) diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts index 497e8a1120f9..927018ac37b3 100644 --- a/src/main/orcad/orcad-bundled-runtime.ts +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -64,6 +64,23 @@ export function isRunningAsBundledOrcadRuntime(directory: string): boolean { return runtime !== null && realpathSync(process.execPath) === realpathSync(runtime) } +/** Refuse an old or substituted runtime before the server opens any profile state. */ +export function assertOrcadServerRuntime(): void { + if (Number(process.versions.node.split('.')[0]) < 18) { + throw new OrcadBundledRuntimeError('The Orca server requires Node.js 18 or newer') + } + const directory = resolveBundledOrcadSlot() + const runtime = resolveBundledOrcadRuntime(directory) + if (runtime && !isRunningAsBundledOrcadRuntime(directory)) { + throw new OrcadBundledRuntimeError('Start the Orca server with its bundled Node.js runtime') + } + if (runtime && process.versions.node !== NODE_RUNTIME_PIN.version) { + throw new OrcadBundledRuntimeError( + `The bundled Orca runtime must be Node ${NODE_RUNTIME_PIN.version}` + ) + } +} + /** Keep old Node service commands usable without letting a host Node open the profile. */ export function handoffToBundledOrcad(): boolean { const script = process.argv[1] diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index c5ab26d28c3e..d8cf2f705a28 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -8,8 +8,7 @@ * to cross the process boundary: orcad drives it, the daemon performs it, and the verdict * travels back over the daemon's socket. */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { hashOrcadLauncher } from '../../shared/orcad-build-identity' import process from 'node:process' import { checkDaemonHealthWithCoverage, type DaemonHealth } from '../daemon/daemon-health' import { ptySpawnHealthPlatformCoverage } from '../daemon/daemon-health-identity' @@ -58,7 +57,7 @@ export type TerminalDaemonHealth = { } export type OrcadHealth = { - /** Content hash of the running orcad bundle — the deployed build's identity. */ + /** Launcher hash understood by older clients; split launchers embed the server digest. */ buildHash: string buildVersion: string nodeVersion: string @@ -83,7 +82,7 @@ export type OrcadHealth = { } /** - * Identity of the exact bytes running. + * Launcher identity shared with clients that predate split server bundles. * * Why hash the entry and not read a version string: `ORCA_VERSION` is whatever the deploy * exported, so two different builds can carry one version. A rollback that did not actually @@ -94,7 +93,7 @@ export function computeOrcadBuildHash(entryPath = process.argv[1]): string { return 'unknown' } try { - return createHash('sha256').update(readFileSync(entryPath)).digest('hex').slice(0, 16) + return hashOrcadLauncher(entryPath) } catch { return 'unknown' } diff --git a/src/main/orcad/orcad-launcher-isolation.integration.test.ts b/src/main/orcad/orcad-launcher-isolation.integration.test.ts new file mode 100644 index 000000000000..5a6a6e32cf9c --- /dev/null +++ b/src/main/orcad/orcad-launcher-isolation.integration.test.ts @@ -0,0 +1,159 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { hashOrcadLauncher } from '../../shared/orcad-build-identity' +import { + locatePinnedNodeForTests, + skipForMissingInputs, + writeNodeSlotFixture +} from './orcad-node-slot-fixture' + +const pinnedNode = locatePinnedNodeForTests() +const launcherNode = + process.env.ORCA_TEST_LAUNCHER_NODE_EXECUTABLE ?? + process.env.ORCA_TEST_NODE_EXECUTABLE ?? + process.execPath +let root = '' +let launcher = '' +let inputs: string[] = [] +const SERVER_FIXTURE = [ + 'using resource = { [Symbol.dispose]() {} };', + 'console.log(JSON.stringify({ node: process.versions.node, args: process.argv.slice(2) }));' +].join('\n') + +async function buildLauncher(entry: string): Promise { + const builder = pathToFileURL(join(process.cwd(), 'config/scripts/orcad-entry-build.mjs')).href + const result = await runProcess({ + program: process.execPath, + args: [ + '--input-type=module', + '-e', + `import {buildOrcadLauncher} from ${JSON.stringify(builder)};` + + `const result=await buildOrcadLauncher(${JSON.stringify(entry)});` + + 'console.log(JSON.stringify(Object.keys(result.metafile.inputs)))' + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout) +} + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-launcher-isolation-')) + launcher = join(root, ORCAD_LAUNCHER_FILENAME) + await writeFile(join(root, ORCAD_SERVER_ENTRY_FILENAME), SERVER_FIXTURE) + inputs = await buildLauncher(launcher) +}) + +it('changes the launcher identity when the server is rebuilt', async () => { + const directory = join(root, 'rebuilt') + await mkdir(directory) + const entry = join(directory, ORCAD_LAUNCHER_FILENAME) + const server = join(directory, ORCAD_SERVER_ENTRY_FILENAME) + await writeFile(server, SERVER_FIXTURE) + await buildLauncher(entry) + const first = hashOrcadLauncher(entry) + await writeFile(server, 'console.log("rebuilt server")') + await buildLauncher(entry) + expect(hashOrcadLauncher(entry)).not.toBe(first) +}) + +afterAll(async () => { + await rm(root, { recursive: true, force: true }) +}) + +it('keeps the server and profile-state graph outside the compatibility launcher', async () => { + expect(inputs.filter((path) => path.startsWith('src/main/'))).toEqual([ + 'src/main/orcad/orcad-app-paths.ts', + 'src/main/orcad/orcad-bundled-runtime.ts', + 'src/main/orcad/launcher.ts' + ]) + expect(inputs.some((path) => path.startsWith('node_modules/'))).toBe(false) + expect((await readFile(launcher)).length).toBeLessThan(32 * 1024) +}) + +it('runs an unpackaged server on the supported host runtime and preserves arguments', async () => { + const directory = join(root, 'unpackaged') + await mkdir(directory) + const entry = join(directory, ORCAD_LAUNCHER_FILENAME) + await writeFile( + join(directory, ORCAD_SERVER_ENTRY_FILENAME), + 'console.log(JSON.stringify({ node: process.versions.node, args: process.argv.slice(2) }))' + ) + await buildLauncher(entry) + const version = await runProcess({ program: launcherNode, args: ['-p', 'process.versions.node'] }) + const args = ['a path with spaces', '--port', '0'] + const result = await runProcess({ + program: launcherNode, + args: [entry, ...args], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ node: version.stdout.trim(), args }) +}) + +const skip = skipForMissingInputs('artifact', pinnedNode ? [] : ['the pinned Node runtime']) + +describe.skipIf(skip)('split launcher with the real bundled runtime', () => { + it('hands off before parsing Node 24 server syntax and preserves arguments', async () => { + if (!pinnedNode) { + throw new Error('Missing pinned runtime') + } + const { slotDir } = await writeNodeSlotFixture(join(root, 'handoff'), pinnedNode) + await writeFile(join(slotDir, ORCAD_LAUNCHER_FILENAME), await readFile(launcher)) + const server = join(slotDir, ORCAD_SERVER_ENTRY_FILENAME) + await writeFile(server, SERVER_FIXTURE) + const args = ['a path with spaces', 'line one\nline two', '--port', '0'] + const result = await runProcess({ + program: launcherNode, + args: [join(slotDir, ORCAD_LAUNCHER_FILENAME), ...args], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code, result.stderr.slice(0, 2000)).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ node: NODE_RUNTIME_PIN.version, args }) + }) + + it('refuses changed server bytes before loading them', async () => { + if (!pinnedNode) { + throw new Error('Missing pinned runtime') + } + const { slotDir, runtime } = await writeNodeSlotFixture(join(root, 'tampered'), pinnedNode) + await writeFile(join(slotDir, ORCAD_LAUNCHER_FILENAME), await readFile(launcher)) + await writeFile( + join(slotDir, ORCAD_SERVER_ENTRY_FILENAME), + 'throw new Error("server was loaded")' + ) + const result = await runProcess({ + program: runtime, + args: [join(slotDir, ORCAD_LAUNCHER_FILENAME)], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code).toBe(78) + expect(result.stderr).toContain('server does not match its launcher') + expect(result.stderr).not.toContain('server was loaded') + }) + + it('refuses an old host runtime without a bundle before loading the server', async ({ skip }) => { + const version = await runProcess({ + program: launcherNode, + args: ['-p', 'process.versions.node'] + }) + if (Number(version.stdout.trim().split('.')[0]) >= 18) { + skip() + } + await writeFile(join(root, ORCAD_SERVER_ENTRY_FILENAME), 'throw new Error("server was loaded")') + const result = await runProcess({ + program: launcherNode, + args: [launcher], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code).toBe(78) + expect(result.stderr).toContain('requires Node.js 18') + expect(result.stderr).not.toContain('server was loaded') + }) +}) diff --git a/src/main/orcad/orcad-local-serve-selection.test.ts b/src/main/orcad/orcad-local-serve-selection.test.ts index 708c74540500..9da11cfccf48 100644 --- a/src/main/orcad/orcad-local-serve-selection.test.ts +++ b/src/main/orcad/orcad-local-serve-selection.test.ts @@ -33,6 +33,7 @@ function slotFixture(): string { writeFileSync(join(slot, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${SHA}\n`) writeFileSync(join(slot, ORCAD_VERSION_FILENAME), '0.1.0+abc\n') writeFileSync(join(slot, 'orcad.js'), '') + writeFileSync(join(slot, 'orcad-server.js'), '') return slot } @@ -77,7 +78,7 @@ describe('orca serve runtime selection', () => { expect(selection).toEqual({ kind: 'orcad', runtime, - entry: join(slot, 'orcad.js'), + entry: join(slot, 'orcad-server.js'), version: '0.1.0+abc' }) expect(existsSync(runtime)).toBe(true) diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts index e9fa6230984f..2d8f51769fe6 100644 --- a/src/main/orcad/orcad-local-serve-selection.ts +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -13,7 +13,11 @@ import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync import { dirname, join } from 'node:path' import { runProcess } from '../../shared/child-process/run-process' import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' -import { ORCAD_VERSION_FILENAME, orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts' +import { + ORCAD_SERVER_ENTRY_FILENAME, + ORCAD_VERSION_FILENAME, + orcadNodeRuntimeRelativePath +} from '../../shared/orcad-artifacts' import { ORCAD_NATIVE_PREFLIGHT_FLAG, parseOrcadNativePreflightReport @@ -87,7 +91,7 @@ export async function selectServeRuntime( if (!runtime) { return electron('the orcad slot names no pinned runtime') } - const entry = join(slotDir, 'orcad.js') + const entry = join(slotDir, ORCAD_SERVER_ENTRY_FILENAME) const report = parseOrcadNativePreflightReport( await (input.nativePreflight ?? runNativePreflight)(runtime, entry).catch(() => '') ) diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts index 0683a3e48b7a..5334bec5f94e 100644 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ b/src/main/orcad/orcad-managed-stop.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { tmpdir } from 'node:os' import { basename, dirname, join, sep } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as WindowsAcl from '../../shared/secure-path-windows-acl' import { OrcadManagedStopCompletionSchema, type OrcadManagedStopRequest @@ -28,8 +29,38 @@ import { ORCAD_STOP_COMPLETION_POLL_MS } from './orcad-stop-deadlines' +const hardening = vi.hoisted(() => ({ pending: new Set>() })) + +vi.mock('../../shared/secure-path-windows-acl', async (importOriginal) => { + const acl = await importOriginal() + return { + ...acl, + bestEffortRestrictWindowsPath: ( + targetPath: string, + isDirectory: boolean, + onSettled?: (restricted: boolean) => void + ): void => { + let finish!: () => void + const pending = new Promise((resolve) => { + finish = resolve + }) + hardening.pending.add(pending) + acl.bestEffortRestrictWindowsPath(targetPath, isDirectory, (restricted) => { + try { + onSettled?.(restricted) + } finally { + hardening.pending.delete(pending) + finish() + } + }) + } + } +}) + const roots: string[] = [] -afterEach(() => { +afterEach(async () => { + // ACL updates must finish before their temporary directories disappear. + await Promise.all(hardening.pending) vi.restoreAllMocks() for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) diff --git a/src/main/orcad/orcad-profile-preflight.test.ts b/src/main/orcad/orcad-profile-preflight.test.ts index 809f7d839802..6d6d1187ed50 100644 --- a/src/main/orcad/orcad-profile-preflight.test.ts +++ b/src/main/orcad/orcad-profile-preflight.test.ts @@ -80,7 +80,7 @@ describe('bundled Orca startup readiness', () => { expect(fixture.run).toHaveBeenCalledOnce() expect(fixture.run).toHaveBeenCalledWith({ program: process.execPath, - args: [join('/slot', 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, expect.any(String)], + args: [join('/slot', 'orcad-server.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, expect.any(String)], env: expect.objectContaining({ ORCA_BACKGROUND_LAUNCH: '1' }), timeoutMs: 90_000, maxOutputBytes: 64 * 1024, diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts index c299e0ca0229..cd66fda5a00e 100644 --- a/src/main/orcad/orcad-profile-preflight.ts +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -11,7 +11,7 @@ import { type OrcadProfilePreflightResponse } from '../../shared/orcad-profile-preflight' import { readOrcadArtifactIdentity } from './orcad-artifact-identity' -import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' +import { ORCAD_SERVER_ENTRY_FILENAME, ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_NODE_RUNTIME_IDENTITY } from '../../shared/orcad-node-runtime-identity' import { runProcess } from '../../shared/child-process/run-process' import { preflightOrcadNativeRuntime } from './orcad-runtime-native-preflight' @@ -32,7 +32,7 @@ export async function preflightBundledOrcadStartup(): Promise { // Keep disposable SQLite ownership and native state out of the serving process. const result = await runProcess({ program: process.execPath, - args: [join(directory, 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce], + args: [join(directory, ORCAD_SERVER_ENTRY_FILENAME), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce], env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, maxOutputBytes: 64 * 1024, diff --git a/src/main/ssh/orcad-activation-gate.ts b/src/main/ssh/orcad-activation-gate.ts index d514d6858e48..3320dae54efe 100644 --- a/src/main/ssh/orcad-activation-gate.ts +++ b/src/main/ssh/orcad-activation-gate.ts @@ -39,7 +39,7 @@ export type OrcadActivationVerdict = | { decision: 'reject'; code: OrcadActivationRejectCode; reason: string } export type OrcadActivationExpectation = { - /** sha256(orcad.js).slice(0,16) computed from the bytes this client just uploaded. */ + /** Entry-file content hash computed from the bytes this client just uploaded. */ buildHash: string /** The full content-hashed version this deploy installed. */ fullVersion: string diff --git a/src/main/ssh/orcad-local-build-hash.ts b/src/main/ssh/orcad-local-build-hash.ts index 3740ea6d55c5..30f0bb74e3a1 100644 --- a/src/main/ssh/orcad-local-build-hash.ts +++ b/src/main/ssh/orcad-local-build-hash.ts @@ -1,22 +1,10 @@ -/** - * The build identity the deploy expects the host to answer with. - * - * It must be computed the same way `computeOrcadBuildHash` computes it on the host — - * sha256 of `orcad.js`, first 16 hex characters — or the activation gate would reject every - * healthy candidate. Keeping the two in one comment is deliberate: they are one contract - * split across a network, and the version string cannot stand in for it, because - * `ORCA_VERSION` is whatever the launch command exported and two builds can carry one value. - */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +/** Shares the host build identity so activation verifies the shipped entry bytes. */ import { join } from 'node:path' +import { hashOrcadLauncher } from '../../shared/orcad-build-identity' +import { ORCAD_LAUNCHER_FILENAME } from '../../shared/orcad-artifacts' -export const ORCAD_BUILD_HASH_LENGTH = 16 +export { ORCAD_BUILD_HASH_LENGTH } from '../../shared/orcad-build-identity' export function computeLocalOrcadBuildHash(localOrcadDir: string): string { - const entry = join(localOrcadDir, 'orcad.js') - return createHash('sha256') - .update(readFileSync(entry)) - .digest('hex') - .slice(0, ORCAD_BUILD_HASH_LENGTH) + return hashOrcadLauncher(join(localOrcadDir, ORCAD_LAUNCHER_FILENAME)) } diff --git a/src/main/ssh/orcad-remote-build-hash.integration.test.ts b/src/main/ssh/orcad-remote-build-hash.integration.test.ts new file mode 100644 index 000000000000..df09abefc17a --- /dev/null +++ b/src/main/ssh/orcad-remote-build-hash.integration.test.ts @@ -0,0 +1,42 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { remoteOrcadBuildHashCommand } from './orcad-remote-build-hash' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +let directory = '' +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "orcad-hash-'quoted ")) +}) +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +it.skipIf(process.platform === 'win32').each([false, true])( + 'reads the installed build identity for split entry %s through a real POSIX shell', + (splitEntry) => { + const launcher = join(directory, 'orcad.js') + writeFileSync(launcher, 'launcher\n') + if (splitEntry) { + writeFileSync(join(directory, 'orcad-server.js'), 'server\n') + } + const command = remoteOrcadBuildHashCommand(getRemoteHostPlatform('linux-x64'), directory) + const run = (prefix = ''): string => + execFileSync('/bin/sh', ['-c', prefix + command], { encoding: 'utf8' }).trim() + const legacyHash = createHash('sha256').update('launcher\n').digest('hex').slice(0, 16) + const expected = `__ORCAD_BUILD_HASH__ ${legacyHash}` + expect(run()).toBe(expected) + expect(run('command() { [ "$2" = shasum ]; }; ')).toBe(expected) + } +) + +it.skipIf(process.platform === 'win32')( + 'refuses an absent launcher without publishing an empty-file hash', + () => { + const command = remoteOrcadBuildHashCommand(getRemoteHostPlatform('linux-x64'), directory) + expect(() => execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' })).toThrow() + } +) diff --git a/src/main/ssh/orcad-remote-build-hash.ts b/src/main/ssh/orcad-remote-build-hash.ts index 1308c4868f04..203da86b593c 100644 --- a/src/main/ssh/orcad-remote-build-hash.ts +++ b/src/main/ssh/orcad-remote-build-hash.ts @@ -1,4 +1,5 @@ -/** The installed slot's `orcad.js` identity, the same 16-hex prefix orcad reports in its health. */ +/** The installed entry bytes, hashed as the server reports them for activation and rollback. */ +import { ORCAD_LAUNCHER_FILENAME } from '../../shared/orcad-artifacts' import { shellEscape } from './ssh-connection-utils' import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' import { orcadWindowsBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node' @@ -29,12 +30,13 @@ export function remoteOrcadBuildHashCommand( host, orcadWindowsBaseDir(host, remoteInstallDir), 'build-hash', - [joinRemotePath(host, remoteInstallDir, 'orcad.js')] + [joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)] ) } - const path = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js')) + const path = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) // Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. return [ + `[ -f ${path} ] && [ -r ${path} ] || exit 1;`, `orca_hash=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum ${path} | awk '{print $1}';`, `elif command -v shasum >/dev/null 2>&1; then shasum -a 256 ${path} | awk '{print $1}'; fi);`, `case "$orca_hash" in [0-9a-fA-F][0-9a-fA-F]*) printf '%s %.16s\\n' ${shellEscape(BUILD_HASH_MARKER)} "$orca_hash";; esac` diff --git a/src/main/ssh/orcad-remote-launch-windows.ts b/src/main/ssh/orcad-remote-launch-windows.ts index ba255e8e3217..b18387f59472 100644 --- a/src/main/ssh/orcad-remote-launch-windows.ts +++ b/src/main/ssh/orcad-remote-launch-windows.ts @@ -2,7 +2,7 @@ * Starting a candidate orcad on a Windows SSH host. * * Win32-OpenSSH kills the session's job when the session ends, so orcad must leave that job. - * `orcad.js --windows-breakaway-launch` does it the way the relay does: one CreateProcessW with + * The server entry's `--windows-breakaway-launch` mode does it the way the relay does: one CreateProcessW with * CREATE_BREAKAWAY_FROM_JOB through the slot's staged process-tree addon. Unlike the relay there * is no WMI fallback: Win32_Process.Create is EDR-scored remote execution and refused to a * standard user's network logon, so a host that cannot break away refuses the launch. @@ -28,7 +28,10 @@ import { orcadWindowsNodeCommandLine, readOrcadWindowsEncodedAnswer } from './orcad-remote-windows-node' -import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script' +import { + ORCAD_WINDOWS_ENTRY_MARKER, + ORCAD_WINDOWS_RUNTIME_MARKER +} from './orcad-windows-host-script' import { ORCAD_LOG_FILENAME, ORCAD_READINESS_FILENAME, @@ -67,14 +70,27 @@ export function readWindowsOrcadSlotRuntime(output: string): string { return runtime } +/** Older host-script answers omit the entry; those slots used the single-file launcher. */ +export function readWindowsOrcadSlotEntry( + output: string, + host: RemoteHostPlatform, + slotDir: string +): string { + return ( + readOrcadWindowsEncodedAnswer(output, ORCAD_WINDOWS_ENTRY_MARKER) ?? + joinRemotePath(host, slotDir, 'orcad.js') + ) +} + export function windowsOrcadLaunchCommand( host: RemoteHostPlatform, spec: OrcadLaunchSpec, - slotRuntime: string + slotRuntime: string, + slotEntry = joinRemotePath(host, spec.remoteInstallDir, 'orcad.js') ): string { const dir = spec.remoteInstallDir return orcadWindowsNodeCommandLine(slotRuntime, [ - joinRemotePath(host, dir, 'orcad.js'), + slotEntry, WINDOWS_BREAKAWAY_LAUNCH_FLAG, // The addon creates both with CREATE_ALWAYS, so a previous run's readiness line is gone // before the launcher returns. diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index c8468fffbb7d..b1b69f53d9f7 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -12,6 +12,7 @@ * the process's whole life; redirecting means the deploy can disconnect and the supervisor * still owns a running service. */ +import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' import { shellEscape } from './ssh-connection-utils' import { isWindowsRemoteHost, @@ -89,7 +90,7 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp ) const log = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME)) const pidFile = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME)) - const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, 'orcad.js')) + const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) const baseDir = remoteDirname(spec.remoteInstallDir.replace(/\/+$/u, ''), host) // Only `~/.orca-remote` itself; never tighten an unrelated parent a custom slot path names. const privateDirs = [ @@ -116,6 +117,7 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, ...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${shellEscape(value)}`), // Keep $! equal to the runtime PID rather than a waiting shell's PID. + // Older clients identify the live PID by orcad.js; the pinned launcher loads the body in-place. `exec nohup "$orcad_runtime" ${entry}`, `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &`, @@ -151,14 +153,15 @@ export function orcadLivenessProbeCommand( } const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - const entry = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js')) + const launcher = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) + const server = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_SERVER_ENTRY_FILENAME)) return [ posixProcessAliveShellFunction({ refuseUnverifiable: true }), // A PID is no identity once reused: a process whose command line does not run this slot's - // orcad.js is not it. No `ps` answer leaves the plain liveness check to decide. - `orcad_entry=${entry};`, + // entry is not it. No `ps` answer leaves the plain liveness check to decide. + `orcad_launcher=${launcher}; orcad_server=${server};`, 'orcad_reused() { args=$(ps -o args= -p "$1" 2>/dev/null) && [ -n "$args" ] && ' + - 'case "$args" in *"$orcad_entry"*) return 1;; *) return 0;; esac; };', + 'case "$args" in *"$orcad_launcher"* | *"$orcad_server"*) return 1;; *) return 0;; esac; };', `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in', `"" ) if [ -e ${pidFile} ] || [ -e ${readiness} ]; then echo UNKNOWN; else echo ${ORCAD_NEVER_LAUNCHED}; fi;;`, diff --git a/src/main/ssh/orcad-remote-preflight.ts b/src/main/ssh/orcad-remote-preflight.ts index 85e41339422e..31357c7b3eb4 100644 --- a/src/main/ssh/orcad-remote-preflight.ts +++ b/src/main/ssh/orcad-remote-preflight.ts @@ -13,8 +13,9 @@ import { orcadWindowsNodeCommandLine, readOrcadWindowsEncodedAnswer } from './orcad-remote-windows-node' +import { readWindowsOrcadSlotEntry } from './orcad-remote-launch-windows' import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script' -import { orcadNodeSlotRuntimeCommand } from './orcad-remote-runtime' +import { orcadNodeSlotRuntimeCommand, selectOrcadSlotEntryCommand } from './orcad-remote-runtime' import { execCommand } from './ssh-relay-deploy-helpers' import { shellEscape } from './ssh-connection-utils' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' @@ -31,11 +32,11 @@ export function orcadProfilePreflightCommand( const launch = [ 'ORCA_BACKGROUND_LAUNCH=1', '"$orcad_runtime"', - shellEscape(joinRemotePath(host, directory, 'orcad.js')), + '"$orcad_entry"', ORCAD_PROFILE_PREFLIGHT_FLAG, shellEscape(nonce) ].join(' ') - return `[ -e ${marker} ] || exit 78; ${orcadNodeSlotRuntimeCommand(host, directory)}${launch}` + return `[ -e ${marker} ] || exit 78; ${orcadNodeSlotRuntimeCommand(host, directory)}${selectOrcadSlotEntryCommand(host, directory)}; ${launch}` } /** Failure leaves the incumbent and its data untouched, including an unconfirmed SSH exit. */ @@ -59,7 +60,7 @@ export async function preflightInstalledOrcad(options: { } /** - * Windows: resolve the slot's node.exe, then run its `orcad.js` with plain argv. No + * Windows: resolve the slot's node.exe, then run its selected entry with plain argv. No * ORCA_BACKGROUND_LAUNCH here: orcad opens no window, and argv cannot set environment. */ async function windowsOrcadProfilePreflightCommand( @@ -72,21 +73,19 @@ async function windowsOrcadProfilePreflightCommand( nonce: string ): Promise { const { host, remoteInstallDir } = options - const runtime = readOrcadWindowsEncodedAnswer( - await execCommand( - options.conn, - orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'slot-runtime', [ - remoteInstallDir - ]), - { signal: options.signal, wrapCommand: false } - ), - ORCAD_WINDOWS_RUNTIME_MARKER + const slotAnswer = await execCommand( + options.conn, + orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'slot-runtime', [ + remoteInstallDir + ]), + { signal: options.signal, wrapCommand: false } ) + const runtime = readOrcadWindowsEncodedAnswer(slotAnswer, ORCAD_WINDOWS_RUNTIME_MARKER) if (!runtime) { throw new Error('The Windows host did not name the runtime this orcad slot needs.') } return orcadWindowsNodeCommandLine(runtime, [ - joinRemotePath(host, remoteInstallDir, 'orcad.js'), + readWindowsOrcadSlotEntry(slotAnswer, host, remoteInstallDir), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce ]) diff --git a/src/main/ssh/orcad-remote-runtime-control.ts b/src/main/ssh/orcad-remote-runtime-control.ts index 590834b487b8..9a43e20f4a3b 100644 --- a/src/main/ssh/orcad-remote-runtime-control.ts +++ b/src/main/ssh/orcad-remote-runtime-control.ts @@ -9,6 +9,7 @@ import { import { readWindowsOrcadLaunchReport, readWindowsOrcadSlotRuntime, + readWindowsOrcadSlotEntry, windowsOrcadLaunchCommand, windowsOrcadLaunchRuntimeCommand } from './orcad-remote-launch-windows' @@ -104,15 +105,18 @@ export async function launchOrcadAndAwaitReadiness( spec: OrcadLaunchSpec ): Promise { if (isWindowsRemoteHost(target.host)) { - const slotRuntime = readWindowsOrcadSlotRuntime( + const slotAnswer = await execOrcadRemote( + target, + windowsOrcadLaunchRuntimeCommand(target.host, spec.remoteInstallDir) + ) + const slotRuntime = readWindowsOrcadSlotRuntime(slotAnswer) + const slotEntry = readWindowsOrcadSlotEntry(slotAnswer, target.host, spec.remoteInstallDir) + readWindowsOrcadLaunchReport( await execOrcadRemote( target, - windowsOrcadLaunchRuntimeCommand(target.host, spec.remoteInstallDir) + windowsOrcadLaunchCommand(target.host, spec, slotRuntime, slotEntry) ) ) - readWindowsOrcadLaunchReport( - await execOrcadRemote(target, windowsOrcadLaunchCommand(target.host, spec, slotRuntime)) - ) } else { await execOrcadRemote(target, orcadLaunchCommand(target.host, spec)) } diff --git a/src/main/ssh/orcad-remote-runtime.ts b/src/main/ssh/orcad-remote-runtime.ts index df63b5a9c185..672c8df6282f 100644 --- a/src/main/ssh/orcad-remote-runtime.ts +++ b/src/main/ssh/orcad-remote-runtime.ts @@ -1,5 +1,7 @@ import { ORCAD_BUILD_TARGET_FILENAME, + ORCAD_LAUNCHER_FILENAME, + ORCAD_SERVER_ENTRY_FILENAME, ORCAD_NODE_RUNTIME_DIR_PREFIX, ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE, @@ -10,6 +12,14 @@ import { assertPosixOrcadHost } from './orcad-remote-host-support' import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, remoteDirname, type RemoteHostPlatform } from './ssh-remote-platform' +/** New slots start the server directly; rollback still accepts older single-entry slots. */ +export function selectOrcadSlotEntryCommand(host: RemoteHostPlatform, directory: string): string { + assertPosixOrcadHost(host) + const server = shellEscape(joinRemotePath(host, directory, ORCAD_SERVER_ENTRY_FILENAME)) + const launcher = shellEscape(joinRemotePath(host, directory, ORCAD_LAUNCHER_FILENAME)) + return `if [ -f ${server} ]; then orcad_entry=${server}; else orcad_entry=${launcher}; fi` +} + /** * Sets `$orcad_runtime` to the pinned Node a slot's `.runtime-node` names, or exits 78. * Assumes the marker exists; callers decide what its absence means. diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index 9227ccd9a00b..fffede27a02a 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -97,7 +97,8 @@ afterEach(() => { async function launchTestRuntime( legacyWrapper = false, - stopRequests = false + stopRequests = false, + splitEntry = false ): Promise<{ runtimePid: number recordedPid: number @@ -106,7 +107,7 @@ async function launchTestRuntime( const terminatedFile = join(versionDir, 'terminated') const requestFile = join(versionDir, ORCAD_STOP_REQUEST_FILENAME) writeFileSync( - join(versionDir, 'orcad.js'), + join(versionDir, splitEntry ? 'orcad-server.js' : 'orcad.js'), [ `const fs = require('node:fs');`, `process.on('SIGTERM', () => {`, @@ -131,6 +132,9 @@ async function launchTestRuntime( `setTimeout(() => process.exit(1), 10_000);` ].join('\n') ) + if (splitEntry) { + writeFileSync(join(versionDir, 'orcad.js'), 'require("./orcad-server.js")') + } let command = orcadLaunchCommand(host, { remoteInstallDir: versionDir, nodePath: process.execPath, @@ -372,16 +376,30 @@ describe('state snapshot commands, run for real', () => { }) describe('liveness and stop commands, run for real', () => { - it('records the runtime PID and waits for that runtime to exit when stopped', async () => { - const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() - expect(recordedPid).toBe(runtimePid) - expect(stopTestRuntime()).toBe('stopped') - expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') - // An unreaped zombie has exited even though kill -0 still succeeds. - expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) - expect(existsSync(join(versionDir, ORCAD_PID_FILENAME))).toBe(true) - expect(stopTestRuntime()).toBe('already-exited') - }) + it.each([false, true])( + 'records and stops the runtime PID with split entry %s', + async (splitEntry) => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime( + false, + false, + splitEntry + ) + expect(recordedPid).toBe(runtimePid) + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') + // The prior client recognizes only orcad.js when deciding whether this PID was reused. + const legacyIdentity = sh( + `args=$(ps -o args= -p ${recordedPid}); ` + + `case "$args" in *${shellEscape(join(versionDir, 'orcad.js'))}*) echo LIVE;; *) echo DEAD;; esac` + ).trim() + expect(legacyIdentity).toBe('LIVE') + expect(stopTestRuntime()).toBe('stopped') + expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') + // An unreaped zombie has exited even though kill -0 still succeeds. + expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) + expect(existsSync(join(versionDir, ORCAD_PID_FILENAME))).toBe(true) + expect(stopTestRuntime()).toBe('already-exited') + } + ) it('stops a build that consumes stop requests by request file, not by signal', async () => { const { terminatedFile } = await launchTestRuntime(false, true) @@ -513,31 +531,34 @@ describe('liveness and stop commands, run for real', () => { } }) - it('reports LIVE for a running process and stops it with SIGTERM', async () => { - // Stands in for orcad: its command line runs this slot's orcad.js. - const child = spawn( - process.execPath, - ['-e', 'setTimeout(() => {}, 30000)', join(versionDir, 'orcad.js')], - { stdio: 'ignore' } - ) - try { - writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(child.pid)) - expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') - - const exited = new Promise((resolve) => - child.once('exit', (_code, signal) => resolve(signal)) + it.each(['orcad.js', 'orcad-server.js'])( + 'reports LIVE and stops a running %s even without entry files', + async (entry) => { + // Stands in for orcad: its command line runs this slot's orcad.js. + const child = spawn( + process.execPath, + ['-e', 'setTimeout(() => {}, 30000)', join(versionDir, entry)], + { stdio: 'ignore' } ) - expect( - parseOrcadStopOutcome( - sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) + try { + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(child.pid)) + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') + + const exited = new Promise((resolve) => + child.once('exit', (_code, signal) => resolve(signal)) ) - ).toBe('stopped') - expect(await exited).toBe('SIGTERM') - expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') - } finally { - child.kill('SIGKILL') + expect( + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) + ) + ).toBe('stopped') + expect(await exited).toBe('SIGTERM') + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + } finally { + child.kill('SIGKILL') + } } - }) + ) // `kill -0` succeeds on a zombie, so a probe built on it alone calls an exited process // live: the stop loop would time out on a process that is already gone, and GC would keep diff --git a/src/main/ssh/orcad-remote-windows-commands.test.ts b/src/main/ssh/orcad-remote-windows-commands.test.ts index 013000dc9553..ede08c7d2844 100644 --- a/src/main/ssh/orcad-remote-windows-commands.test.ts +++ b/src/main/ssh/orcad-remote-windows-commands.test.ts @@ -14,6 +14,7 @@ import { orcadLivenessProbeCommand, type OrcadLaunchSpec } from './orcad-remote- import { OrcadWindowsLaunchRefusedError, readWindowsOrcadLaunchReport, + readWindowsOrcadSlotEntry, windowsOrcadLaunchCommand, windowsOrcadLaunchRuntimeCommand } from './orcad-remote-launch-windows' @@ -86,6 +87,18 @@ describe('Windows orcad commands run node.exe directly', () => { ['build hash', remoteOrcadBuildHashCommand(host, slot)] ] + it('selects the split server entry and preserves older host-script answers', () => { + const server = `${slot}/orcad-server.js` + const selected = readWindowsOrcadSlotEntry(encoded('__ORCAD_ENTRY__', server), host, slot) + expect(selected).toBe(server) + expect(windowsOrcadLaunchCommand(host, spec, SLOT_NODE, selected)).toContain( + `${server} --windows-breakaway-launch` + ) + expect(readWindowsOrcadSlotEntry(encoded('__ORCAD_RUNTIME__', SLOT_NODE), host, slot)).toBe( + `${slot}/orcad.js` + ) + }) + it.each(commands())('%s: no PowerShell hop, no encoding, no WMI or signal', (_name, command) => { expect(command).toMatch(/^C:\\Users\\u\\\.orca-remote\\runtimes\\node-[0-9a-f]+\\node\.exe /u) expect(command).not.toMatch( @@ -126,7 +139,10 @@ describe('Windows orcad commands run node.exe directly', () => { it('never polls across SSH: runtime, launch, then one host-side wait', async () => { mockExec - .mockResolvedValueOnce(encoded('__ORCAD_RUNTIME__', SLOT_NODE)) + .mockResolvedValueOnce( + encoded('__ORCAD_RUNTIME__', SLOT_NODE) + + encoded('__ORCAD_ENTRY__', `${slot}/orcad-server.js`) + ) .mockResolvedValueOnce( 'ORCA_ORCAD_LAUNCH {"method":"breakaway","pid":4242,"inJob":false}\r\n' ) @@ -144,6 +160,9 @@ describe('Windows orcad commands run node.exe directly', () => { expect(result).toMatchObject({ state: 'ready', readiness: { runtimeId: 'r1' } }) expect(mockExec).toHaveBeenCalledTimes(3) expect(String(mockExec.mock.calls[1]?.[1]).startsWith(`${SLOT_NODE} `)).toBe(true) + expect(String(mockExec.mock.calls[1]?.[1])).toContain( + `${slot}/orcad-server.js --windows-breakaway-launch` + ) expect(sleep).not.toHaveBeenCalled() }) }) diff --git a/src/main/ssh/orcad-remote-windows-scripts.test.ts b/src/main/ssh/orcad-remote-windows-scripts.test.ts index 69d3dfc77b09..d1699ac18d31 100644 --- a/src/main/ssh/orcad-remote-windows-scripts.test.ts +++ b/src/main/ssh/orcad-remote-windows-scripts.test.ts @@ -5,6 +5,7 @@ * every platform. The process-tree addon is faked by a preload that loads `*.node` as a table of * creation times, so the identity rules run for real without Windows. */ +import { createHash } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -22,6 +23,7 @@ import { ORCAD_WINDOWS_HOST_SCRIPT, ORCAD_WINDOWS_READINESS_MARKER, ORCAD_WINDOWS_RUNTIME_MARKER, + ORCAD_WINDOWS_ENTRY_MARKER, type OrcadWindowsHostOp } from './orcad-windows-host-script' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -317,6 +319,14 @@ describe('Windows slot runtime and file removal', () => { expect(readOrcadWindowsEncodedAnswer(plain.stdout, ORCAD_WINDOWS_RUNTIME_MARKER)).toBe( join(dir, 'runtimes', `node-${sha}`, 'node.exe') ) + expect(readOrcadWindowsEncodedAnswer(plain.stdout, ORCAD_WINDOWS_ENTRY_MARKER)).toBe( + join(slot(), 'orcad.js') + ) + writeFileSync(join(slot(), 'orcad-server.js'), '') + const split = await runOp('slot-runtime', [slot()], false) + expect(readOrcadWindowsEncodedAnswer(split.stdout, ORCAD_WINDOWS_ENTRY_MARKER)).toBe( + join(slot(), 'orcad-server.js') + ) expect(existsSync(join(slot(), ORCAD_STOP_REQUEST_FILENAME))).toBe(true) await runOp('slot-runtime', [slot(), 'clear-stop-request'], false) expect(existsSync(join(slot(), ORCAD_STOP_REQUEST_FILENAME))).toBe(false) @@ -345,3 +355,17 @@ describe('Windows slot runtime and file removal', () => { it('decodes nothing from output that lacks the marker', () => { expect(readOrcadWindowsEncodedAnswer('noise\r\n', ORCAD_WINDOWS_READINESS_MARKER)).toBeNull() }) + +describe('Windows installed build identity', () => { + it.each([false, true])('matches the server hash for split entry %s', async (splitEntry) => { + const launcher = join(dir, 'orcad.js') + writeFileSync(launcher, 'launcher\n') + if (splitEntry) { + writeFileSync(join(dir, 'orcad-server.js'), 'server\n') + } + const result = await runOp('build-hash', [launcher], false) + expect(result.code).toBe(0) + const legacyHash = createHash('sha256').update('launcher\n').digest('hex').slice(0, 16) + expect(result.stdout.trim()).toBe(`__ORCAD_BUILD_HASH__ ${legacyHash}`) + }) +}) diff --git a/src/main/ssh/orcad-windows-host-script.ts b/src/main/ssh/orcad-windows-host-script.ts index 9b6957c3d840..c731244c6ffe 100644 --- a/src/main/ssh/orcad-windows-host-script.ts +++ b/src/main/ssh/orcad-windows-host-script.ts @@ -13,6 +13,8 @@ */ import { createHash } from 'node:crypto' import { + ORCAD_LAUNCHER_FILENAME, + ORCAD_SERVER_ENTRY_FILENAME, ORCAD_NODE_RUNTIME_DIR_PREFIX, ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE, @@ -43,6 +45,7 @@ export const ORCAD_RECORD_PRESENT_MARKER = '__ORCAD_RECORD_PRESENT__' export const ORCAD_BUILD_HASH_MARKER = '__ORCAD_BUILD_HASH__' export const ORCAD_WINDOWS_READINESS_MARKER = '__ORCAD_READINESS__' export const ORCAD_WINDOWS_RUNTIME_MARKER = '__ORCAD_RUNTIME__' +export const ORCAD_WINDOWS_ENTRY_MARKER = '__ORCAD_ENTRY__' export const ORCAD_WINDOWS_LIVENESS_MANY_MARKER = '__ORCAD_LIVENESS__' export const ORCAD_WINDOWS_LOG_TAIL_MARKER = '__ORCAD_LOG_TAIL__' /** A slot whose marker names no usable runtime; the POSIX selector exits the same way. */ @@ -271,7 +274,13 @@ const ops = { if (mode === 'clear-stop-request') { try { fs.unlinkSync(path.join(slotDir, ${text(ORCAD_STOP_REQUEST_FILENAME)})) } catch (error) { if (error.code !== 'ENOENT') process.exit(1) } } - encoded(${text(ORCAD_WINDOWS_RUNTIME_MARKER)}, Buffer.from(runtime, 'utf8')) + const server = path.join(slotDir, ${text(ORCAD_SERVER_ENTRY_FILENAME)}) + const entry = fs.existsSync(server) ? server : path.join(slotDir, ${text(ORCAD_LAUNCHER_FILENAME)}) + answer([ + [${text(ORCAD_WINDOWS_RUNTIME_MARKER)}, Buffer.from(runtime, 'utf8').toString('base64')].join(' '), + [${text(ORCAD_WINDOWS_ENTRY_MARKER)}, Buffer.from(entry, 'utf8').toString('base64')].join(' '), + '' + ].join('\\n')) }, // Never exits on its own: it lives as long as the client's exec channel or orcad's socket. diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 7b3678814187..33b3e963c913 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -100,6 +100,8 @@ export const ORCAD_EMOJI_SHORTCODE_DATASET = 'node_modules/emojibase-data/en/shortcodes/emojibase.json' export const ORCAD_VERSION = '0.1.0' +export const ORCAD_LAUNCHER_FILENAME = 'orcad.js' +export const ORCAD_SERVER_ENTRY_FILENAME = 'orcad-server.js' // Equals FOREIGN_SQLITE_READER_ENTRY_FILENAME; that module is not loadable under type stripping. export const ORCAD_FOREIGN_SQLITE_READER_ENTRY = 'foreign-sqlite-reader-entry.js' @@ -157,7 +159,8 @@ export type OrcadArtifact = { } export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ - { filename: 'orcad.js' }, + { filename: ORCAD_LAUNCHER_FILENAME }, + { filename: ORCAD_SERVER_ENTRY_FILENAME }, // Forked so a native @parcel/watcher fault kills the child, not the server. { filename: 'parcel-watcher-process-entry.js' }, // Forked so PTYs outlive the runtime process; its absence makes every restart destructive. diff --git a/src/shared/orcad-build-identity.test.ts b/src/shared/orcad-build-identity.test.ts new file mode 100644 index 000000000000..88cb6ed1bed4 --- /dev/null +++ b/src/shared/orcad-build-identity.test.ts @@ -0,0 +1,34 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { hashOrcadLauncher } from './orcad-build-identity' + +let directory = '' +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-entry-identity-')) +}) +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +it('preserves an older single-entry build identity for rollback', () => { + const entry = join(directory, 'orcad.js') + writeFileSync(entry, 'old-server') + expect(hashOrcadLauncher(entry)).toBe( + createHash('sha256').update('old-server').digest('hex').slice(0, 16) + ) +}) + +it('keeps the launcher hash older clients expect for both split entry paths', () => { + const launcher = join(directory, 'orcad.js') + const server = join(directory, 'orcad-server.js') + writeFileSync(launcher, 'launcher-a') + writeFileSync(server, 'server-a') + const first = hashOrcadLauncher(launcher) + expect(hashOrcadLauncher(server)).toBe(first) + expect(first).toBe(createHash('sha256').update('launcher-a').digest('hex').slice(0, 16)) + writeFileSync(launcher, 'launcher-b') + expect(hashOrcadLauncher(server)).not.toBe(first) +}) diff --git a/src/shared/orcad-build-identity.ts b/src/shared/orcad-build-identity.ts new file mode 100644 index 000000000000..e449408b3116 --- /dev/null +++ b/src/shared/orcad-build-identity.ts @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto' +import { readFileSync, realpathSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from './orcad-artifacts' + +export const ORCAD_BUILD_HASH_LENGTH = 16 + +/** The launcher embeds its server digest, keeping the hash older clients calculate. */ +export function hashOrcadLauncher(entryPath: string): string { + const entry = realpathSync(entryPath) + const launcher = + basename(entry) === ORCAD_SERVER_ENTRY_FILENAME + ? join(dirname(entry), ORCAD_LAUNCHER_FILENAME) + : entry + return createHash('sha256') + .update(readFileSync(launcher)) + .digest('hex') + .slice(0, ORCAD_BUILD_HASH_LENGTH) +} From 245a6c71162ab9f6a6983d269e396ea68708cf6f Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 18:52:32 -0700 Subject: [PATCH 2/2] fix(runtime): preserve server crash exit semantics --- src/main/orcad/launcher.ts | 6 +++++- ...cad-launcher-isolation.integration.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/orcad/launcher.ts b/src/main/orcad/launcher.ts index 05003eba857b..4e43729084a6 100644 --- a/src/main/orcad/launcher.ts +++ b/src/main/orcad/launcher.ts @@ -7,6 +7,8 @@ import { assertOrcadServerRuntime, handoffToBundledOrcad } from './orcad-bundled declare const ORCAD_SERVER_SHA256: string +let loadServer: (() => void) | undefined + try { if (!handoffToBundledOrcad()) { assertOrcadServerRuntime() @@ -15,9 +17,11 @@ try { if (createHash('sha256').update(readFileSync(server)).digest('hex') !== ORCAD_SERVER_SHA256) { throw new Error('The Orca server does not match its launcher') } - createRequire(entry)(server) + loadServer = () => createRequire(entry)(server) } } catch (error) { console.error('orcad: failed to launch:', error) process.exit(78) } + +loadServer?.() diff --git a/src/main/orcad/orcad-launcher-isolation.integration.test.ts b/src/main/orcad/orcad-launcher-isolation.integration.test.ts index 5a6a6e32cf9c..f52fe00b36a8 100644 --- a/src/main/orcad/orcad-launcher-isolation.integration.test.ts +++ b/src/main/orcad/orcad-launcher-isolation.integration.test.ts @@ -97,6 +97,25 @@ it('runs an unpackaged server on the supported host runtime and preserves argume expect(JSON.parse(result.stdout)).toEqual({ node: version.stdout.trim(), args }) }) +it('preserves the crash exit code when the server fails while loading', async () => { + const directory = join(root, 'server-crash') + await mkdir(directory) + const entry = join(directory, ORCAD_LAUNCHER_FILENAME) + await writeFile( + join(directory, ORCAD_SERVER_ENTRY_FILENAME), + 'throw new Error("server evaluation failed")' + ) + await buildLauncher(entry) + const result = await runProcess({ + program: process.execPath, + args: [entry], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code).toBe(1) + expect(result.stderr).toContain('server evaluation failed') + expect(result.stderr).not.toContain('orcad: failed to launch:') +}) + const skip = skipForMissingInputs('artifact', pinnedNode ? [] : ['the pinned Node runtime']) describe.skipIf(skip)('split launcher with the real bundled runtime', () => {