@relayflows/sdk 2.0.11 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/adapters/base.d.ts +6 -0
  2. package/dist/adapters/base.d.ts.map +1 -1
  3. package/dist/adapters/claude.d.ts.map +1 -1
  4. package/dist/adapters/claude.js +1 -0
  5. package/dist/adapters/claude.js.map +1 -1
  6. package/dist/authored-admission.d.ts +5 -0
  7. package/dist/authored-admission.d.ts.map +1 -0
  8. package/dist/authored-admission.js +14 -0
  9. package/dist/authored-admission.js.map +1 -0
  10. package/dist/authored-budget.d.ts +1 -1
  11. package/dist/authored-budget.d.ts.map +1 -1
  12. package/dist/authored-budget.js +3 -3
  13. package/dist/authored-budget.js.map +1 -1
  14. package/dist/authored-flow-error.d.ts +1 -1
  15. package/dist/authored-flow-error.d.ts.map +1 -1
  16. package/dist/authored-flow-error.js.map +1 -1
  17. package/dist/authored-flow-executor.d.ts +10 -0
  18. package/dist/authored-flow-executor.d.ts.map +1 -1
  19. package/dist/authored-flow-executor.js +10 -7
  20. package/dist/authored-flow-executor.js.map +1 -1
  21. package/dist/authored-flow-loader.d.ts +20 -0
  22. package/dist/authored-flow-loader.d.ts.map +1 -1
  23. package/dist/authored-flow-loader.js +86 -11
  24. package/dist/authored-flow-loader.js.map +1 -1
  25. package/dist/authored-helper-effect.d.ts +1 -1
  26. package/dist/authored-helper-effect.d.ts.map +1 -1
  27. package/dist/authored-helper-effect.js +5 -3
  28. package/dist/authored-helper-effect.js.map +1 -1
  29. package/dist/authored-mcp.d.ts +1 -1
  30. package/dist/authored-mcp.d.ts.map +1 -1
  31. package/dist/authored-mcp.js +7 -4
  32. package/dist/authored-mcp.js.map +1 -1
  33. package/dist/authored-node-entry.d.ts +2 -0
  34. package/dist/authored-node-entry.d.ts.map +1 -0
  35. package/dist/authored-node-entry.js +111 -0
  36. package/dist/authored-node-entry.js.map +1 -0
  37. package/dist/authored-node-runner.d.ts +11 -0
  38. package/dist/authored-node-runner.d.ts.map +1 -0
  39. package/dist/authored-node-runner.js +246 -0
  40. package/dist/authored-node-runner.js.map +1 -0
  41. package/dist/authored-plugin-effect.d.ts +1 -1
  42. package/dist/authored-plugin-effect.d.ts.map +1 -1
  43. package/dist/authored-plugin-effect.js +7 -4
  44. package/dist/authored-plugin-effect.js.map +1 -1
  45. package/dist/authored-root.d.ts +38 -0
  46. package/dist/authored-root.d.ts.map +1 -0
  47. package/dist/authored-root.js +329 -0
  48. package/dist/authored-root.js.map +1 -0
  49. package/dist/authored-runtime-capability.d.ts +6 -0
  50. package/dist/authored-runtime-capability.d.ts.map +1 -0
  51. package/dist/authored-runtime-capability.js +40 -0
  52. package/dist/authored-runtime-capability.js.map +1 -0
  53. package/dist/authored-source-authority.d.ts +4 -0
  54. package/dist/authored-source-authority.d.ts.map +1 -0
  55. package/dist/authored-source-authority.js +61 -0
  56. package/dist/authored-source-authority.js.map +1 -0
  57. package/dist/authored-worker-step.d.ts +2 -2
  58. package/dist/authored-worker-step.d.ts.map +1 -1
  59. package/dist/authored-worker-step.js +11 -6
  60. package/dist/authored-worker-step.js.map +1 -1
  61. package/dist/budget-preflight.d.ts +17 -3
  62. package/dist/budget-preflight.d.ts.map +1 -1
  63. package/dist/budget-preflight.js +31 -24
  64. package/dist/budget-preflight.js.map +1 -1
  65. package/dist/cli/check.js +3 -1
  66. package/dist/cli/check.js.map +1 -1
  67. package/dist/cli/direct-run.d.ts.map +1 -1
  68. package/dist/cli/direct-run.js +18 -14
  69. package/dist/cli/direct-run.js.map +1 -1
  70. package/dist/cli/run.d.ts +2 -0
  71. package/dist/cli/run.d.ts.map +1 -1
  72. package/dist/cli/run.js +56 -1
  73. package/dist/cli/run.js.map +1 -1
  74. package/dist/cli-adapter.d.ts +16 -0
  75. package/dist/cli-adapter.d.ts.map +1 -1
  76. package/dist/cli-adapter.js +17 -0
  77. package/dist/cli-adapter.js.map +1 -1
  78. package/dist/cli.js +4 -4
  79. package/dist/cli.js.map +1 -1
  80. package/dist/cloud-run.d.ts +11 -1
  81. package/dist/cloud-run.d.ts.map +1 -1
  82. package/dist/cloud-run.js +65 -19
  83. package/dist/cloud-run.js.map +1 -1
  84. package/dist/failure-kinds.d.ts +7 -3
  85. package/dist/failure-kinds.d.ts.map +1 -1
  86. package/dist/failure-kinds.js +5 -1
  87. package/dist/failure-kinds.js.map +1 -1
  88. package/dist/journal-client.d.ts +1 -1
  89. package/dist/journal-client.d.ts.map +1 -1
  90. package/dist/journal-client.js +6 -2
  91. package/dist/journal-client.js.map +1 -1
  92. package/dist/llm-worker.d.ts.map +1 -1
  93. package/dist/llm-worker.js +4 -2
  94. package/dist/llm-worker.js.map +1 -1
  95. package/dist/local-agent.d.ts +1 -1
  96. package/dist/local-agent.d.ts.map +1 -1
  97. package/dist/local-agent.js +2 -2
  98. package/dist/local-agent.js.map +1 -1
  99. package/dist/model-pricing.d.ts +3 -4
  100. package/dist/model-pricing.d.ts.map +1 -1
  101. package/dist/model-pricing.js +4 -4
  102. package/dist/model-pricing.js.map +1 -1
  103. package/dist/preflight.d.ts +4 -1
  104. package/dist/preflight.d.ts.map +1 -1
  105. package/dist/preflight.js +36 -18
  106. package/dist/preflight.js.map +1 -1
  107. package/dist/protocol.d.ts +2 -0
  108. package/dist/protocol.d.ts.map +1 -1
  109. package/dist/spec.d.ts +5 -4
  110. package/dist/spec.d.ts.map +1 -1
  111. package/dist/spec.js.map +1 -1
  112. package/dist/worker-cli.d.ts.map +1 -1
  113. package/dist/worker-cli.js +6 -5
  114. package/dist/worker-cli.js.map +1 -1
  115. package/dist/worker-spend.d.ts +4 -3
  116. package/dist/worker-spend.d.ts.map +1 -1
  117. package/dist/worker-spend.js +13 -4
  118. package/dist/worker-spend.js.map +1 -1
  119. package/dist/worker.d.ts.map +1 -1
  120. package/dist/worker.js +4 -2
  121. package/dist/worker.js.map +1 -1
  122. package/package.json +2 -2
  123. package/src/adapters/base.ts +7 -0
  124. package/src/adapters/claude.ts +1 -0
  125. package/src/authored-admission.ts +17 -0
  126. package/src/authored-budget.ts +4 -3
  127. package/src/authored-flow-error.ts +0 -1
  128. package/src/authored-flow-executor.ts +27 -7
  129. package/src/authored-flow-loader.ts +96 -13
  130. package/src/authored-helper-effect.ts +5 -3
  131. package/src/authored-mcp.ts +6 -4
  132. package/src/authored-node-entry.ts +90 -0
  133. package/src/authored-node-runner.ts +219 -0
  134. package/src/authored-plugin-effect.ts +6 -3
  135. package/src/authored-root.ts +396 -0
  136. package/src/authored-runtime-capability.ts +40 -0
  137. package/src/authored-source-authority.ts +62 -0
  138. package/src/authored-worker-step.ts +15 -4
  139. package/src/budget-preflight.ts +41 -25
  140. package/src/cli/check.ts +3 -1
  141. package/src/cli/direct-run.ts +24 -16
  142. package/src/cli/run.ts +58 -1
  143. package/src/cli-adapter.ts +27 -0
  144. package/src/cli.ts +5 -5
  145. package/src/cloud-run.ts +74 -21
  146. package/src/failure-kinds.ts +5 -1
  147. package/src/journal-client.ts +6 -2
  148. package/src/llm-worker.ts +4 -2
  149. package/src/local-agent.ts +7 -2
  150. package/src/model-pricing.ts +4 -4
  151. package/src/preflight.ts +40 -18
  152. package/src/protocol.ts +2 -0
  153. package/src/spec.ts +5 -4
  154. package/src/worker-cli.ts +7 -5
  155. package/src/worker-spend.ts +15 -4
  156. package/src/worker.ts +4 -2
