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-<sha256>/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-<sha>/bin/node layout

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-10-01 00:10:57 -07:00
committed by GitHub
co-authored by m4air
parent d4ae6a904b
commit d2dfc79764
12 changed files with 492 additions and 36 deletions
+10
View File
@@ -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
+7 -6
View File
@@ -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'])
@@ -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 <ref>
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 <ref> 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
}
}
@@ -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)
})
})
+19
View File
@@ -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}`
}
+2
View File
@@ -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',
+1 -18
View File
@@ -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')) {
@@ -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,
+1
View File
@@ -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",
+97 -7
View File
@@ -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' })
})
})
+34 -4
View File
@@ -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`
)
+11
View File
@@ -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-<sha256>/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 =