diff --git a/packages/sdk/src/authored-node-runner.ts b/packages/sdk/src/authored-node-runner.ts index 957f7f6c..281ab63a 100644 --- a/packages/sdk/src/authored-node-runner.ts +++ b/packages/sdk/src/authored-node-runner.ts @@ -3,9 +3,9 @@ import { FLOW_READ_BUDGET_MS } from './journal-read-policy.js'; import { spawn, spawnSync } from 'node:child_process'; import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { readFileSync, realpathSync } from 'node:fs'; +import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { isAbsolute, join } from 'node:path'; +import { delimiter, isAbsolute, join } from 'node:path'; import type { Readable } from 'node:stream'; import type { AuthoredRootMetadata } from './authored-root.js'; import type { AuthoredExecutionRuntime, AuthoredFlowExecutionResult, ExecuteAuthoredFlowOptions } from './authored-flow-executor.js'; @@ -35,10 +35,51 @@ const refusal = (): AuthoredFlowExecutionError => new AuthoredFlowExecutionError 'authored execution requires the embedded Node runner and Node >=22.14 on PATH (or an absolute FLOWS_AUTHORED_NODE); no workflow body was executed', ); interface NodeAuthority { path: string; version: string; executableSha256: string } +/** + * One authored run asks for the Node authority before root admission and + * again before spawning the body. Each probe spawns Node and hashes its whole + * executable (~100 MB), which a cold sandbox pays on the startup path twice. + * Reuse the previous answer only while the same candidate is selected (the + * override or the first `node` on PATH, by path and by realpath, so a + * retargeted symlink re-probes) and the executable it resolved is unchanged. + * The spawned child still hashes the executable it actually runs and the + * parent compares that report with this authority, so a binary replaced + * after the probe is refused, never trusted. + */ +let probedAuthority: { selection: string; fingerprint: string; authority: NodeAuthority } | undefined; +function executableFingerprint(path: string): string { + const stat = statSync(path, { bigint: true }); + return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':'); +} +/** The candidate `spawnSync(override ?? 'node')` would run, by path and realpath. */ +function selectedCandidate(override: string | undefined): string | undefined { + const directories = (process.env['PATH'] ?? '').split(delimiter); + // A relative PATH entry resolves against the working directory; do not + // guess which candidate the spawn would pick, just probe every time. + if (override === undefined && directories.some(directory => !isAbsolute(directory))) return undefined; + const candidates = override !== undefined ? [override] : directories.map(directory => join(directory, 'node')); + for (const candidate of candidates) { + try { + if (!statSync(candidate).isFile()) continue; + accessSync(candidate, fsConstants.X_OK); + return `${candidate}\0${realpathSync(candidate)}`; + } catch { /* Not this candidate. */ } + } + return undefined; +} function nodeAuthority(): NodeAuthority { if (embeddedSource === undefined) throw refusal(); const override = process.env['FLOWS_AUTHORED_NODE']; if (override !== undefined && !isAbsolute(override)) throw refusal(); + const selection = selectedCandidate(override); + if (selection !== undefined && probedAuthority?.selection === selection) { + try { + if (executableFingerprint(probedAuthority.authority.path) === probedAuthority.fingerprint) { + return probedAuthority.authority; + } + } catch { /* A vanished executable is re-probed, and refused there. */ } + } + probedAuthority = undefined; const probe = spawnSync(override ?? 'node', ['--input-type=module', '--eval', ` import {createHook} from 'node:async_hooks'; import {stripTypeScriptTypes,register} from 'node:module'; @@ -56,10 +97,22 @@ process.stdout.write(JSON.stringify({path:process.execPath,version:process.versi const [major, minor] = value.version.split('.').map(Number); if (major! < 22 || (major === 22 && minor! < 14)) throw refusal(); const path = realpathSync(value.path); - return { path, version: value.version, executableSha256: hash(readFileSync(path)) }; + const fingerprint = executableFingerprint(path); + const authority = { path, version: value.version, executableSha256: hash(readFileSync(path)) }; + // Reuse only an answer for a candidate that could be identified, and not + // one whose file was rewritten while it was being hashed. + if (selection !== undefined && executableFingerprint(path) === fingerprint) { + probedAuthority = { selection, fingerprint, authority }; + } + return authority; } catch { throw refusal(); } } +/** Test seam: resolve the authored Node authority exactly as the runner does. */ +export function authoredNodeAuthorityForTest(): Readonly { + return nodeAuthority(); +} + /** Called before root admission/resume; declarative commands never reach it. */ export function assertAuthoredRuntimeAvailable(): void { if (process.versions['bun'] === undefined) assertAuthoredPromiseHooks(); diff --git a/packages/sdk/tests/authored-node-authority.test.ts b/packages/sdk/tests/authored-node-authority.test.ts new file mode 100644 index 00000000..c3db4f60 --- /dev/null +++ b/packages/sdk/tests/authored-node-authority.test.ts @@ -0,0 +1,113 @@ +import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { authoredNodeAuthorityForTest, installAuthoredNodeSource } from '../src/authored-node-runner.js'; + +// The probe spawns the Node candidate and hashes its whole executable. One CLI +// run used to do both twice on the startup path (before root admission and +// again before spawning the body). The candidate here is a stand-in that +// answers the probe and records each invocation; its own file is the +// executable the authority fingerprints and hashes. +let directory: string, candidate: string, probes: string, previousOverride: string | undefined; + +beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), 'authored-node-authority-')); + probes = join(directory, 'probes'); + writeFileSync(probes, ''); + candidate = writeCandidate('node-candidate'); + previousOverride = process.env['FLOWS_AUTHORED_NODE']; + process.env['FLOWS_AUTHORED_NODE'] = candidate; + installAuthoredNodeSource('export {};\n'); +}); + +afterAll(() => { + if (previousOverride === undefined) delete process.env['FLOWS_AUTHORED_NODE']; + else process.env['FLOWS_AUTHORED_NODE'] = previousOverride; + rmSync(directory, { recursive: true, force: true }); +}); + +const probeCount = (): number => readFileSync(probes, 'utf8').split('\n').filter(Boolean).length; + +/** A stand-in Node that answers the probe with its own path and records the call. */ +function writeCandidate(name: string): string { + const path = join(directory, name); + writeFileSync(path, `#!/bin/sh\necho probe >> ${JSON.stringify(probes)}\nprintf '%s' '${JSON.stringify({ path, version: '22.14.0' })}'\n`); + chmodSync(path, 0o755); + return path; +} + +describe('authored Node authority', () => { + it('probes and hashes the executable once per process while it is unchanged', () => { + const first = authoredNodeAuthorityForTest(); + const second = authoredNodeAuthorityForTest(); + expect(probeCount()).toBe(1); + expect(second).toEqual(first); + expect(first).toMatchObject({ path: realpathSync(candidate), version: '22.14.0' }); + expect(first.executableSha256).toMatch(/^[a-f0-9]{64}$/); + }); + + it('re-probes and re-hashes when the executable file changes', () => { + const before = authoredNodeAuthorityForTest(); + const count = probeCount(); + writeFileSync(candidate, readFileSync(candidate, 'utf8') + '# replaced\n'); + const later = new Date(Date.now() + 60_000); + utimesSync(candidate, later, later); + const after = authoredNodeAuthorityForTest(); + expect(probeCount()).toBe(count + 1); + expect(after.executableSha256).not.toBe(before.executableSha256); + }); + + it('re-probes when the override selects a different executable', () => { + authoredNodeAuthorityForTest(); + const count = probeCount(); + const other = writeCandidate('other-candidate'); + process.env['FLOWS_AUTHORED_NODE'] = other; + try { + expect(authoredNodeAuthorityForTest().path).toBe(realpathSync(other)); + } finally { process.env['FLOWS_AUTHORED_NODE'] = candidate; } + expect(probeCount()).toBe(count + 1); + }); + + it('re-probes when the selected override symlink is retargeted while the old binary remains', () => { + const first = writeCandidate('symlink-target-a'); + const second = writeCandidate('symlink-target-b'); + const selected = join(directory, 'selected-node'); + symlinkSync(first, selected); + process.env['FLOWS_AUTHORED_NODE'] = selected; + try { + expect(authoredNodeAuthorityForTest().path).toBe(realpathSync(first)); + const count = probeCount(); + expect(authoredNodeAuthorityForTest().path).toBe(realpathSync(first)); + expect(probeCount()).toBe(count); + unlinkSync(selected); + symlinkSync(second, selected); + expect(authoredNodeAuthorityForTest().path).toBe(realpathSync(second)); + expect(probeCount()).toBe(count + 1); + } finally { process.env['FLOWS_AUTHORED_NODE'] = candidate; } + }); + + it('re-probes when the `node` found on PATH is retargeted', () => { + const first = writeCandidate('path-target-a'); + const second = writeCandidate('path-target-b'); + const bin = mkdtempSync(join(directory, 'bin-')); + const node = join(bin, 'node'); + symlinkSync(first, node); + const path = process.env['PATH']; + delete process.env['FLOWS_AUTHORED_NODE']; + process.env['PATH'] = `${bin}:/usr/bin:/bin`; + try { + expect(authoredNodeAuthorityForTest().path).toBe(realpathSync(first)); + const count = probeCount(); + expect(authoredNodeAuthorityForTest().path).toBe(realpathSync(first)); + expect(probeCount()).toBe(count); + unlinkSync(node); + symlinkSync(second, node); + expect(authoredNodeAuthorityForTest().path).toBe(realpathSync(second)); + expect(probeCount()).toBe(count + 1); + } finally { + process.env['PATH'] = path; + process.env['FLOWS_AUTHORED_NODE'] = candidate; + } + }); +});