@@ -13,6 +13,7 @@ import { helperProviders, type HelperCall } from '@relayflows/surface/runtime';
13
13
  import { helperWriteback, HelperDeliveryError } from './helper-writeback.js';
14
14
  import { checkSlackHelpers } from './slack-preflight.js';
15
15
  import { atomicJson, readSlackReceipt, receiptPath, slackWriteback, type SlackCall } from './slack-writeback.js';
16
+ import { authoredChildAdmissionKey } from './authored-admission.js';
16
17
 
17
18
  type ProviderCall = HelperCall | SlackCall;
18
19
 
@@ -30,20 +31,21 @@ export function assertHelperCredentials(provider: string): void {
30
31
  /** Each helper uses the existing agent lease + effect protocol; no new kernel verb. */
31
32
  export async function runHelperEffect(
32
33
  journal: JournalClient, name: string, stepId: string, call: ProviderCall,
33
- dataDir: string, journalSteps: AuthoredFlowJournalStep[],
34
+ dataDir: string, journalSteps: AuthoredFlowJournalStep[], rootRunId?: string,
34
35
  ): Promise<unknown> {
35
36
  assertHelperCredentials(call.provider);
36
37
  if (call.provider === 'notion' && call.verb === 'appendBlock' && process.env.RELAYFLOWS_NOTION_MOCK !== '1') {
37
38
  throw new AuthoredFlowExecutionError('helper_provider.unsupported', 'Notion appendBlock has no upstream mount writeback route');
38
39
  }
39
- const stream = `${call.provider}-helper-${randomUUID()}`;
40
+ const admissionKey = authoredChildAdmissionKey(rootRunId, stepId);
41
+ const stream = `${call.provider}-helper-${admissionKey?.slice(-32) ?? randomUUID()}`;
40
42
  const spec = toKernelSpec(compileSpec({
41
43
  version: SPEC_SCHEMA_VERSION, name: `${name}/${stepId}`,
42
44
  steps: [{ id: stepId, type: 'agent', instruction: JSON.stringify(call),
43
45
  maxIterations: 3, recoveryMode: 'reset',
44
46
  surfaces: { streams: [{ stream }], external: [`/${call.provider}`] } }],
45
47
  }));
46
- const outcome = await journal.runStart(spec);
48
+ const outcome = await journal.runStart(spec, undefined, admissionKey);
47
49
  await atomicJson(join(dataDir, 'helper-runs', `${outcome.run_id}.json`), { provider: call.provider });
48
50
  await driveHelperEffect(journal, outcome.run_id, spec.steps[0] as KernelAgentStep, call, dataDir);
49
51
  const output = await readCompletedStepOutput(journal, outcome.run_id, stepId, journalSteps);
@@ -11,6 +11,7 @@ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
11
11
  import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
12
12
  import { readCompletedStepOutput } from './authored-step-output.js';
13
13
  import { withWorkerLease } from './worker-lease.js';
14
+ import { authoredChildAdmissionKey } from './authored-admission.js';
14
15
 
15
16
  /** Own keys expose precisely the preflight inventory, including prototype-like names. */
16
17
  export function buildMcpProxy(
@@ -49,11 +50,12 @@ export class McpStepError extends AuthoredFlowExecutionError {
49
50
  export async function runMcpEffect(
50
51
  journal: JournalClient, flowName: string, id: string, server: string, tool: string,
51
52
  args: unknown, known: boolean, config: McpServerConfig,
52
- journalSteps: AuthoredFlowJournalStep[],
53
+ journalSteps: AuthoredFlowJournalStep[], rootRunId?: string,
53
54
  ): Promise<unknown> {
54
55
  const idempotencyKey = `mcp:${server}:${tool}:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`;
55
56
  const surfacePath = `/mcp/${pathPart(server)}/${pathPart(tool)}`;
56
- const stream = `mcp-worker-${randomUUID()}`;
57
+ const admissionKey = authoredChildAdmissionKey(rootRunId, id);
58
+ const stream = `mcp-worker-${admissionKey?.slice(-32) ?? randomUUID()}`;
57
59
  const instruction = JSON.stringify({ type: 'mcp', server, tool, input: args });
58
60
  const peer = journal.createPeer();
59
61
  let diagnostic: string | undefined;
@@ -132,11 +134,11 @@ export async function runMcpEffect(
132
134
  failed(new Error('MCP worker dispatch deadline exceeded'));
133
135
  }, 30_000);
134
136
  // Propagate a dispatch failure even if run.start itself is still pending.
135
- const starting = journal.runStart(spec);
137
+ const starting = journal.runStart(spec, undefined, admissionKey);
136
138
  await Promise.race([starting, completed]);
137
139
  const outcome = await starting;
138
140
  childRunId = outcome.run_id;
139
- await completed;
141
+ if (outcome.status !== 'completed') await completed;
140
142
  if (diagnostic !== undefined) {
141
143
  try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
142
144
  catch (error) {
@@ -0,0 +1,90 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { createHash, createHmac } from 'node:crypto';
3
+ import { readFileSync, writeSync } from 'node:fs';
4
+ import { JournalClient } from './journal-client.js';
5
+ import { executeAuthoredFlow } from './authored-flow-executor.js';
6
+ import { loadPinnedAuthoredSource } from './authored-source-authority.js';
7
+ import { assertAuthoredNodeVersion, parseAuthoredParentPid } from './authored-runtime-capability.js';
8
+ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
9
+ import type { AuthoredRootMetadata } from './authored-root.js';
10
+
11
+ let channelKey: string | undefined, sequence = 0;
12
+ // Capture writers before loading authored modules; credentials never enter env.
13
+ const writeFrame = writeSync, mac = createHmac;
14
+ const send = (message: unknown): void => {
15
+ const payload = JSON.stringify(message);
16
+ if (channelKey === undefined) { writeFrame(3, payload + '\n'); return; }
17
+ const seq = ++sequence;
18
+ writeFrame(3, JSON.stringify({ seq, payload,
19
+ mac: mac('sha256', channelKey).update(`${seq}\0${payload}`).digest('hex') }) + '\n');
20
+ };
21
+ const hash = (path: string): string => createHash('sha256').update(readFileSync(path)).digest('hex');
22
+ const controller = new AbortController();
23
+ let finished = false;
24
+ const abort = (): void => {
25
+ controller.abort();
26
+ setTimeout(() => process.exit(1), 2000).unref();
27
+ };
28
+ process.on('SIGINT', abort); process.on('SIGTERM', abort);
29
+ // The parent owns the root lease. Do not continue authored effects after it dies.
30
+ process.stdin.on('end', () => { if (!finished) process.exit(1); });
31
+ process.stdin.on('error', () => process.exit(1));
32
+ // A separate event loop enforces parent loss even while authored JS is blocked.
33
+ // Capture the expected PID in the parent's spawn arguments, before any child work.
34
+ const parentPid = parseAuthoredParentPid(process.argv[2]);
35
+ const watchdog = new Worker(`
36
+ const {parentPort,workerData}=require('node:worker_threads');
37
+ function check(){if(process.ppid!==workerData.parentPid)process.kill(process.pid,'SIGKILL');}
38
+ check();setInterval(check,50);parentPort.postMessage('ready');
39
+ `, { eval: true, workerData: { parentPid } });
40
+ // Enforcement must not silently disappear after the readiness Promise settles.
41
+ const watchdogLost = (): void => { if (!finished) process.kill(process.pid, 'SIGKILL'); };
42
+ watchdog.on('error', watchdogLost);
43
+ watchdog.on('exit', watchdogLost);
44
+ let client: JournalClient | undefined;
45
+ try {
46
+ assertAuthoredNodeVersion();
47
+ await new Promise<void>((resolve,reject)=>{watchdog.once('message',()=>resolve());watchdog.once('error',reject);});
48
+ send({ type: 'ready', runtime: { kind: 'node', version: process.versions.node,
49
+ executableSha256: hash(process.execPath), payloadSha256: hash(process.argv[1]!) } });
50
+ const request = await new Promise<{ channelKey: string; metadata: AuthoredRootMetadata; socketPath: string;
51
+ rootRunId: string; dataDir: string; localAgentStream?: string }>((resolve, reject) => {
52
+ let buffer = '';
53
+ process.stdin.setEncoding('utf8');
54
+ const onData = (chunk: string): void => {
55
+ buffer += chunk;
56
+ if (Buffer.byteLength(buffer) > 2 * 1024 * 1024) { reject(new Error('authored runtime request exceeded limit')); return; }
57
+ const end = buffer.indexOf('\n');
58
+ if (end < 0) return;
59
+ process.stdin.off('data', onData);
60
+ try { resolve(JSON.parse(buffer.slice(0, end))); } catch { reject(new Error('invalid authored runtime request')); }
61
+ };
62
+ process.stdin.on('data', onData);
63
+ });
64
+ if (typeof request.channelKey !== 'string' || !/^[a-f0-9]{64}$/.test(request.channelKey)) throw new Error('invalid authored channel key');
65
+ channelKey = request.channelKey;
66
+ controller.signal.throwIfAborted();
67
+ const loaded = await loadPinnedAuthoredSource(request.metadata, true);
68
+ if (request.localAgentStream !== request.metadata.localAgentStream) throw new Error('authored root local agent surface mismatch');
69
+ client = new JournalClient(request.socketPath);
70
+ await client.connect(); await client.hello('flows-authored-node');
71
+ const result = await executeAuthoredFlow(loaded.handle, client,
72
+ request.metadata.inputPresent ? request.metadata.input : undefined, {
73
+ getDefinition: loaded.getDefinition, dataDir: request.dataDir,
74
+ flowPath: request.metadata.flowPath, rootRunId: request.rootRunId,
75
+ localAgentStream: request.localAgentStream, signal: controller.signal,
76
+ onProgress: event => send({ type: 'progress', event }),
77
+ onWait: event => send({ type: 'wait', event }),
78
+ });
79
+ send({ type: 'result', result });
80
+ } catch (error) {
81
+ const message = error instanceof Error ? error.message : 'authored body failed';
82
+ const prefix = error instanceof AuthoredFlowExecutionError ? `${error.code}: ` : '';
83
+ send({ type: 'error', message: prefix && message.startsWith(prefix) ? message.slice(prefix.length) : message,
84
+ ...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
85
+ completionReason: error.completionReason, runId: error.runId } : {}) });
86
+ process.exitCode = 1;
87
+ } finally {
88
+ finished = true; await watchdog.terminate(); client?.close(); process.stdin.destroy();
89
+ process.off('SIGINT', abort); process.off('SIGTERM', abort);
90
+ }
@@ -0,0 +1,219 @@
1
+ import { JournalClient } from './journal-client.js';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
4
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { readFileSync, realpathSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { isAbsolute, join } from 'node:path';
8
+ import type { Readable } from 'node:stream';
9
+ import type { AuthoredRootMetadata } from './authored-root.js';
10
+ import type { AuthoredExecutionRuntime, AuthoredFlowExecutionResult, ExecuteAuthoredFlowOptions } from './authored-flow-executor.js';
11
+ import { AuthoredFlowExecutionError, type AuthoredFlowExecutionErrorCode } from './authored-flow-error.js';
12
+ import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
13
+
14
+ let embeddedSource: string | undefined;
15
+ /** Installed by the standalone build; never fetched or resolved from a workspace. */
16
+ export function installAuthoredNodeSource(source: string): void {
17
+ if (embeddedSource !== undefined || source.length === 0) throw new Error('invalid authored runtime payload');
18
+ embeddedSource = source;
19
+ }
20
+ const hash = (value: Uint8Array | string): string => createHash('sha256').update(value).digest('hex');
21
+ const refusal = (): AuthoredFlowExecutionError => new AuthoredFlowExecutionError(
22
+ 'unsupported_promise_lifecycle',
23
+ 'authored execution requires the embedded Node runner and Node >=22.14 on PATH (or an absolute FLOWS_AUTHORED_NODE); no workflow body was executed',
24
+ );
25
+ interface NodeAuthority { path: string; version: string; executableSha256: string }
26
+ function nodeAuthority(): NodeAuthority {
27
+ if (embeddedSource === undefined) throw refusal();
28
+ const override = process.env['FLOWS_AUTHORED_NODE'];
29
+ if (override !== undefined && !isAbsolute(override)) throw refusal();
30
+ const probe = spawnSync(override ?? 'node', ['--input-type=module', '--eval', `
31
+ import {createHook} from 'node:async_hooks';
32
+ import {stripTypeScriptTypes,register} from 'node:module';
33
+ const [major,minor]=process.versions.node.split('.').map(Number);
34
+ let init=false,resolve=false;
35
+ const h=createHook({init(_id,type){if(type==='PROMISE')init=true},promiseResolve(){resolve=true}}).enable();
36
+ new Promise(r=>r());h.disable();
37
+ if(process.versions.bun || major<22 || (major===22 && minor<14) || !init || !resolve || typeof stripTypeScriptTypes!=='function' || typeof register!=='function') process.exit(2);
38
+ process.stdout.write(JSON.stringify({path:process.execPath,version:process.versions.node}));
39
+ `], { encoding: 'utf8', timeout: 10_000, maxBuffer: 4096, env: process.env });
40
+ if (probe.error || probe.status !== 0) throw refusal();
41
+ try {
42
+ const value = JSON.parse(probe.stdout) as { path: string; version: string };
43
+ if (!isAbsolute(value.path) || !/^\d+\.\d+\.\d+$/.test(value.version)) throw refusal();
44
+ const [major, minor] = value.version.split('.').map(Number);
45
+ if (major! < 22 || (major === 22 && minor! < 14)) throw refusal();
46
+ const path = realpathSync(value.path);
47
+ return { path, version: value.version, executableSha256: hash(readFileSync(path)) };
48
+ } catch { throw refusal(); }
49
+ }
50
+
51
+ /** Called before root admission/resume; declarative commands never reach it. */
52
+ export function assertAuthoredRuntimeAvailable(): void {
53
+ if (process.versions['bun'] === undefined) assertAuthoredPromiseHooks();
54
+ else nodeAuthority();
55
+ }
56
+
57
+ /** Bun retains the root lease and local workers. Only the authored body uses Node. */
58
+ export async function runAuthoredInNode(
59
+ metadata: AuthoredRootMetadata,
60
+ socketPath: string,
61
+ rootRunId: string,
62
+ options: ExecuteAuthoredFlowOptions,
63
+ ): Promise<AuthoredFlowExecutionResult> {
64
+ const authority = nodeAuthority();
65
+ const source = embeddedSource!;
66
+ const runtime: AuthoredExecutionRuntime = {
67
+ kind: 'node', version: authority.version,
68
+ executableSha256: authority.executableSha256, payloadSha256: hash(source),
69
+ };
70
+ options.signal?.throwIfAborted();
71
+ const directory = await mkdtemp(join(tmpdir(), 'flows-authored-node-'));
72
+ const entry = join(directory, 'runner.mjs');
73
+ try {
74
+ await writeFile(entry, source, { mode: 0o400, flag: 'wx' });
75
+ if (hash(await readFile(entry)) !== runtime.payloadSha256) throw refusal();
76
+ const child = spawn(authority.path, ['--experimental-transform-types', entry, String(process.pid)], {
77
+ cwd: process.cwd(), env: process.env,
78
+ stdio: ['pipe', 'inherit', 'inherit', 'pipe'],
79
+ });
80
+ const result = await new Promise<AuthoredFlowExecutionResult>((resolve, reject) => {
81
+ const channelKey = randomBytes(32).toString('hex');
82
+ let expectedSequence = 1;
83
+ let buffer = '', ready = false, result: AuthoredFlowExecutionResult | undefined;
84
+ let failure: Error | undefined, killTimer: ReturnType<typeof setTimeout> | undefined;
85
+ const stop = (error: Error): void => {
86
+ failure ??= error;
87
+ child.kill('SIGTERM');
88
+ killTimer ??= setTimeout(() => child.kill('SIGKILL'), 2500);
89
+ killTimer.unref();
90
+ };
91
+ const abort = (): void => stop(new Error('authored root execution was aborted'));
92
+ options.signal?.addEventListener('abort', abort, { once: true });
93
+ if (options.signal?.aborted) abort();
94
+ // Do not intercept the parent's signals: its normal exit keeps the root
95
+ // recoverable. The child observes parent EOF (and shared process-group signals).
96
+ const startupTimer = setTimeout(() => stop(new Error('authored runtime readiness timed out')), 10_000);
97
+ startupTimer.unref();
98
+ const pipe = child.stdio[3] as Readable;
99
+ pipe.setEncoding('utf8');
100
+ pipe.on('data', (chunk: string) => {
101
+ buffer += chunk;
102
+ if (Buffer.byteLength(buffer) > 16 * 1024 * 1024) { stop(new Error('authored runtime response exceeded limit')); return; }
103
+ for (;;) {
104
+ const end = buffer.indexOf('\n'); if (end < 0) break;
105
+ const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
106
+ try {
107
+ let message = JSON.parse(line);
108
+ if (ready) {
109
+ if (message.seq !== expectedSequence || typeof message.payload !== 'string'
110
+ || typeof message.mac !== 'string' || !/^[a-f0-9]{64}$/.test(message.mac)) {
111
+ throw new Error('invalid authored runtime authenticated frame');
112
+ }
113
+ const expected = createHmac('sha256', channelKey)
114
+ .update(`${expectedSequence}\0${message.payload}`).digest();
115
+ if (!timingSafeEqual(expected, Buffer.from(message.mac, 'hex'))) {
116
+ throw new Error('invalid authored runtime authenticated frame');
117
+ }
118
+ expectedSequence++;
119
+ message = JSON.parse(message.payload);
120
+ }
121
+ if (message.type === 'ready' && !ready && !result) {
122
+ if (JSON.stringify(message.runtime) !== JSON.stringify(runtime)) throw refusal();
123
+ ready = true;
124
+ clearTimeout(startupTimer);
125
+ // Keep stdin open: EOF tells the child its lease-owning parent died.
126
+ child.stdin!.write(JSON.stringify({ channelKey, metadata, socketPath, rootRunId,
127
+ dataDir: options.dataDir, localAgentStream: options.localAgentStream }) + '\n');
128
+ } else if (!ready || result) throw new Error('unexpected authored runtime message');
129
+ else if (message.type === 'progress') options.onProgress?.(message.event);
130
+ else if (message.type === 'wait') options.onWait?.(message.event);
131
+ else if (message.type === 'result') result = { ...message.result, executionRuntime: runtime };
132
+ else if (message.type === 'error') {
133
+ failure = typeof message.code === 'string'
134
+ ? new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
135
+ message.message, message.completionReason, message.runId)
136
+ : new Error(message.message);
137
+ } else throw new Error('unknown authored runtime message');
138
+ } catch (error) { stop(error instanceof Error ? error : new Error('invalid authored runtime message')); }
139
+ }
140
+ });
141
+ child.stdin!.on('error', () => stop(new Error('authored runtime request pipe closed')));
142
+ pipe.on('error', () => stop(new Error('authored runtime result pipe failed')));
143
+ child.once('error', () => { failure = refusal(); });
144
+ child.once('close', (code) => {
145
+ clearTimeout(startupTimer);
146
+ if (killTimer !== undefined) clearTimeout(killTimer);
147
+ options.signal?.removeEventListener('abort', abort);
148
+ if (failure) reject(failure);
149
+ else if (code !== 0 || !ready || result === undefined || buffer.length > 0) reject(new Error('authored runtime exited without a complete result'));
150
+ else resolve(result);
151
+ });
152
+ });
153
+ await verifyAuthoredNodeResult(result, metadata, rootRunId, socketPath);
154
+ return result;
155
+ } finally { await rm(directory, { recursive: true, force: true }); }
156
+ }
157
+
158
+ /** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */
159
+ export async function verifyAuthoredNodeResult(
160
+ result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata,
161
+ rootRunId: string, socketPath: string,
162
+ ): Promise<void> {
163
+ const invalid = (): never => { throw new Error('authored runtime result has no matching durable completion'); };
164
+ if (result.rootRunId !== rootRunId || result.name !== metadata.flowName
165
+ || !['success', 'needs_human'].includes(result.completionReason)
166
+ || !Array.isArray(result.journalSteps) || result.journalSteps.length === 0) invalid();
167
+ const terminal = result.journalSteps.at(-1)!;
168
+ if (!terminal || !/^complete-[1-9][0-9]*$/.test(terminal.id)) invalid();
169
+ const count = Number(terminal.id.slice('complete-'.length));
170
+ if (!Number.isSafeInteger(count) || result.journalSteps.length !== count) invalid();
171
+ const ordinal = (id: string): number => Number(/-([1-9][0-9]*)$/.exec(id)?.[1]);
172
+ // Parallel awaits may finish in either order; validate a copy in declaration order.
173
+ const ordered = [...result.journalSteps].sort((a,b)=>ordinal(a.id)-ordinal(b.id));
174
+ const runs = new Set<string>();
175
+ const journal = new JournalClient(socketPath);
176
+ await journal.connect();
177
+ try {
178
+ await journal.hello('flows-authored-result-verifier');
179
+ for (const [index, claimed] of ordered.entries()) {
180
+ if (!claimed || typeof claimed.id !== 'string' || typeof claimed.runId !== 'string'
181
+ || ordinal(claimed.id) !== index+1 || claimed.completionReason !== 'success'
182
+ || runs.has(claimed.runId)) invalid();
183
+ runs.add(claimed.runId);
184
+ const state = await journal.runGet(claimed.runId);
185
+ if (state.run_id !== claimed.runId || state.status !== 'completed'
186
+ || state.steps[claimed.id]?.state !== 'done') invalid();
187
+ const entries: Array<{
188
+ seq: number; entry_type: string; step_id?: string; payload?: {
189
+ completionReason?: string; spec?: { name?: string; steps?: Array<{id?:string;type?:string;command?:string}> };
190
+ };
191
+ }> = [];
192
+ let fromSeq = 1;
193
+ for (;;) {
194
+ const page = (await journal.journalRead(claimed.runId, fromSeq, 100)).entries;
195
+ if (page.length === 0) break;
196
+ for (const raw of page) {
197
+ const entry = raw as typeof entries[number];
198
+ if (!Number.isSafeInteger(entry?.seq) || entry.seq < fromSeq) invalid();
199
+ fromSeq = entry.seq + 1;
200
+ // Keep only completion evidence; streaming logs can span many pages.
201
+ if (['run.spawned', 'step.completed', 'run.completed'].includes(entry.entry_type)) entries.push(entry);
202
+ }
203
+ }
204
+ const spec = entries.find(entry => entry.entry_type === 'run.spawned')?.payload?.spec;
205
+ const step = spec?.steps?.[0];
206
+ const completed = entries.filter(entry => entry.entry_type === 'step.completed' && entry.step_id === claimed.id);
207
+ const terminalFacts = entries.filter(entry => entry.entry_type === 'run.completed');
208
+ if (spec?.name !== `${metadata.flowName}/${claimed.id}` || spec?.steps?.length !== 1
209
+ || step?.id !== claimed.id || completed.length !== 1
210
+ || completed[0]?.payload?.completionReason !== 'success'
211
+ || terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid();
212
+ if (claimed === terminal) {
213
+ const command = result.completionReason === 'needs_human'
214
+ ? `printf '%s' '{"completionReason":"needs_human"}'` : ':';
215
+ if (step?.type !== 'deterministic' || step.command !== command) invalid();
216
+ }
217
+ }
218
+ } finally { journal.close(); }
219
+ }
@@ -10,6 +10,7 @@ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
10
10
  import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
11
11
  import { readCompletedStepOutput } from './authored-step-output.js';
12
12
  import { withWorkerLease } from './worker-lease.js';
13
+ import { authoredChildAdmissionKey } from './authored-admission.js';
13
14
 
14
15
  export class PluginStepError extends AuthoredFlowExecutionError {
15
16
  constructor(readonly diagnostic: string, runId: string) {
@@ -24,12 +25,14 @@ export class PluginStepError extends AuthoredFlowExecutionError {
24
25
  export async function runPluginEffect(
25
26
  journal: JournalClient, flowName: string, id: string, plugin: LoadedPlugin, verb: PluginVerb,
26
27
  args: unknown, journalSteps: AuthoredFlowJournalStep[], budget: AuthoredBudget,
28
+ rootRunId?: string,
27
29
  ): Promise<unknown> {
28
30
  const server = verb.namespace;
29
31
  const tool = verb.method;
30
32
  const idempotencyKey = `plugin:${server}:${tool}:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`;
31
33
  const surfacePath = `/plugins/${pathPart(server)}/${pathPart(tool)}`;
32
- const stream = `plugin-worker-${randomUUID()}`;
34
+ const admissionKey = authoredChildAdmissionKey(rootRunId, id);
35
+ const stream = `plugin-worker-${admissionKey?.slice(-32) ?? randomUUID()}`;
33
36
  const instruction = JSON.stringify({ type: 'effect', server, tool, input: args });
34
37
  const peer = journal.createPeer();
35
38
  let diagnostic: string | undefined;
@@ -120,7 +123,7 @@ export async function runPluginEffect(
120
123
  childRunId = outcome.run_id;
121
124
  // run.start may answer after the deadline and the outer cleanup.
122
125
  if (dispatchExpired) await cancelChildRun();
123
- await completed;
126
+ if (outcome.status !== 'completed') await completed;
124
127
  if (diagnostic !== undefined) {
125
128
  try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
126
129
  catch (error) {
@@ -130,7 +133,7 @@ export async function runPluginEffect(
130
133
  }
131
134
  const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps) as { output: unknown };
132
135
  return receipt.output;
133
- }), deadline]);
136
+ }, admissionKey), deadline]);
134
137
  } finally {
135
138
  clearTimeout(timer);
136
139
  finishDeadline();