@relayflows/sdk 2.0.0
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.
- package/dist/authored-flow-error.d.ts +9 -0
- package/dist/authored-flow-error.d.ts.map +1 -0
- package/dist/authored-flow-error.js +13 -0
- package/dist/authored-flow-error.js.map +1 -0
- package/dist/authored-flow-executor.d.ts +28 -0
- package/dist/authored-flow-executor.d.ts.map +1 -0
- package/dist/authored-flow-executor.js +203 -0
- package/dist/authored-flow-executor.js.map +1 -0
- package/dist/authored-flow-lifecycle.d.ts +61 -0
- package/dist/authored-flow-lifecycle.d.ts.map +1 -0
- package/dist/authored-flow-lifecycle.js +284 -0
- package/dist/authored-flow-lifecycle.js.map +1 -0
- package/dist/authored-flow-loader.d.ts +7 -0
- package/dist/authored-flow-loader.d.ts.map +1 -0
- package/dist/authored-flow-loader.js +39 -0
- package/dist/authored-flow-loader.js.map +1 -0
- package/dist/authored-flow-operation.d.ts +43 -0
- package/dist/authored-flow-operation.d.ts.map +1 -0
- package/dist/authored-flow-operation.js +176 -0
- package/dist/authored-flow-operation.js.map +1 -0
- package/dist/authored-flow.d.ts +12 -0
- package/dist/authored-flow.d.ts.map +1 -0
- package/dist/authored-flow.js +13 -0
- package/dist/authored-flow.js.map +1 -0
- package/dist/authored-promise-graph.d.ts +67 -0
- package/dist/authored-promise-graph.d.ts.map +1 -0
- package/dist/authored-promise-graph.js +206 -0
- package/dist/authored-promise-graph.js.map +1 -0
- package/dist/backlog-picker.d.ts +55 -0
- package/dist/backlog-picker.d.ts.map +1 -0
- package/dist/backlog-picker.js +117 -0
- package/dist/backlog-picker.js.map +1 -0
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +55 -0
- package/dist/canonical.js.map +1 -0
- package/dist/cli/check.d.ts +30 -0
- package/dist/cli/check.d.ts.map +1 -0
- package/dist/cli/check.js +324 -0
- package/dist/cli/check.js.map +1 -0
- package/dist/cli/direct-run.d.ts +3 -0
- package/dist/cli/direct-run.d.ts.map +1 -0
- package/dist/cli/direct-run.js +69 -0
- package/dist/cli/direct-run.js.map +1 -0
- package/dist/cli/hn-monitor.d.ts +107 -0
- package/dist/cli/hn-monitor.d.ts.map +1 -0
- package/dist/cli/hn-monitor.js +220 -0
- package/dist/cli/hn-monitor.js.map +1 -0
- package/dist/cli/interruptible-sleep.d.ts +13 -0
- package/dist/cli/interruptible-sleep.d.ts.map +1 -0
- package/dist/cli/interruptible-sleep.js +31 -0
- package/dist/cli/interruptible-sleep.js.map +1 -0
- package/dist/cli/run.d.ts +54 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +316 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/tick-runner.d.ts +151 -0
- package/dist/cli/tick-runner.d.ts.map +1 -0
- package/dist/cli/tick-runner.js +258 -0
- package/dist/cli/tick-runner.js.map +1 -0
- package/dist/cli-adapter.d.ts +29 -0
- package/dist/cli-adapter.d.ts.map +1 -0
- package/dist/cli-adapter.js +92 -0
- package/dist/cli-adapter.js.map +1 -0
- package/dist/cli-executable.d.ts +3 -0
- package/dist/cli-executable.d.ts.map +1 -0
- package/dist/cli-executable.js +10 -0
- package/dist/cli-executable.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +348 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +42 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +515 -0
- package/dist/compile.js.map +1 -0
- package/dist/demo-hn-monitor.d.ts +2 -0
- package/dist/demo-hn-monitor.d.ts.map +1 -0
- package/dist/demo-hn-monitor.js +118 -0
- package/dist/demo-hn-monitor.js.map +1 -0
- package/dist/dir-watcher-poller.d.ts +73 -0
- package/dist/dir-watcher-poller.d.ts.map +1 -0
- package/dist/dir-watcher-poller.js +80 -0
- package/dist/dir-watcher-poller.js.map +1 -0
- package/dist/direct-input.d.ts +10 -0
- package/dist/direct-input.d.ts.map +1 -0
- package/dist/direct-input.js +61 -0
- package/dist/direct-input.js.map +1 -0
- package/dist/failure-kinds.d.ts +25 -0
- package/dist/failure-kinds.d.ts.map +1 -0
- package/dist/failure-kinds.js +64 -0
- package/dist/failure-kinds.js.map +1 -0
- package/dist/gate-contract.d.ts +24 -0
- package/dist/gate-contract.d.ts.map +1 -0
- package/dist/gate-contract.js +50 -0
- package/dist/gate-contract.js.map +1 -0
- package/dist/hn-poller.d.ts +50 -0
- package/dist/hn-poller.d.ts.map +1 -0
- package/dist/hn-poller.js +79 -0
- package/dist/hn-poller.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/journal-client.d.ts +122 -0
- package/dist/journal-client.d.ts.map +1 -0
- package/dist/journal-client.js +277 -0
- package/dist/journal-client.js.map +1 -0
- package/dist/json-schema-bound.d.ts +7 -0
- package/dist/json-schema-bound.d.ts.map +1 -0
- package/dist/json-schema-bound.js +353 -0
- package/dist/json-schema-bound.js.map +1 -0
- package/dist/json-schema.d.ts +5 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +72 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/json-value.d.ts +6 -0
- package/dist/json-value.d.ts.map +1 -0
- package/dist/json-value.js +94 -0
- package/dist/json-value.js.map +1 -0
- package/dist/model-name.d.ts +7 -0
- package/dist/model-name.d.ts.map +1 -0
- package/dist/model-name.js +21 -0
- package/dist/model-name.js.map +1 -0
- package/dist/output-schema.d.ts +8 -0
- package/dist/output-schema.d.ts.map +1 -0
- package/dist/output-schema.js +28 -0
- package/dist/output-schema.js.map +1 -0
- package/dist/preflight.d.ts +80 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +361 -0
- package/dist/preflight.js.map +1 -0
- package/dist/protocol.d.ts +359 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +15 -0
- package/dist/protocol.js.map +1 -0
- package/dist/spec.d.ts +294 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +13 -0
- package/dist/spec.js.map +1 -0
- package/dist/step-dependencies.d.ts +2 -0
- package/dist/step-dependencies.d.ts.map +1 -0
- package/dist/step-dependencies.js +88 -0
- package/dist/step-dependencies.js.map +1 -0
- package/dist/step-fields.d.ts +17 -0
- package/dist/step-fields.d.ts.map +1 -0
- package/dist/step-fields.js +32 -0
- package/dist/step-fields.js.map +1 -0
- package/dist/tick-source.d.ts +205 -0
- package/dist/tick-source.d.ts.map +1 -0
- package/dist/tick-source.js +234 -0
- package/dist/tick-source.js.map +1 -0
- package/dist/unknown-keys.d.ts +7 -0
- package/dist/unknown-keys.d.ts.map +1 -0
- package/dist/unknown-keys.js +47 -0
- package/dist/unknown-keys.js.map +1 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +458 -0
- package/dist/validate.js.map +1 -0
- package/dist/work-package-consumer.d.ts +33 -0
- package/dist/work-package-consumer.d.ts.map +1 -0
- package/dist/work-package-consumer.js +44 -0
- package/dist/work-package-consumer.js.map +1 -0
- package/dist/work-package-validator.d.ts +15 -0
- package/dist/work-package-validator.d.ts.map +1 -0
- package/dist/work-package-validator.js +72 -0
- package/dist/work-package-validator.js.map +1 -0
- package/dist/worker-cli.d.ts +16 -0
- package/dist/worker-cli.d.ts.map +1 -0
- package/dist/worker-cli.js +77 -0
- package/dist/worker-cli.js.map +1 -0
- package/dist/worker.d.ts +57 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +129 -0
- package/dist/worker.js.map +1 -0
- package/dist/wrapper-runtime.d.ts +10 -0
- package/dist/wrapper-runtime.d.ts.map +1 -0
- package/dist/wrapper-runtime.js +83 -0
- package/dist/wrapper-runtime.js.map +1 -0
- package/dist/wrapper-session.d.ts +16 -0
- package/dist/wrapper-session.d.ts.map +1 -0
- package/dist/wrapper-session.js +244 -0
- package/dist/wrapper-session.js.map +1 -0
- package/package.json +50 -0
- package/src/authored-flow-error.ts +31 -0
- package/src/authored-flow-executor.ts +365 -0
- package/src/authored-flow-lifecycle.ts +344 -0
- package/src/authored-flow-loader.ts +44 -0
- package/src/authored-flow-operation.ts +252 -0
- package/src/authored-flow.ts +21 -0
- package/src/authored-promise-graph.ts +196 -0
- package/src/backlog-picker.ts +153 -0
- package/src/canonical.ts +57 -0
- package/src/cli/check.ts +391 -0
- package/src/cli/direct-run.ts +86 -0
- package/src/cli/hn-monitor.ts +286 -0
- package/src/cli/interruptible-sleep.ts +27 -0
- package/src/cli/run.ts +439 -0
- package/src/cli/tick-runner.ts +378 -0
- package/src/cli-adapter.ts +115 -0
- package/src/cli-executable.ts +12 -0
- package/src/cli.ts +364 -0
- package/src/compile.ts +573 -0
- package/src/demo-hn-monitor.ts +132 -0
- package/src/dir-watcher-poller.ts +122 -0
- package/src/direct-input.ts +69 -0
- package/src/failure-kinds.ts +77 -0
- package/src/gate-contract.ts +72 -0
- package/src/hn-poller.ts +104 -0
- package/src/index.ts +195 -0
- package/src/journal-client.ts +379 -0
- package/src/json-schema-bound.ts +357 -0
- package/src/json-schema.ts +65 -0
- package/src/json-value.ts +110 -0
- package/src/model-name.ts +20 -0
- package/src/output-schema.ts +33 -0
- package/src/preflight.ts +482 -0
- package/src/protocol.ts +364 -0
- package/src/spec.ts +343 -0
- package/src/step-dependencies.ts +100 -0
- package/src/step-fields.ts +36 -0
- package/src/tick-source.ts +334 -0
- package/src/unknown-keys.ts +52 -0
- package/src/validate.ts +503 -0
- package/src/work-package-consumer.ts +73 -0
- package/src/work-package-validator.ts +90 -0
- package/src/worker-cli.ts +110 -0
- package/src/worker.ts +150 -0
- package/src/wrapper-runtime.ts +93 -0
- package/src/wrapper-session.ts +287 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
agentExecution,
|
|
4
|
+
cliAdapterKind,
|
|
5
|
+
type CliInvocation,
|
|
6
|
+
} from './cli-adapter.js';
|
|
7
|
+
import {
|
|
8
|
+
runWrapperSession,
|
|
9
|
+
type WrapperSessionLimits,
|
|
10
|
+
} from './wrapper-session.js';
|
|
11
|
+
import { wrapperEnvironment } from './wrapper-runtime.js';
|
|
12
|
+
|
|
13
|
+
/** Present only when a dispatched agent step carries a journaled wake context. */
|
|
14
|
+
export const WAKE_CONTEXT_ENV = 'RELAYFLOW_WAKE_CONTEXT';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Private wrapper model variable name. Ambient values are scrubbed; a wrapper
|
|
18
|
+
* may set it inside the already-identified process from the session request.
|
|
19
|
+
* Raw Claude/Codex adapters receive provider-native model flags.
|
|
20
|
+
*/
|
|
21
|
+
export const MODEL_ENV = 'RELAYFLOW_MODEL';
|
|
22
|
+
|
|
23
|
+
export interface WorkerCliResult {
|
|
24
|
+
exit_code: number | null;
|
|
25
|
+
stdout_tail: string;
|
|
26
|
+
stderr_tail: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runAgentCli(
|
|
30
|
+
cli: string,
|
|
31
|
+
instruction: string,
|
|
32
|
+
wakeContext: unknown,
|
|
33
|
+
model?: string,
|
|
34
|
+
wrapperLimits?: Partial<WrapperSessionLimits>,
|
|
35
|
+
): Promise<WorkerCliResult> {
|
|
36
|
+
const kind = cliAdapterKind(cli);
|
|
37
|
+
|
|
38
|
+
if (kind === 'relayflows-wrapper-v1') {
|
|
39
|
+
return runWrapperSession(
|
|
40
|
+
cli,
|
|
41
|
+
instruction,
|
|
42
|
+
wakeContext,
|
|
43
|
+
model,
|
|
44
|
+
wrapperEnvironment(process.env),
|
|
45
|
+
wrapperLimits,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
50
|
+
delete env[WAKE_CONTEXT_ENV];
|
|
51
|
+
delete env[MODEL_ENV];
|
|
52
|
+
const invocation = agentExecution(kind, instruction, model);
|
|
53
|
+
|
|
54
|
+
if (wakeContext !== undefined) {
|
|
55
|
+
try {
|
|
56
|
+
env[WAKE_CONTEXT_ENV] = JSON.stringify(wakeContext);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
exit_code: null,
|
|
60
|
+
stdout_tail: '',
|
|
61
|
+
stderr_tail: `wake_context could not be JSON-serialized for the CLI: ${String(error)}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (invocation.modelEnv !== undefined) env[MODEL_ENV] = invocation.modelEnv;
|
|
67
|
+
return spawnInvocation(cli, invocation, env);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function spawnInvocation(
|
|
71
|
+
cli: string,
|
|
72
|
+
invocation: CliInvocation,
|
|
73
|
+
env: NodeJS.ProcessEnv,
|
|
74
|
+
): Promise<WorkerCliResult> {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const child = spawn(cli, invocation.args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
77
|
+
const stdout: Buffer[] = [];
|
|
78
|
+
const stderr: Buffer[] = [];
|
|
79
|
+
let settled = false;
|
|
80
|
+
let timer: NodeJS.Timeout | undefined;
|
|
81
|
+
const finish = (result: WorkerCliResult): void => {
|
|
82
|
+
if (settled) return;
|
|
83
|
+
settled = true;
|
|
84
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
85
|
+
resolve(result);
|
|
86
|
+
};
|
|
87
|
+
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
|
88
|
+
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
|
89
|
+
child.once('error', (error) => finish({
|
|
90
|
+
exit_code: null,
|
|
91
|
+
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
92
|
+
stderr_tail: error.message,
|
|
93
|
+
}));
|
|
94
|
+
child.once('close', (code) => finish({
|
|
95
|
+
exit_code: code,
|
|
96
|
+
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
97
|
+
stderr_tail: Buffer.concat(stderr).toString('utf8'),
|
|
98
|
+
}));
|
|
99
|
+
if (invocation.timeoutMs > 0) {
|
|
100
|
+
timer = setTimeout(() => {
|
|
101
|
+
child.kill('SIGTERM');
|
|
102
|
+
finish({
|
|
103
|
+
exit_code: null,
|
|
104
|
+
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
105
|
+
stderr_tail: `CLI invocation timed out after ${invocation.timeoutMs}ms.`,
|
|
106
|
+
});
|
|
107
|
+
}, invocation.timeoutMs);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { JournalClient } from './journal-client.js';
|
|
3
|
+
import type { Pins, StepDispatchEvent } from './protocol.js';
|
|
4
|
+
import type { KernelAgentStep } from './spec.js';
|
|
5
|
+
import { runAgentCli } from './worker-cli.js';
|
|
6
|
+
|
|
7
|
+
export { MODEL_ENV, WAKE_CONTEXT_ENV } from './worker-cli.js';
|
|
8
|
+
|
|
9
|
+
export interface AgentWorkerOptions {
|
|
10
|
+
workerId: string;
|
|
11
|
+
pins: Pins;
|
|
12
|
+
capacity?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Executes dispatched agent steps using their declared CLI.
|
|
17
|
+
*
|
|
18
|
+
* Shutdown contract (close): async and drain-aware. The caller may await
|
|
19
|
+
* close() to guarantee every dispatch this worker started before close
|
|
20
|
+
* was called has either completed its stepComplete journal write or
|
|
21
|
+
* thrown out through the worker's `error` event. Dispatches that arrive
|
|
22
|
+
* AFTER close begins are ignored. Idempotent.
|
|
23
|
+
*
|
|
24
|
+
* Not implemented: releasing the worker registration with the kernel.
|
|
25
|
+
* `sdk/src/protocol.ts` has no `workerRelease` verb today, so on close()
|
|
26
|
+
* the kernel keeps this workerId in its registry until its lease expires.
|
|
27
|
+
* When workerRelease lands, add a client call at the top of close()
|
|
28
|
+
* (before the drain) so the kernel stops routing dispatches during
|
|
29
|
+
* shutdown.
|
|
30
|
+
*/
|
|
31
|
+
export class AgentWorker extends EventEmitter {
|
|
32
|
+
private attached = false;
|
|
33
|
+
private closing = false;
|
|
34
|
+
private readonly inFlight: Set<Promise<void>> = new Set();
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly client: JournalClient,
|
|
38
|
+
private readonly options: AgentWorkerOptions,
|
|
39
|
+
) {
|
|
40
|
+
super();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async attach(): Promise<void> {
|
|
44
|
+
if (this.attached) throw new Error('agent worker: already attached');
|
|
45
|
+
if (this.closing) throw new Error('agent worker: cannot attach a closed worker (construct a new one)');
|
|
46
|
+
this.client.on('step.dispatch', this.onDispatch);
|
|
47
|
+
try {
|
|
48
|
+
await this.client.workerAttach(
|
|
49
|
+
this.options.workerId,
|
|
50
|
+
['agent'],
|
|
51
|
+
this.options.pins,
|
|
52
|
+
this.options.capacity,
|
|
53
|
+
);
|
|
54
|
+
this.attached = true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.client.off('step.dispatch', this.onDispatch);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Async, drain-aware shutdown. Awaits every dispatch in-flight; ignores
|
|
63
|
+
* dispatches that arrive after close() begins. Idempotent.
|
|
64
|
+
*
|
|
65
|
+
* A prior synchronous close() did NOT drain, so a caller shutting
|
|
66
|
+
* mid-dispatch could silently lose an in-flight step's stepComplete
|
|
67
|
+
* journal write when the socket was immediately shut. This closes
|
|
68
|
+
* that hole.
|
|
69
|
+
*/
|
|
70
|
+
async close(): Promise<void> {
|
|
71
|
+
if (this.closing) return;
|
|
72
|
+
this.closing = true;
|
|
73
|
+
this.client.off('step.dispatch', this.onDispatch);
|
|
74
|
+
const pending = Array.from(this.inFlight);
|
|
75
|
+
if (pending.length > 0) {
|
|
76
|
+
await Promise.allSettled(pending);
|
|
77
|
+
}
|
|
78
|
+
this.attached = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private readonly onDispatch = (dispatch: StepDispatchEvent): void => {
|
|
82
|
+
if (this.closing) return;
|
|
83
|
+
if (dispatch.step_type !== 'agent') return;
|
|
84
|
+
const running: Promise<void> = this.execute(dispatch).catch((error: unknown) => {
|
|
85
|
+
this.emit('error', error);
|
|
86
|
+
});
|
|
87
|
+
this.inFlight.add(running);
|
|
88
|
+
void running.finally(() => { this.inFlight.delete(running); });
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
private async execute(dispatch: StepDispatchEvent): Promise<void> {
|
|
92
|
+
const spec = dispatch.spec as Partial<KernelAgentStep>;
|
|
93
|
+
const result = typeof spec.cli === 'string' && typeof spec.instruction === 'string'
|
|
94
|
+
? await runAgentCli(spec.cli, spec.instruction, dispatch.wake_context, spec.model)
|
|
95
|
+
: { exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' };
|
|
96
|
+
const completionReason = result.exit_code === 0 ? 'success' : 'worker_error';
|
|
97
|
+
|
|
98
|
+
// Output shape: if the CLI's stdout parses as JSON, promote THAT
|
|
99
|
+
// as the step's `output` value so `json_schema` verification
|
|
100
|
+
// validates the analysis payload, not a wrapper around stdout.
|
|
101
|
+
// On the JSON path the CliResult (exit_code / stdout_tail /
|
|
102
|
+
// stderr_tail) is DISCARDED from `output` — the schema author
|
|
103
|
+
// wrote a shape for the analysis, not for the process wrapper.
|
|
104
|
+
// Non-JSON stdout falls back to the wrapper so text-emitting
|
|
105
|
+
// tools still round-trip usefully.
|
|
106
|
+
//
|
|
107
|
+
// Implicit contract: CLIs signal errors via non-zero exit, not by
|
|
108
|
+
// emitting an error JSON with exit 0. `completionReason` is
|
|
109
|
+
// derived from exit code, so a CLI that exits 0 while emitting
|
|
110
|
+
// `{"error":...}` will report success with an error payload.
|
|
111
|
+
const output = parseJsonOutput(result.stdout_tail) ?? result;
|
|
112
|
+
|
|
113
|
+
await this.client.stepComplete(
|
|
114
|
+
dispatch.run_id,
|
|
115
|
+
dispatch.step_id,
|
|
116
|
+
dispatch.attempt,
|
|
117
|
+
dispatch.idempotency_key,
|
|
118
|
+
completionReason,
|
|
119
|
+
{
|
|
120
|
+
output,
|
|
121
|
+
started_pins: dispatch.pins,
|
|
122
|
+
end_pins: dispatch.pins,
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Return an object-shaped JSON payload parsed from `stdout`, or
|
|
130
|
+
* `null` when stdout is empty, non-JSON, or JSON-of-a-scalar/array.
|
|
131
|
+
* The object-only restriction matches how `json_schema` gates are
|
|
132
|
+
* authored — a scalar or array sneaking through would confuse both
|
|
133
|
+
* the schema and downstream readers who expect field lookups on
|
|
134
|
+
* `output`. Text-emitting tools and non-object JSON both fall
|
|
135
|
+
* through to the CliResult wrapper preserved by the caller.
|
|
136
|
+
*/
|
|
137
|
+
export function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
|
138
|
+
const trimmed = stdout.trim();
|
|
139
|
+
if (trimmed === '') return null;
|
|
140
|
+
let parsed: unknown;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(trimmed);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return parsed as Record<string, unknown>;
|
|
150
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const WRAPPER_ENV_ALLOWLIST = [
|
|
5
|
+
'PATH',
|
|
6
|
+
'HOME',
|
|
7
|
+
'TMPDIR',
|
|
8
|
+
'TMP',
|
|
9
|
+
'TEMP',
|
|
10
|
+
'SHELL',
|
|
11
|
+
'LANG',
|
|
12
|
+
'LC_ALL',
|
|
13
|
+
'LC_CTYPE',
|
|
14
|
+
'TZ',
|
|
15
|
+
'USER',
|
|
16
|
+
'LOGNAME',
|
|
17
|
+
'SystemRoot',
|
|
18
|
+
'ComSpec',
|
|
19
|
+
'PATHEXT',
|
|
20
|
+
'WINDIR',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export interface WrapperIdentity {
|
|
24
|
+
executable: string;
|
|
25
|
+
fingerprint: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build a wrapper environment from a closed list; ambient credentials never cross. */
|
|
29
|
+
export function wrapperEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
30
|
+
const env: NodeJS.ProcessEnv = {};
|
|
31
|
+
for (const name of WRAPPER_ENV_ALLOWLIST) {
|
|
32
|
+
const value = source[name];
|
|
33
|
+
if (value !== undefined) env[name] = value;
|
|
34
|
+
}
|
|
35
|
+
return env;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolve and fingerprint the exact executable that will enter the private protocol. */
|
|
39
|
+
export function captureWrapperIdentity(
|
|
40
|
+
cli: string,
|
|
41
|
+
env: NodeJS.ProcessEnv,
|
|
42
|
+
): WrapperIdentity {
|
|
43
|
+
const executable = realpathSync(resolveWrapperExecutable(cli, env));
|
|
44
|
+
accessSync(executable, constants.X_OK);
|
|
45
|
+
const stat = statSync(executable, { bigint: true });
|
|
46
|
+
if (!stat.isFile()) throw new Error('resolved wrapper is not a regular file');
|
|
47
|
+
return {
|
|
48
|
+
executable,
|
|
49
|
+
fingerprint: [
|
|
50
|
+
stat.dev,
|
|
51
|
+
stat.ino,
|
|
52
|
+
stat.mode,
|
|
53
|
+
stat.size,
|
|
54
|
+
stat.mtimeNs,
|
|
55
|
+
stat.ctimeNs,
|
|
56
|
+
].join(':'),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function sameWrapperIdentity(
|
|
61
|
+
cli: string,
|
|
62
|
+
env: NodeJS.ProcessEnv,
|
|
63
|
+
expected: WrapperIdentity,
|
|
64
|
+
): boolean {
|
|
65
|
+
try {
|
|
66
|
+
const current = captureWrapperIdentity(cli, env);
|
|
67
|
+
return current.executable === expected.executable
|
|
68
|
+
&& current.fingerprint === expected.fingerprint;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveWrapperExecutable(cli: string, env: NodeJS.ProcessEnv): string {
|
|
75
|
+
if (isAbsolute(cli) || cli.includes('/') || cli.includes('\\')) return resolve(cli);
|
|
76
|
+
const path = env.PATH;
|
|
77
|
+
if (path === undefined) throw new Error('PATH is unavailable while resolving the wrapper');
|
|
78
|
+
const extensions = process.platform === 'win32'
|
|
79
|
+
? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';')
|
|
80
|
+
: [''];
|
|
81
|
+
for (const directory of path.split(delimiter)) {
|
|
82
|
+
for (const extension of extensions) {
|
|
83
|
+
const candidate = join(directory || '.', `${cli}${extension}`);
|
|
84
|
+
try {
|
|
85
|
+
accessSync(candidate, constants.X_OK);
|
|
86
|
+
return candidate;
|
|
87
|
+
} catch {
|
|
88
|
+
// Continue through the deterministic PATH candidate list.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`wrapper executable ${JSON.stringify(cli)} was not found on PATH`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
WRAPPER_EXECUTE_TOKEN,
|
|
4
|
+
WRAPPER_IDENTIFY_ARG,
|
|
5
|
+
WRAPPER_IDENTIFY_TOKEN,
|
|
6
|
+
} from './cli-adapter.js';
|
|
7
|
+
import {
|
|
8
|
+
captureWrapperIdentity,
|
|
9
|
+
sameWrapperIdentity,
|
|
10
|
+
type WrapperIdentity,
|
|
11
|
+
} from './wrapper-runtime.js';
|
|
12
|
+
|
|
13
|
+
export interface WrapperSessionLimits {
|
|
14
|
+
handshakeTimeoutMs: number;
|
|
15
|
+
executionTimeoutMs: number;
|
|
16
|
+
maxOutputBytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WrapperSessionResult {
|
|
20
|
+
exit_code: number | null;
|
|
21
|
+
stdout_tail: string;
|
|
22
|
+
stderr_tail: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_LIMITS: WrapperSessionLimits = {
|
|
26
|
+
handshakeTimeoutMs: 10_000,
|
|
27
|
+
executionTimeoutMs: 300_000,
|
|
28
|
+
maxOutputBytes: 1_048_576,
|
|
29
|
+
};
|
|
30
|
+
const HANDSHAKE_OUTPUT_LIMIT = 8_192;
|
|
31
|
+
const FORCE_KILL_DELAY_MS = 1_000;
|
|
32
|
+
/**
|
|
33
|
+
* Grace after `SIGKILL` before the reader settles on its own. Node emits
|
|
34
|
+
* `'close'` only once every inherited stdio pipe is closed, which any
|
|
35
|
+
* descendant of the wrapper can withhold forever. Resolution therefore may
|
|
36
|
+
* not depend on `'close'`: past this point the reader settles regardless.
|
|
37
|
+
*/
|
|
38
|
+
const SETTLE_AFTER_KILL_MS = 250;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Identify and execute a custom wrapper in one pinned process. No private
|
|
42
|
+
* request is written until its declared path still names the captured file.
|
|
43
|
+
*/
|
|
44
|
+
export function runWrapperSession(
|
|
45
|
+
cli: string,
|
|
46
|
+
instruction: string,
|
|
47
|
+
wakeContext: unknown,
|
|
48
|
+
model: string | undefined,
|
|
49
|
+
env: NodeJS.ProcessEnv,
|
|
50
|
+
overrides: Partial<WrapperSessionLimits> = {},
|
|
51
|
+
): Promise<WrapperSessionResult> {
|
|
52
|
+
const limits = sessionLimits(overrides);
|
|
53
|
+
let request: string;
|
|
54
|
+
try {
|
|
55
|
+
request = JSON.stringify({
|
|
56
|
+
protocol: WRAPPER_IDENTIFY_TOKEN,
|
|
57
|
+
instruction,
|
|
58
|
+
...(model !== undefined ? { model } : {}),
|
|
59
|
+
...(wakeContext !== undefined ? { wakeContext } : {}),
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return Promise.resolve(failure(
|
|
63
|
+
`wake_context could not be JSON-serialized for the CLI: ${String(error)}`,
|
|
64
|
+
));
|
|
65
|
+
}
|
|
66
|
+
let identity: WrapperIdentity;
|
|
67
|
+
try {
|
|
68
|
+
identity = captureWrapperIdentity(cli, env);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return Promise.resolve(failure(
|
|
71
|
+
`CLI ${JSON.stringify(cli)} wrapper identity could not be pinned: ${String(error)}`,
|
|
72
|
+
));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return executePinnedWrapper(cli, identity, request, env, limits);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function executePinnedWrapper(
|
|
79
|
+
cli: string,
|
|
80
|
+
identity: WrapperIdentity,
|
|
81
|
+
request: string,
|
|
82
|
+
env: NodeJS.ProcessEnv,
|
|
83
|
+
limits: WrapperSessionLimits,
|
|
84
|
+
): Promise<WrapperSessionResult> {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const child = spawn(identity.executable, [WRAPPER_IDENTIFY_ARG], {
|
|
87
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
88
|
+
env,
|
|
89
|
+
});
|
|
90
|
+
const stdout: string[] = [];
|
|
91
|
+
const stderr: Buffer[] = [];
|
|
92
|
+
let handshakePending = '';
|
|
93
|
+
let executionPending = '';
|
|
94
|
+
let capturedBytes = 0;
|
|
95
|
+
let phase: 'identity' | 'ack' | 'execute' = 'identity';
|
|
96
|
+
let protocolError: string | undefined;
|
|
97
|
+
let settled = false;
|
|
98
|
+
let lifecycleTimer: NodeJS.Timeout | undefined;
|
|
99
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
100
|
+
let settleTimer: NodeJS.Timeout | undefined;
|
|
101
|
+
|
|
102
|
+
const clearTimers = (): void => {
|
|
103
|
+
if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
|
|
104
|
+
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
105
|
+
if (settleTimer !== undefined) clearTimeout(settleTimer);
|
|
106
|
+
};
|
|
107
|
+
const finish = (result: WrapperSessionResult): void => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
settled = true;
|
|
110
|
+
clearTimers();
|
|
111
|
+
resolve(result);
|
|
112
|
+
};
|
|
113
|
+
const terminate = (message: string): void => {
|
|
114
|
+
if (protocolError !== undefined) return;
|
|
115
|
+
protocolError = message;
|
|
116
|
+
if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
|
|
117
|
+
child.kill('SIGTERM');
|
|
118
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), FORCE_KILL_DELAY_MS);
|
|
119
|
+
killTimer.unref();
|
|
120
|
+
// The reader owns the bound. `'close'` is emitted only after every
|
|
121
|
+
// inherited stdio pipe closes, so a wrapper that leaves a descendant
|
|
122
|
+
// holding one withholds it forever and strands the step with no
|
|
123
|
+
// `completionReason` at all. Settle on our own deadline instead — the
|
|
124
|
+
// same shape `spawnInvocation` uses in worker-cli.ts, where the timer
|
|
125
|
+
// resolves rather than delegating to a child-controlled event. The
|
|
126
|
+
// result is byte-identical to the one the `'close'` path would build
|
|
127
|
+
// for this `protocolError`, so this changes only WHEN we settle.
|
|
128
|
+
settleTimer = setTimeout(
|
|
129
|
+
() => finish(failure(message)),
|
|
130
|
+
FORCE_KILL_DELAY_MS + SETTLE_AFTER_KILL_MS,
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
const startExecutionTimer = (): void => {
|
|
134
|
+
if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
|
|
135
|
+
lifecycleTimer = setTimeout(() => terminate(
|
|
136
|
+
`CLI ${JSON.stringify(cli)} execution timed out after ${limits.executionTimeoutMs}ms.`,
|
|
137
|
+
), limits.executionTimeoutMs);
|
|
138
|
+
};
|
|
139
|
+
const exceedsOutputLimit = (additionalBytes: number): boolean => {
|
|
140
|
+
const pendingBytes = Buffer.byteLength(executionPending);
|
|
141
|
+
if (capturedBytes + pendingBytes + additionalBytes <= limits.maxOutputBytes) return false;
|
|
142
|
+
terminate(
|
|
143
|
+
`CLI ${JSON.stringify(cli)} exceeded the captured output limit of ${limits.maxOutputBytes} bytes.`,
|
|
144
|
+
);
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
const appendStdout = (value: string): void => {
|
|
148
|
+
const bytes = Buffer.byteLength(value);
|
|
149
|
+
capturedBytes += bytes;
|
|
150
|
+
stdout.push(value);
|
|
151
|
+
};
|
|
152
|
+
const acceptHandshakeLine = (line: string): void => {
|
|
153
|
+
const normalized = normalizeLine(line);
|
|
154
|
+
if (phase === 'identity') {
|
|
155
|
+
if (normalized !== WRAPPER_IDENTIFY_TOKEN) {
|
|
156
|
+
terminate(`CLI ${JSON.stringify(cli)} did not identify as ${WRAPPER_IDENTIFY_TOKEN} at worker execution.`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!sameWrapperIdentity(cli, env, identity)) {
|
|
160
|
+
terminate(`CLI ${JSON.stringify(cli)} wrapper identity changed before private request delivery.`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
phase = 'ack';
|
|
164
|
+
child.stdin.end(`${request}\n`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (phase === 'ack') {
|
|
168
|
+
if (normalized !== WRAPPER_EXECUTE_TOKEN) {
|
|
169
|
+
terminate(`CLI ${JSON.stringify(cli)} did not accept the ${WRAPPER_IDENTIFY_TOKEN} same-process execution request.`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
phase = 'execute';
|
|
173
|
+
startExecutionTimer();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const acceptExecutionData = (chunk: string): void => {
|
|
177
|
+
executionPending += chunk;
|
|
178
|
+
if (exceedsOutputLimit(0)) return;
|
|
179
|
+
while (true) {
|
|
180
|
+
const newline = executionPending.indexOf('\n');
|
|
181
|
+
if (newline < 0) return;
|
|
182
|
+
const line = executionPending.slice(0, newline);
|
|
183
|
+
executionPending = executionPending.slice(newline + 1);
|
|
184
|
+
if (normalizeLine(line) === WRAPPER_EXECUTE_TOKEN) {
|
|
185
|
+
terminate(`CLI ${JSON.stringify(cli)} emitted a duplicate execute protocol frame.`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
appendStdout(`${line}\n`);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const handshakeComplete = (): boolean => phase === 'execute';
|
|
192
|
+
|
|
193
|
+
lifecycleTimer = setTimeout(() => terminate(
|
|
194
|
+
`CLI ${JSON.stringify(cli)} did not identify as ${WRAPPER_IDENTIFY_TOKEN} within ${limits.handshakeTimeoutMs}ms.`,
|
|
195
|
+
), limits.handshakeTimeoutMs);
|
|
196
|
+
|
|
197
|
+
child.stdout.setEncoding('utf8');
|
|
198
|
+
child.stdout.on('data', (chunk: string) => {
|
|
199
|
+
if (protocolError !== undefined) return;
|
|
200
|
+
if (phase === 'execute') {
|
|
201
|
+
acceptExecutionData(chunk);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
handshakePending += chunk;
|
|
205
|
+
// Drain every complete line FIRST. The handshake bound belongs to the
|
|
206
|
+
// handshake, and a line that is already terminated is no longer part
|
|
207
|
+
// of it: the execute token sitting at the front of this buffer ends
|
|
208
|
+
// the handshake, and the bytes behind it are execution output bound by
|
|
209
|
+
// `maxOutputBytes`. Measuring the whole buffer before draining capped a
|
|
210
|
+
// conforming wrapper's first result flush at HANDSHAKE_OUTPUT_LIMIT
|
|
211
|
+
// whenever the OS coalesced its writes, and named the wrong bound.
|
|
212
|
+
while (!handshakeComplete()) {
|
|
213
|
+
const newline = handshakePending.indexOf('\n');
|
|
214
|
+
if (newline < 0) break;
|
|
215
|
+
const line = handshakePending.slice(0, newline);
|
|
216
|
+
handshakePending = handshakePending.slice(newline + 1);
|
|
217
|
+
acceptHandshakeLine(line);
|
|
218
|
+
if (protocolError !== undefined) return;
|
|
219
|
+
}
|
|
220
|
+
if (handshakeComplete()) {
|
|
221
|
+
if (handshakePending.length > 0) {
|
|
222
|
+
const remainder = handshakePending;
|
|
223
|
+
handshakePending = '';
|
|
224
|
+
acceptExecutionData(remainder);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Still handshaking: bound the un-terminated residue, which is the only
|
|
229
|
+
// buffer the handshake still owns.
|
|
230
|
+
if (Buffer.byteLength(handshakePending) > HANDSHAKE_OUTPUT_LIMIT) {
|
|
231
|
+
terminate(
|
|
232
|
+
`CLI ${JSON.stringify(cli)} exceeded the wrapper handshake limit of ${HANDSHAKE_OUTPUT_LIMIT} bytes before completing the ${WRAPPER_IDENTIFY_TOKEN} handshake.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
237
|
+
if (protocolError !== undefined || exceedsOutputLimit(chunk.byteLength)) return;
|
|
238
|
+
capturedBytes += chunk.byteLength;
|
|
239
|
+
stderr.push(chunk);
|
|
240
|
+
});
|
|
241
|
+
child.stdin.on('error', () => {
|
|
242
|
+
// A child that closes stdin before acknowledgement is classified on close.
|
|
243
|
+
});
|
|
244
|
+
child.once('error', (error) => finish(failure(error.message)));
|
|
245
|
+
child.once('close', (code) => {
|
|
246
|
+
if (protocolError === undefined && phase === 'execute' && executionPending.length > 0) {
|
|
247
|
+
if (normalizeLine(executionPending) === WRAPPER_EXECUTE_TOKEN) {
|
|
248
|
+
protocolError = `CLI ${JSON.stringify(cli)} emitted a duplicate execute protocol frame.`;
|
|
249
|
+
} else if (!exceedsOutputLimit(0)) {
|
|
250
|
+
appendStdout(executionPending);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (protocolError !== undefined || phase !== 'execute') {
|
|
254
|
+
finish(failure(
|
|
255
|
+
protocolError
|
|
256
|
+
?? `CLI ${JSON.stringify(cli)} exited before completing the ${WRAPPER_IDENTIFY_TOKEN} same-process handshake.`,
|
|
257
|
+
));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
finish({
|
|
261
|
+
exit_code: code,
|
|
262
|
+
stdout_tail: stdout.join(''),
|
|
263
|
+
stderr_tail: Buffer.concat(stderr).toString('utf8'),
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sessionLimits(overrides: Partial<WrapperSessionLimits>): WrapperSessionLimits {
|
|
270
|
+
return {
|
|
271
|
+
handshakeTimeoutMs: positiveLimit(overrides.handshakeTimeoutMs, DEFAULT_LIMITS.handshakeTimeoutMs),
|
|
272
|
+
executionTimeoutMs: positiveLimit(overrides.executionTimeoutMs, DEFAULT_LIMITS.executionTimeoutMs),
|
|
273
|
+
maxOutputBytes: positiveLimit(overrides.maxOutputBytes, DEFAULT_LIMITS.maxOutputBytes),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function positiveLimit(value: number | undefined, fallback: number): number {
|
|
278
|
+
return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function normalizeLine(line: string): string {
|
|
282
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function failure(stderr_tail: string): WrapperSessionResult {
|
|
286
|
+
return { exit_code: null, stdout_tail: '', stderr_tail };
|
|
287
|
+
}
|