Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions packages/sdk/src/authored-node-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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<NodeAuthority> {
return nodeAuthority();
}

/** Called before root admission/resume; declarative commands never reach it. */
export function assertAuthoredRuntimeAvailable(): void {
if (process.versions['bun'] === undefined) assertAuthoredPromiseHooks();
Expand Down
113 changes: 113 additions & 0 deletions packages/sdk/tests/authored-node-authority.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
});
Loading