From d2dfc79764a2b1f6f954733aebbffdffb86eb82f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 1 Oct 2026 00:10:57 -0700 Subject: [PATCH] ci(daemon): runtime-launcher protocol ratchet and Node slot marker (#24108) * ci(daemon): gate PRs on daemon protocol crossing from the newest release Lands daemon-protocol-facts.mjs from the Windows update diagnostic branch with a stricter parser, and adds check-daemon-protocol-crossing.mjs (rule R1): the working tree must attach the newest release tag's daemon. Rollback crossing is reported only. Runs in the cross-version-wire job, which already has full tags; tag selection moves to config/scripts/stable-release-tags.mjs so both use one rule. * feat(persistence): run profile backups in the worker whenever its entry is bundled * refactor(orcad): make profile and native preflight runtime-neutral The profile preflight parser now takes the expected runtime identity from the caller (shipped callers pass the pinned Bun identity), and the native preflight is renamed to orcad-runtime-native-preflight with neutral wording. * feat(runtime): pin the Node 24.21.0 server runtime with an offline CI check Add src/shared/node-runtime-pin.ts (NODE_RUNTIME_PIN, SERVER_TARGETS, NODE_RUNTIME_ASSETS for all 8 server targets plus the headers tarball), generated by config/scripts/update-node-runtime-pin.mjs from the nodejs.org and unofficial-builds SHASUMS. check-node-runtime-pin.mjs verifies, with no network, that the pin tracks the locked Electron, matches engines.node's major, and covers exactly SERVER_TARGETS; it runs in the static analysis job. ORCAD_BUN_TARGETS consumers now read SERVER_TARGETS so there is one target list; orcad's Bun runtime and build output are unchanged. * test(persistence): skip plain-Node backup selection tests in the Bun profile suite * fix(runtime): reject a pinned archive that belongs to another target * ci(daemon): fail PRs that swap a runtime launcher and bump the daemon protocol D7.1 R3: hosting orcad or the daemon on another runtime is not a protocol change, so one PR must not do both. The launcher file list lives in the check script; the allow-runtime-launcher-protocol-bump label overrides it. * feat(orcad): select pinned-Node slots by a .runtime-node marker D7.1 R5: a Node slot names its shared runtimes/node-/node through .runtime-node instead of .build-target, so Bun-era clients read it as a legacy slot rather than exiting 78 on a missing bun-runtime. Nothing builds the marker yet. * fix(runtime): load the Node pin without the typeless-module warning check-node-runtime-pin.mjs now requires the pin and takes nodeDistArchiveName from its own module, so it no longer loads the update script's build graph. * fix(orcad): resolve Node slots to the design's runtimes/node-/bin/node layout --------- Co-authored-by: m4air --- .github/workflows/pr.yml | 10 + config/scripts/check-node-runtime-pin.mjs | 13 +- ...heck-runtime-launcher-protocol-ratchet.mjs | 128 +++++++++++++ ...runtime-launcher-protocol-ratchet.test.mjs | 181 ++++++++++++++++++ config/scripts/node-dist-archive-name.mjs | 19 ++ config/scripts/pr-code-change-scope.mjs | 2 + config/scripts/update-node-runtime-pin.mjs | 19 +- .../scripts/update-node-runtime-pin.test.mjs | 2 +- package.json | 1 + src/main/ssh/orcad-remote-runtime.test.ts | 104 +++++++++- src/main/ssh/orcad-remote-runtime.ts | 38 +++- src/shared/orcad-artifacts.ts | 11 ++ 12 files changed, 492 insertions(+), 36 deletions(-) create mode 100644 config/scripts/check-runtime-launcher-protocol-ratchet.mjs create mode 100644 config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs create mode 100644 config/scripts/node-dist-archive-name.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0f39f530fa8..eba17d60116 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -856,6 +856,16 @@ jobs: - name: Daemon protocol crossing against newest release run: pnpm run check:daemon-protocol-crossing + # R3: a runtime launcher swap must not also bump the daemon protocol (D7.1). + # The label is read from the event payload, so push again after applying it. + - name: Runtime launcher change does not bump daemon protocol + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + ORCA_ALLOW_RUNTIME_LAUNCHER_PROTOCOL_BUMP: ${{ contains(github.event.pull_request.labels.*.name, 'allow-runtime-launcher-protocol-bump') }} + run: | + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + pnpm run check:runtime-launcher-protocol-ratchet -- --base "$DIFF_BASE" + # A path filter that matches nothing exits 1 ("No test files found"), so this # lane cannot report success while running zero tests. - name: Old/new client and server compatibility journeys diff --git a/config/scripts/check-node-runtime-pin.mjs b/config/scripts/check-node-runtime-pin.mjs index 1e1b7d6a8ee..c9820a18aa6 100644 --- a/config/scripts/check-node-runtime-pin.mjs +++ b/config/scripts/check-node-runtime-pin.mjs @@ -2,15 +2,16 @@ // Static, offline consistency gate for src/shared/node-runtime-pin.ts; update-node-runtime-pin.mjs owns the network. import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { parseAllDocuments } from 'yaml' -import { - NODE_RUNTIME_ASSETS, - NODE_RUNTIME_PIN, - SERVER_TARGETS -} from '../../src/shared/node-runtime-pin.ts' -import { nodeDistArchiveName } from './update-node-runtime-pin.mjs' +import { nodeDistArchiveName } from './node-dist-archive-name.mjs' + +// Why require: an ESM import of a .ts file under a typeless package.json prints MODULE_TYPELESS_PACKAGE_JSON. +const { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN, SERVER_TARGETS } = createRequire(import.meta.url)( + '../../src/shared/node-runtime-pin.ts' +) const SHA256 = /^[0-9a-f]{64}$/ const ASSET_SOURCES = new Set(['official', 'unofficial']) diff --git a/config/scripts/check-runtime-launcher-protocol-ratchet.mjs b/config/scripts/check-runtime-launcher-protocol-ratchet.mjs new file mode 100644 index 00000000000..9acf1e23e80 --- /dev/null +++ b/config/scripts/check-runtime-launcher-protocol-ratchet.mjs @@ -0,0 +1,128 @@ +// R3 gate (docs/reference/node-runtime-design.html, D7.1): swapping the process that hosts orcad +// or the daemon is not a daemon protocol change, so one PR must not do both. A bump riding along +// with a runtime swap would strand every live terminal on upgrade and rollback at once. +// Usage: node check-runtime-launcher-protocol-ratchet.mjs --base +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { DAEMON_PROTOCOL_SOURCE_PATH, parseDaemonProtocolFacts } from './daemon-protocol-facts.mjs' + +/** Files that decide which runtime executable launches orcad or the terminal 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/shared/orcad-bun-runtime.ts', + 'src/shared/node-runtime-pin.ts', + 'src/main/ssh/orcad-bun-runtime-materializer.ts', + // orcad slot layout: which runtime file a packaged slot carries. + 'src/shared/orcad-artifacts.ts', + 'config/scripts/build-orcad.mjs', + 'config/scripts/build-orcad-bun.mjs', + 'config/scripts/build-orcad-template.mjs', + // Remote slot runtime selection. + 'src/main/ssh/orcad-remote-runtime.ts', + // Terminal daemon host launch: which executable the daemon child is forked from. + 'src/main/daemon/daemon-launched-child.ts', + 'src/main/daemon/daemon-launched-child-spawn.ts', + 'src/main/daemon/daemon-out-of-process-launcher.ts', + 'src/main/daemon/daemon-host-relocation.ts', + 'src/main/daemon/daemon-host-manifest.ts' +] + +export const RUNTIME_PROTOCOL_OVERRIDE_ENV = 'ORCA_ALLOW_RUNTIME_LAUNCHER_PROTOCOL_BUMP' +export const RUNTIME_PROTOCOL_OVERRIDE_LABEL = 'allow-runtime-launcher-protocol-bump' + +function git(repoRoot, args) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 16 * 1024 * 1024 + }) +} + +/** + * @param {{ changedFiles: string[], baseProtocolVersion: number, candidateProtocolVersion: number, overridden: boolean }} input + */ +export function assessRuntimeLauncherProtocolRatchet({ + changedFiles, + baseProtocolVersion, + candidateProtocolVersion, + overridden +}) { + const launcherChanges = RUNTIME_LAUNCHER_PATHS.filter((path) => changedFiles.includes(path)) + const protocolChanged = baseProtocolVersion !== candidateProtocolVersion + const violated = protocolChanged && launcherChanges.length > 0 + const lines = [ + `daemon PROTOCOL_VERSION: base ${baseProtocolVersion}, candidate ${candidateProtocolVersion}`, + launcherChanges.length > 0 + ? `runtime launcher files changed: ${launcherChanges.join(', ')}` + : 'runtime launcher files changed: none' + ] + if (!violated) { + lines.push('OK: this change does not both swap a runtime launcher and bump the daemon protocol') + } else if (overridden) { + lines.push( + `OVERRIDDEN: launcher change and protocol bump together, allowed by ${RUNTIME_PROTOCOL_OVERRIDE_ENV}` + ) + } else { + lines.push( + 'FAIL: split the protocol bump and the runtime launcher change into separate PRs (D7.1 R3), ' + + `or apply the "${RUNTIME_PROTOCOL_OVERRIDE_LABEL}" label and re-run the workflow with a new push.` + ) + } + return { ok: !violated || overridden, violated, lines } +} + +export function isOverrideEnabled(env = process.env) { + return ['1', 'true'].includes(env[RUNTIME_PROTOCOL_OVERRIDE_ENV]?.trim().toLowerCase() ?? '') +} + +export function checkRuntimeLauncherProtocolRatchet({ repoRoot, base, env = process.env }) { + // Why diff against the working tree: CI's tree is HEAD, and a local run also sees unstaged edits. + const changedFiles = git(repoRoot, ['diff', '--name-only', '--no-renames', base, '--']) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const baseFacts = parseDaemonProtocolFacts( + git(repoRoot, ['show', `${base}:${DAEMON_PROTOCOL_SOURCE_PATH}`]), + `${base}:${DAEMON_PROTOCOL_SOURCE_PATH}` + ) + const candidateFacts = parseDaemonProtocolFacts( + readFileSync(join(repoRoot, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8'), + DAEMON_PROTOCOL_SOURCE_PATH + ) + return assessRuntimeLauncherProtocolRatchet({ + changedFiles, + baseProtocolVersion: baseFacts.protocolVersion, + candidateProtocolVersion: candidateFacts.protocolVersion, + overridden: isOverrideEnabled(env) + }) +} + +function parseArgs(argv) { + const index = argv.indexOf('--base') + const value = index === -1 ? undefined : argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('--base is required (the pull request diff base)') + } + return { base: value } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const repoRoot = resolve(import.meta.dirname, '..', '..') + try { + const { base } = parseArgs(process.argv.slice(2)) + const result = checkRuntimeLauncherProtocolRatchet({ repoRoot, base }) + for (const line of result.lines) { + console.log(line) + } + if (!result.ok) { + process.exitCode = 1 + } + } catch (error) { + console.error(`runtime launcher protocol ratchet failed: ${error.message}`) + process.exitCode = 1 + } +} diff --git a/config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs b/config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs new file mode 100644 index 00000000000..bf7cdb99638 --- /dev/null +++ b/config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs @@ -0,0 +1,181 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + RUNTIME_LAUNCHER_PATHS, + RUNTIME_PROTOCOL_OVERRIDE_ENV, + assessRuntimeLauncherProtocolRatchet, + checkRuntimeLauncherProtocolRatchet, + isOverrideEnabled +} from './check-runtime-launcher-protocol-ratchet.mjs' +import { DAEMON_PROTOCOL_SOURCE_PATH } from './daemon-protocol-facts.mjs' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +const repoRoot = resolve(import.meta.dirname, '..', '..') +const repos = [] +const LAUNCHER = 'src/main/ssh/orcad-remote-runtime.ts' + +function git(repo, args) { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) +} + +function write(repo, path, contents) { + const file = join(repo, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, contents) +} + +function writeProtocol(repo, current) { + const previous = Array.from({ length: current - 1 }, (_, index) => index + 1) + write( + repo, + DAEMON_PROTOCOL_SOURCE_PATH, + `export const PROTOCOL_VERSION = ${current}\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous.join(', ')}] as const\n` + ) +} + +function commit(repo, message) { + git(repo, ['add', '.']) + git(repo, [ + '-c', + 'user.name=test', + '-c', + 'user.email=test@example.com', + 'commit', + '-q', + '--no-gpg-sign', + '-m', + message + ]) + return git(repo, ['rev-parse', 'HEAD']).trim() +} + +/** A base commit at protocol 3, then a candidate commit shaped by `change`. */ +function repoWithCandidate(change) { + const repo = mkdtempSync(join(tmpdir(), 'runtime-launcher-ratchet-')) + repos.push(repo) + git(repo, ['init', '-q']) + writeProtocol(repo, 3) + write(repo, LAUNCHER, 'export const launcher = 1\n') + write(repo, 'src/unrelated.ts', 'export const unrelated = 1\n') + const base = commit(repo, 'base') + change(repo) + commit(repo, 'candidate') + return { repo, base } +} + +afterEach(() => { + for (const repo of repos.splice(0)) { + rmSync(repo, { recursive: true, force: true }) + } +}) + +describe('RUNTIME_LAUNCHER_PATHS', () => { + it('names only files that exist, so a rename cannot silently drop one from the gate', () => { + expect(RUNTIME_LAUNCHER_PATHS.filter((path) => !existsSync(join(repoRoot, path)))).toEqual([]) + }) +}) + +describe('PR routing', () => { + it('runs the ratchet job when its checker or the protocol changes', () => { + for (const file of [ + 'config/scripts/check-runtime-launcher-protocol-ratchet.mjs', + DAEMON_PROTOCOL_SOURCE_PATH + ]) { + expect(classifyPrJobs([file])['cross-version-wire']).toBe(true) + } + }) +}) + +describe('assessRuntimeLauncherProtocolRatchet', () => { + const launcherAndBump = { + changedFiles: [LAUNCHER, DAEMON_PROTOCOL_SOURCE_PATH], + baseProtocolVersion: 3, + candidateProtocolVersion: 4 + } + + it('fails a launcher change that also bumps the protocol', () => { + expect( + assessRuntimeLauncherProtocolRatchet({ ...launcherAndBump, overridden: false }) + ).toMatchObject({ + ok: false, + violated: true + }) + }) + + it('lets an explicit override through while still reporting the violation', () => { + const result = assessRuntimeLauncherProtocolRatchet({ ...launcherAndBump, overridden: true }) + expect(result).toMatchObject({ ok: true, violated: true }) + expect(result.lines.at(-1)).toContain('OVERRIDDEN') + }) + + it('passes a protocol bump without a launcher change, and a launcher change without a bump', () => { + expect( + assessRuntimeLauncherProtocolRatchet({ + ...launcherAndBump, + changedFiles: [DAEMON_PROTOCOL_SOURCE_PATH], + overridden: false + }).ok + ).toBe(true) + expect( + assessRuntimeLauncherProtocolRatchet({ + ...launcherAndBump, + candidateProtocolVersion: 3, + overridden: false + }).ok + ).toBe(true) + }) +}) + +describe('isOverrideEnabled', () => { + it('accepts only an explicit true', () => { + expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'true' })).toBe(true) + expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: '1' })).toBe(true) + expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'false' })).toBe(false) + expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: '' })).toBe(false) + expect(isOverrideEnabled({})).toBe(false) + }) +}) + +describe('checkRuntimeLauncherProtocolRatchet against git history', () => { + it('fails when the diff from the base both edits a launcher and bumps PROTOCOL_VERSION', () => { + const { repo, base } = repoWithCandidate((candidate) => { + writeProtocol(candidate, 4) + write(candidate, LAUNCHER, 'export const launcher = 2\n') + }) + expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(false) + expect( + checkRuntimeLauncherProtocolRatchet({ + repoRoot: repo, + base, + env: { [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'true' } + }).ok + ).toBe(true) + }) + + it('passes a bump next to unrelated edits', () => { + const { repo, base } = repoWithCandidate((candidate) => { + writeProtocol(candidate, 4) + write(candidate, 'src/unrelated.ts', 'export const unrelated = 2\n') + }) + expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(true) + }) + + it('ignores edits to the protocol file that keep PROTOCOL_VERSION', () => { + const { repo, base } = repoWithCandidate((candidate) => { + write( + candidate, + DAEMON_PROTOCOL_SOURCE_PATH, + '// comment\nexport const PROTOCOL_VERSION = 3\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2] as const\n' + ) + write(candidate, LAUNCHER, 'export const launcher = 2\n') + }) + expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(true) + }) +}) diff --git a/config/scripts/node-dist-archive-name.mjs b/config/scripts/node-dist-archive-name.mjs new file mode 100644 index 00000000000..6565ffad043 --- /dev/null +++ b/config/scripts/node-dist-archive-name.mjs @@ -0,0 +1,19 @@ +// Kept apart from update-node-runtime-pin.mjs so the offline check does not load its build graph. + +/** Node's platform suffix for each server target; nodejs.org names Windows `win`, not `win32`. */ +export const NODE_DIST_PLATFORMS = { + 'darwin-arm64': 'darwin-arm64', + 'darwin-x64': 'darwin-x64', + 'linux-arm64-glibc': 'linux-arm64', + 'linux-x64-glibc': 'linux-x64', + 'linux-arm64-musl': 'linux-arm64-musl', + 'linux-x64-musl': 'linux-x64-musl', + 'win32-arm64': 'win-arm64', + 'win32-x64': 'win-x64' +} + +export function nodeDistArchiveName(version, target) { + // Why .tar.gz over .tar.xz: every POSIX host can extract gzip; xz is not guaranteed. + const extension = target.startsWith('win32-') ? 'zip' : 'tar.gz' + return `node-v${version}-${NODE_DIST_PLATFORMS[target]}.${extension}` +} diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index a98168d120f..88627245173 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -148,6 +148,8 @@ const CROSS_VERSION_WIRE_PREFIXES = [ // The R1 daemon protocol crossing gate runs in this job. 'config/scripts/daemon-protocol-facts', 'config/scripts/check-daemon-protocol-crossing', + // R3 runtime launcher protocol ratchet; a bump always routes here via the protocol file. + 'config/scripts/check-runtime-launcher-protocol-ratchet', 'src/main/daemon/daemon-protocol-version.ts', 'src/shared/protocol-version', 'src/shared/terminal-stream-protocol', diff --git a/config/scripts/update-node-runtime-pin.mjs b/config/scripts/update-node-runtime-pin.mjs index d474dc082dd..241748d64ae 100644 --- a/config/scripts/update-node-runtime-pin.mjs +++ b/config/scripts/update-node-runtime-pin.mjs @@ -24,6 +24,7 @@ import { nodeRuntimeReleaseUrl } from '../../src/shared/node-runtime-pin.ts' import { currentTarget } from './build-orcad-bun.mjs' +import { nodeDistArchiveName } from './node-dist-archive-name.mjs' import { runProcessSync } from './script-child-process.mjs' import { getZipExtractorCommand } from './zip-extractor-command.mjs' @@ -34,24 +35,6 @@ const GENERATED_END = '// @generated-end' const RELEASE_KEYRING_URL = 'https://raw.githubusercontent.com/nodejs/release-keys/HEAD/gpg/pubring.kbx' -/** Node's platform suffix for each server target; nodejs.org names Windows `win`, not `win32`. */ -export const NODE_DIST_PLATFORMS = { - 'darwin-arm64': 'darwin-arm64', - 'darwin-x64': 'darwin-x64', - 'linux-arm64-glibc': 'linux-arm64', - 'linux-x64-glibc': 'linux-x64', - 'linux-arm64-musl': 'linux-arm64-musl', - 'linux-x64-musl': 'linux-x64-musl', - 'win32-arm64': 'win-arm64', - 'win32-x64': 'win-x64' -} - -export function nodeDistArchiveName(version, target) { - // Why .tar.gz over .tar.xz: every POSIX host can extract gzip; xz is not guaranteed. - const extension = target.startsWith('win32-') ? 'zip' : 'tar.gz' - return `node-v${version}-${NODE_DIST_PLATFORMS[target]}.${extension}` -} - export function parseShasums(text) { const hashes = new Map() for (const line of text.split('\n')) { diff --git a/config/scripts/update-node-runtime-pin.test.mjs b/config/scripts/update-node-runtime-pin.test.mjs index 81a82e67102..2e2f449f527 100644 --- a/config/scripts/update-node-runtime-pin.test.mjs +++ b/config/scripts/update-node-runtime-pin.test.mjs @@ -7,8 +7,8 @@ import { SERVER_TARGETS, nodeRuntimeExecutablePath } from '../../src/shared/node-runtime-pin.ts' +import { nodeDistArchiveName } from './node-dist-archive-name.mjs' import { - nodeDistArchiveName, parseNodeApiVersion, parseShasums, renderGeneratedBlock, diff --git a/package.json b/package.json index 39ab30535d3..ebf2136e145 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", "check:readme-local-links": "node config/scripts/check-readme-local-links.mjs", "check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs", + "check:runtime-launcher-protocol-ratchet": "node config/scripts/check-runtime-launcher-protocol-ratchet.mjs", "check:node-runtime-pin": "node config/scripts/check-node-runtime-pin.mjs", "build:orcad": "node config/scripts/build-orcad-bun.mjs", "build:orcad-template": "node config/scripts/build-orcad-template.mjs", diff --git a/src/main/ssh/orcad-remote-runtime.test.ts b/src/main/ssh/orcad-remote-runtime.test.ts index 637ab61e670..2b774d8d2c5 100644 --- a/src/main/ssh/orcad-remote-runtime.test.ts +++ b/src/main/ssh/orcad-remote-runtime.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -6,6 +6,7 @@ import { runProcessSync } from '../../shared/child-process/run-process' import { getRemoteHostPlatform } from './ssh-remote-platform' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { stopOrcadCommand } from './orcad-remote-process-control' +import { shellEscape } from './ssh-connection-utils' const directories: string[] = [] const host = getRemoteHostPlatform('linux-x64') @@ -22,17 +23,44 @@ function fixture(): string { return directory } -function launch(directory: string, nodePath: string) { +const NODE_SHA = 'e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b' + +/** A slot beside the shared `runtimes/` dir, as installed under `~/.orca-remote`. */ +function nodeSlot(options: { marker?: string; runtime?: boolean } = {}): string { + const root = fixture() + const slot = join(root, 'orcad-0.1.0-abcdef') + mkdirSync(slot) + writeFileSync(join(slot, '.runtime-node'), options.marker ?? `${NODE_SHA}\n`) + if (options.runtime !== false) { + const binDir = join(root, 'runtimes', `node-${NODE_SHA}`, 'bin') + mkdirSync(binDir, { recursive: true }) + symlinkSync(process.execPath, join(binDir, 'node')) + } + return slot +} + +/** Frozen copy of the Bun-era selector that shipped clients still run (design D7.1 R5). */ +function bunEraSelectorCommand(directory: string, legacyNodePath: string): string { + const runtime = shellEscape(join(directory, 'bun-runtime')) + const target = shellEscape(join(directory, '.build-target')) + return ( + `if [ -e ${target} ] || [ -e ${runtime} ]; then ` + + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` + + `else orcad_runtime=${shellEscape(legacyNodePath)}; fi` + ) +} + +function runSelected(selector: string) { return runProcessSync({ program: '/bin/sh', - args: [ - '-c', - `${selectOrcadSlotRuntimeCommand(host, directory, nodePath)}; ` + - '"$orcad_runtime" -e \'process.stdout.write("selected")\'' - ] + args: ['-c', `${selector}; "$orcad_runtime" -e 'process.stdout.write("selected")'`] }) } +function launch(directory: string, nodePath: string) { + return runSelected(selectOrcadSlotRuntimeCommand(host, directory, nodePath)) +} + describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', () => { it('returns an unverifiable stop result when a bundled runtime cannot execute', () => { const directory = fixture() @@ -64,4 +92,66 @@ describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', () it('retains the original runtime for a legacy slot', () => { expect(launch(fixture(), process.execPath)).toMatchObject({ code: 0, stdout: 'selected' }) }) + + it('launches a Node slot from its shared pinned runtime, never host Node', () => { + expect(launch(nodeSlot(), '/missing-host-node')).toMatchObject({ code: 0, stdout: 'selected' }) + }) + + it('accepts a Node slot path with a trailing separator', () => { + expect(launch(`${nodeSlot()}/`, '/missing-host-node')).toMatchObject({ + code: 0, + stdout: 'selected' + }) + }) + + it('refuses a Node slot whose pinned runtime is missing before invoking a working host Node', () => { + expect(launch(nodeSlot({ runtime: false }), process.execPath)).toMatchObject({ + code: 78, + stdout: '' + }) + }) + + it.each([ + ['empty', ''], + ['short', 'abc123'], + ['uppercase', NODE_SHA.toUpperCase()], + ['path traversal', `../${NODE_SHA.slice(3)}`] + ])('refuses a Node slot with a %s marker', (_label, marker) => { + expect(launch(nodeSlot({ marker }), process.execPath)).toMatchObject({ code: 78, stdout: '' }) + }) + + it('prefers the Node marker over Bun files in the same slot', () => { + const slot = nodeSlot() + writeFileSync(join(slot, '.build-target'), 'linux-x64-glibc') + expect(launch(slot, '/missing-host-node')).toMatchObject({ code: 0, stdout: 'selected' }) + }) + + it('shows a Node slot to a Bun-era client as legacy, not as a broken Bun slot', () => { + const slot = nodeSlot() + expect(runSelected(bunEraSelectorCommand(slot, process.execPath))).toMatchObject({ + code: 0, + stdout: 'selected' + }) + }) + + it('keeps the frozen Bun-era selector in step with the current Bun and legacy branches', () => { + const bun = fixture() + writeFileSync(join(bun, '.build-target'), 'linux-x64-glibc') + expect(runSelected(bunEraSelectorCommand(bun, process.execPath)).code).toBe(78) + expect(launch(bun, process.execPath).code).toBe(78) + const legacy = fixture() + expect(runSelected(bunEraSelectorCommand(legacy, process.execPath)).stdout).toBe('selected') + expect(launch(legacy, process.execPath).stdout).toBe('selected') + }) + + it('reads a stop verdict from a Node slot through its pinned runtime', () => { + const slot = nodeSlot() + writeFileSync(join(slot, '.orcad-pid'), String(process.pid)) + const result = runProcessSync({ + program: '/bin/sh', + args: ['-c', stopOrcadCommand(host, slot, { waitSeconds: 1, nodePath: '/missing-host-node' })] + }) + // No readiness file: the pinned runtime ran and reported no PID, so the verdict is unverifiable. + expect(result).toMatchObject({ code: 0, stdout: 'UNKNOWN\n' }) + }) }) diff --git a/src/main/ssh/orcad-remote-runtime.ts b/src/main/ssh/orcad-remote-runtime.ts index 505965620a0..7c92077afde 100644 --- a/src/main/ssh/orcad-remote-runtime.ts +++ b/src/main/ssh/orcad-remote-runtime.ts @@ -1,19 +1,49 @@ -import { ORCAD_BUILD_TARGET_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_NODE_RUNTIME_DIR_PREFIX, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE, + ORCAD_RUNTIMES_DIRNAME, + orcadBunRuntimeFilename +} from '../../shared/orcad-artifacts' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { shellEscape } from './ssh-connection-utils' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { joinRemotePath, remoteDirname, type RemoteHostPlatform } from './ssh-remote-platform' -/** Only legacy slots may use host Node; an incomplete Bun slot must not change runtimes. */ +/** + * The slot's own contents pick its runtime: Node marker, then Bun, then legacy host Node. + * Only legacy slots may use host Node; an incomplete Node or Bun slot must not change runtimes. + */ export function selectOrcadSlotRuntimeCommand( host: RemoteHostPlatform, directory: string, legacyNodePath: string ): string { assertPosixOrcadHost(host) + const nodeMarker = shellEscape( + joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME) + ) + const nodeRuntimePrefix = shellEscape( + joinRemotePath( + host, + remoteDirname(directory.replace(/\/+$/, ''), host), + ORCAD_RUNTIMES_DIRNAME, + ORCAD_NODE_RUNTIME_DIR_PREFIX + ) + ) + const nodeExecutable = shellEscape(`/${ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE}`) const runtime = shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))) const target = shellEscape(joinRemotePath(host, directory, ORCAD_BUILD_TARGET_FILENAME)) return ( - `if [ -e ${target} ] || [ -e ${runtime} ]; then ` + + `if [ -e ${nodeMarker} ]; then ` + + `orcad_node_sha=$(cat ${nodeMarker}) || exit 78; ` + + // Why validate: the digest becomes a path segment, so only a bare sha256 may reach it. + // The leading `(` keeps the pattern parseable when stop embeds this in `$(...)`. + 'case "$orcad_node_sha" in ("" | *[!0-9a-f]*) exit 78;; esac; ' + + '[ "${#orcad_node_sha}" -eq 64 ] || exit 78; ' + + `orcad_runtime=${nodeRuntimePrefix}"$orcad_node_sha"${nodeExecutable}; ` + + '[ -x "$orcad_runtime" ] || exit 78; ' + + `elif [ -e ${target} ] || [ -e ${runtime} ]; then ` + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` + `else orcad_runtime=${shellEscape(legacyNodePath)}; fi` ) diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index fea71dc63c4..4ea0a09708a 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -25,6 +25,17 @@ export function orcadArtifactHashPrefix(target: string): string { : '' } export const ORCAD_BUILD_TARGET_FILENAME = '.build-target' + +/** + * Marks a slot launched by the pinned Node runtime; its content is that runtime's + * executableSha256 (node-runtime-pin.ts). A Node slot must never carry `.build-target`: + * Bun-era selectors exit 78 on `.build-target` without `bun-runtime` (design D7.1 R5). + */ +export const ORCAD_NODE_RUNTIME_MARKER_FILENAME = '.runtime-node' +/** Beside the slot dirs and shared across Orca versions (design D2): `runtimes/node-/bin/node`. */ +export const ORCAD_RUNTIMES_DIRNAME = 'runtimes' +export const ORCAD_NODE_RUNTIME_DIR_PREFIX = 'node-' +export const ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE = 'bin/node' export const ORCAD_PARCEL_WATCHER_ENTRY = 'node_modules/@parcel/watcher/index.js' export const ORCAD_PARCEL_WATCHER_NATIVE = 'node_modules/@parcel/watcher/watcher.node' export const ORCAD_EMOJI_SHORTCODE_DATASET =