diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 450cef82..92b2d1d2 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -68,7 +68,8 @@ import { injectClaudeAgentRelayMcpConfig, injectCodexSubcommandArgs, resolveAgentRelayBrokerMcpArgs, - resolveRelayMcpFromEnv as resolveRelayMcpFromEnvShared + resolveRelayMcpFromEnv as resolveRelayMcpFromEnvShared, + spawnNonInteractiveAndCapture } from '@agentworkforce/runtime'; import { listBuiltInPersonas, @@ -4502,50 +4503,16 @@ async function runPersonaImprover(args: { const timeoutMs = selection.harnessSettings.timeoutSeconds ? selection.harnessSettings.timeoutSeconds * 1000 : undefined; - let captureResult: { exitCode: number | null; stderr: string }; + let captureResult: { exitCode: number; stderr: string }; try { - captureResult = await new Promise<{ exitCode: number | null; stderr: string }>( - (resolveResult) => { - const child = spawn(spec.bin, [...spec.args], { - cwd, - env: childEnv, - stdio: ['ignore', 'pipe', 'pipe'], - shell: false - }); - let stderrBuf = ''; - let forceKillTimeout: NodeJS.Timeout | undefined; - child.stdout?.setEncoding('utf8'); - child.stderr?.setEncoding('utf8'); - child.stderr?.on('data', (chunk: string) => { - stderrBuf += chunk; - }); - // SIGTERM first; if the harness traps or ignores it, escalate to - // SIGKILL after a 1s grace so the timeout is actually enforced. - const timeout = - timeoutMs !== undefined - ? setTimeout(() => { - child.kill('SIGTERM'); - forceKillTimeout = setTimeout(() => { - if (!child.killed) child.kill('SIGKILL'); - }, 1000); - }, timeoutMs) - : undefined; - const clearTimers = () => { - if (timeout) clearTimeout(timeout); - if (forceKillTimeout) clearTimeout(forceKillTimeout); - }; - child.on('error', (err) => { - clearTimers(); - resolveResult({ exitCode: 1, stderr: `${stderrBuf}${err.message}\n` }); - }); - child.on('close', (code, signal) => { - clearTimers(); - const exitCode = - typeof code === 'number' ? code : signal ? signalExitCode(signal) : null; - resolveResult({ exitCode, stderr: stderrBuf }); - }); - } - ); + captureResult = await spawnNonInteractiveAndCapture({ + bin: spec.bin, + args: [...spec.args], + prompt: spec.prompt, + cwd, + env: childEnv, + ...(timeoutMs !== undefined ? { timeoutMs } : {}) + }); } finally { // Always restore — a synchronous spawn() throw or unexpected promise // rejection must not leave orphaned `opencode.json` (or any other @@ -4554,7 +4521,7 @@ async function runPersonaImprover(args: { } if (captureResult.exitCode !== 0) { throw new Error( - `improver exited with code=${captureResult.exitCode ?? 'null'}.${captureResult.stderr ? ` stderr: ${captureResult.stderr.slice(0, 400)}` : ''}` + `improver exited with code=${captureResult.exitCode}.${captureResult.stderr ? ` stderr: ${captureResult.stderr.slice(0, 400)}` : ''}` ); } let raw: string; diff --git a/packages/persona-kit/CHANGELOG.md b/packages/persona-kit/CHANGELOG.md index 9d6f3814..2be62baf 100644 --- a/packages/persona-kit/CHANGELOG.md +++ b/packages/persona-kit/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking Changes + +- `buildNonInteractiveSpec` no longer includes the one-shot task in `args`. + Consumers must deliver the new required `prompt` descriptor through stdin or + a prompt file as directed; spawning only `bin` and `args` now omits the task. + +### Fixed + +- Keep large one-shot harness prompts off argv to avoid Linux + `MAX_ARG_STRLEN`/`E2BIG` failures. + ## [4.1.23] - 2026-07-15 ### Added diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index fd0dad9b..e35bfc06 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -227,6 +227,7 @@ export { type BuildInteractiveSpecInput, type InteractiveConfigFile, type InteractiveSpec, + type NonInteractivePrompt, type NonInteractiveSpec, type RelayMcpConfig, resolvePersonaRelayMcp, diff --git a/packages/persona-kit/src/interactive-spec.test.ts b/packages/persona-kit/src/interactive-spec.test.ts index ed473868..e99123fc 100644 --- a/packages/persona-kit/src/interactive-spec.test.ts +++ b/packages/persona-kit/src/interactive-spec.test.ts @@ -343,9 +343,9 @@ test('opencode non-interactive spec omits cwd/model flags and normalizes the age '--format', 'default', '--title', - 'daily-ship', - 'say pong' + 'daily-ship' ]); + assert.deepEqual(result.prompt, { mode: 'stdin', contents: 'say pong' }); assert.ok(!result.args.includes('--dir')); assert.ok(!result.args.includes('--model')); @@ -413,7 +413,7 @@ test('grok warns for unsupported permission fields', () => { ]); }); -test('grok non-interactive spec uses single-shot mode with cwd and always-approve', () => { +test('grok non-interactive spec uses a prompt file with cwd and always-approve', () => { const result = buildNonInteractiveSpec({ harness: 'grok', personaId: 'daily-ship', @@ -432,10 +432,13 @@ test('grok non-interactive spec uses single-shot mode with cwd and always-approv 'plain', '--cwd', '/tmp/project', - '--always-approve', - '--single', - 'Reply pong.\n\nUser task:\nsay pong' + '--always-approve' ]); + assert.deepEqual(result.prompt, { + mode: 'file', + contents: 'Reply pong.\n\nUser task:\nsay pong', + flag: '--prompt-file' + }); assert.deepEqual(result.configFiles, [ { path: 'AGENTS.md', contents: 'Reply pong.\n' } ]); @@ -458,10 +461,13 @@ test('grok non-interactive spec still adds always-approve for unsupported permis 'grok-build-0.1', '--output-format', 'plain', - '--always-approve', - '--single', - 'say pong' + '--always-approve' ]); + assert.deepEqual(result.prompt, { + mode: 'file', + contents: 'say pong', + flag: '--prompt-file' + }); assert.deepEqual(result.warnings, [ 'persona declares permissions.mode "plan" but the grok harness only supports bypassPermissions via --always-approve; proceeding with Grok defaults.' ]); @@ -534,7 +540,44 @@ test('claude non-interactive spec omits unsupported --name while preserving run assert.equal(args[outputIdx + 1], 'text'); assert.ok(promptIdx >= 0); assert.equal(args[promptIdx + 1], 'Reply pong.'); - assert.equal(args[args.length - 1], 'say pong'); + assert.ok(!args.includes('say pong')); + assert.deepEqual(result.prompt, { mode: 'stdin', contents: 'say pong' }); +}); + +test('non-interactive tasks stay off argv and argv remains below Linux per-argument limits', () => { + const marker = 'TASK_CANARY_d7e9b9b0'; + const task = `${marker}\n${'x'.repeat(200_000)}`; + const cases = [ + { harness: 'claude' as const, model: 'claude-sonnet-4-6', promptMode: 'stdin' }, + { harness: 'codex' as const, model: 'openai-codex/gpt-5.3-codex', promptMode: 'stdin' }, + { harness: 'opencode' as const, model: 'opencode/minimax-m2.5', promptMode: 'stdin' }, + { harness: 'grok' as const, model: 'grok-build-0.1', promptMode: 'file' } + ]; + + for (const entry of cases) { + const result = buildNonInteractiveSpec({ + harness: entry.harness, + personaId: 'argv-budget-test', + model: entry.model, + systemPrompt: 'short system prompt', + task, + name: 'argv-budget-test', + workingDirectory: '/tmp/project' + }); + const argvBytes = [result.bin, ...result.args].reduce( + (total, arg) => total + Buffer.byteLength(arg) + 1, + 0 + ); + const largestArgBytes = Math.max(...result.args.map((arg) => Buffer.byteLength(arg))); + + assert.equal(result.prompt.mode, entry.promptMode, entry.harness); + assert.ok(result.prompt.contents.includes(marker), entry.harness); + assert.ok(result.args.every((arg) => !arg.includes(marker)), entry.harness); + // Linux rejects any single argv element at 128 KiB (MAX_ARG_STRLEN), + // independently of the larger combined argv + env budget. + assert.ok(largestArgBytes < 128 * 1024, `${entry.harness}: largest arg=${largestArgBytes}`); + assert.ok(argvBytes < 128 * 1024, `${entry.harness}: argv total=${argvBytes}`); + } }); test('opencode omits agent.prompt when systemPrompt is empty', () => { diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index facc5c5f..41c863d0 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -662,11 +662,24 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact } } +/** Off-argv delivery for a one-shot harness prompt. Keeping the potentially + * large per-run task out of `args` avoids Linux's 128 KiB single-argument + * limit as well as the combined argv + env limit. */ +export type NonInteractivePrompt = + | { readonly mode: 'stdin'; readonly contents: string } + | { + readonly mode: 'file'; + readonly contents: string; + readonly flag: '--prompt-file'; + }; + /** Result of translating a persona's runtime into a one-shot, non-interactive - * spawnable command. Caller writes `configFiles` before spawning. */ + * spawnable command. Caller writes `configFiles` and delivers `prompt` using + * its declared off-argv mode before spawning. */ export interface NonInteractiveSpec { bin: string; args: readonly string[]; + prompt: NonInteractivePrompt; configFiles: readonly InteractiveConfigFile[]; warnings: readonly string[]; } @@ -674,16 +687,17 @@ export interface NonInteractiveSpec { /** * Translate a persona's runtime into a non-interactive, one-shot command. * Layers harness-specific non-interactive flags on top of {@link buildInteractiveSpec}, - * then appends the user task. Pure — no I/O. + * then declares how the caller must deliver the user task off argv. Pure — no I/O. * - * - `claude`: appends `--print --output-format text `. - * - `codex`: prefixes `exec`, appends `--skip-git-repo-check`, then a prompt - * built from any `initialPrompt` joined with the user task. + * - `claude`: appends `--print --output-format text`; task is written to stdin. + * - `codex`: prefixes `exec`, appends `--skip-git-repo-check -`; stdin receives + * a prompt built from any `initialPrompt` joined with the user task. * - `opencode`: prefixes `run`, appends `--format default - * [--title ] `; model selection stays in the generated agent config. + * [--title ]`; task is written to stdin and model selection stays in the + * generated agent config. * - `grok`: appends `--output-format plain [--cwd ] --always-approve - * --single `, where prompt includes the persona system prompt plus - * the one-shot task. + * --prompt-file ` at spawn time, where the temporary file contains + * the persona system prompt plus the one-shot task. */ export function buildNonInteractiveSpec( input: BuildInteractiveSpecInput & { @@ -696,10 +710,10 @@ export function buildNonInteractiveSpec( switch (input.harness) { case 'claude': { const args = [...interactive.args, '--print', '--output-format', 'text']; - args.push(input.task); return { bin: interactive.bin, args, + prompt: { mode: 'stdin', contents: input.task }, configFiles: interactive.configFiles, warnings: interactive.warnings }; @@ -710,7 +724,8 @@ export function buildNonInteractiveSpec( : input.task; return { bin: interactive.bin, - args: ['exec', ...interactive.args, '--skip-git-repo-check', prompt], + args: ['exec', ...interactive.args, '--skip-git-repo-check', '-'], + prompt: { mode: 'stdin', contents: prompt }, configFiles: interactive.configFiles, warnings: interactive.warnings }; @@ -723,10 +738,10 @@ export function buildNonInteractiveSpec( // separately, and `opencode run` does not support `--dir`. const args = ['run', ...interactive.args, '--format', 'default']; if (input.name) args.push('--title', input.name); - args.push(input.task); return { bin: interactive.bin, args, + prompt: { mode: 'stdin', contents: input.task }, configFiles: interactive.configFiles, warnings: interactive.warnings }; @@ -740,10 +755,10 @@ export function buildNonInteractiveSpec( if (!args.includes('--always-approve')) { args.push('--always-approve'); } - args.push('--single', prompt); return { bin: interactive.bin, args, + prompt: { mode: 'file', contents: prompt, flag: '--prompt-file' }, configFiles: interactive.configFiles, warnings: interactive.warnings }; diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index afa3e869..287b33fb 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Deliver one-shot Claude, Codex, and OpenCode prompts through stdin and Grok + prompts through a private temporary file instead of oversized argv elements. + ## [4.1.24] - 2026-07-16 ### Fixed diff --git a/packages/runtime/src/cloud-defaults.ts b/packages/runtime/src/cloud-defaults.ts index 9c85c802..db222c19 100644 --- a/packages/runtime/src/cloud-defaults.ts +++ b/packages/runtime/src/cloud-defaults.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process'; import { constants } from 'node:fs'; import { accessSync, statSync } from 'node:fs'; import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; @@ -25,6 +24,7 @@ import { } from './relay-mcp.js'; import { createDefaultLlm } from './cloud-llm.js'; import { SandboxNotAvailableError } from './errors.js'; +import { spawnAndCapture, spawnNonInteractiveAndCapture } from './harness-process.js'; import type { FilesContext, HarnessRunArgs, @@ -606,9 +606,10 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { WORKFORCE_DEPLOYMENT_ID: args.deployment.id, WORKFORCE_WORKSPACE_ID: args.workspaceId }; - const result = await spawnAndCapture({ + const result = await spawnNonInteractiveAndCapture({ bin: spec.bin, args: spawnArgs, + prompt: spec.prompt, cwd, env: childEnv, timeoutMs: args.persona.harnessSettings.timeoutSeconds @@ -709,83 +710,6 @@ function sidecarForPersona( return undefined; } -async function spawnAndCapture(args: { - bin: string; - args: string[]; - cwd: string; - env: NodeJS.ProcessEnv; - timeoutMs?: number; -}): Promise<{ output: string; stderr: string; exitCode: number }> { - return new Promise((resolve) => { - const child = spawn(args.bin, args.args, { - cwd: args.cwd, - env: args.env, - stdio: ['ignore', 'pipe', 'pipe'], - shell: false - }); - let stdout = ''; - let stderr = ''; - let forceKillTimeout: NodeJS.Timeout | undefined; - child.stdout?.setEncoding('utf8'); - child.stderr?.setEncoding('utf8'); - child.stdout?.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr?.on('data', (chunk: string) => { - stderr += chunk; - }); - const timeout = - args.timeoutMs !== undefined - ? setTimeout(() => { - child.kill('SIGTERM'); - forceKillTimeout = setTimeout(() => child.kill('SIGKILL'), 1000); - }, args.timeoutMs) - : undefined; - const clearTimers = () => { - if (timeout) clearTimeout(timeout); - if (forceKillTimeout) clearTimeout(forceKillTimeout); - }; - child.on('error', (err) => { - clearTimers(); - resolve({ output: stdout, stderr: `${stderr}${err.message}\n`, exitCode: 1 }); - }); - child.on('close', (code, signal) => { - clearTimers(); - resolve({ - output: stdout, - stderr, - exitCode: typeof code === 'number' ? code : signal ? signalExitCode(signal) : 1 - }); - }); - }); -} - -function signalExitCode(signal: NodeJS.Signals): number { - const code = signal.startsWith('SIG') ? signalCode(signal.slice(3)) : undefined; - return code ? 128 + code : 1; -} - -function signalCode(name: string): number | undefined { - const signals: Record = { - HUP: 1, - INT: 2, - QUIT: 3, - ILL: 4, - TRAP: 5, - ABRT: 6, - BUS: 7, - FPE: 8, - KILL: 9, - USR1: 10, - SEGV: 11, - USR2: 12, - PIPE: 13, - ALRM: 14, - TERM: 15 - }; - return signals[name]; -} - /** * Build the caller-facing `output` for a harness run. On a non-zero exit the * failure reason almost always lands on stderr — CLI auth/usage errors, and diff --git a/packages/runtime/src/harness-process.test.ts b/packages/runtime/src/harness-process.test.ts new file mode 100644 index 00000000..76d328b3 --- /dev/null +++ b/packages/runtime/src/harness-process.test.ts @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { spawnAndCapture, spawnNonInteractiveAndCapture } from './harness-process.js'; + +const cwd = process.cwd(); +const env = process.env; + +test('spawnAndCapture round-trips a prompt larger than a pipe buffer through stdin', async () => { + const canary = 'STDIN_CANARY_2f713bb9'; + const prompt = `${canary}\n${'x'.repeat(200_000)}`; + const result = await spawnAndCapture({ + bin: process.execPath, + args: [ + '-e', + "process.stdin.setEncoding('utf8');let body='';process.stdin.on('data',c=>body+=c);process.stdin.on('end',()=>process.stdout.write(body));" + ], + cwd, + env, + stdin: prompt + }); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.equal(result.output.length, prompt.length); + assert.ok(result.output.startsWith(canary)); + assert.equal(result.output, prompt); +}); + +test('spawnNonInteractiveAndCapture writes and removes a prompt file when the child fails', async () => { + const prompt = `FILE_CANARY_47766e8f\n${'y'.repeat(200_000)}`; + const script = [ + "const fs=require('node:fs')", + "const i=process.argv.indexOf('--prompt-file')", + 'const file=process.argv[i+1]', + "const body=fs.readFileSync(file,'utf8')", + 'process.stdout.write(JSON.stringify({file,body}),()=>process.exit(23))' + ].join(';'); + const result = await spawnNonInteractiveAndCapture({ + bin: process.execPath, + args: ['-e', script, '--'], + cwd, + env, + prompt: { mode: 'file', contents: prompt, flag: '--prompt-file' } + }); + const observed = JSON.parse(result.output) as { file: string; body: string }; + + assert.equal(result.exitCode, 23); + assert.equal(observed.body, prompt); + assert.equal(existsSync(observed.file), false); +}); + +test('spawnAndCapture turns an early-close stdin error into a loud failure', async () => { + const result = await spawnAndCapture({ + bin: process.execPath, + args: ['-e', "require('node:fs').closeSync(0);setTimeout(()=>process.exit(0),50)"], + cwd, + env, + stdin: 'z'.repeat(2_000_000) + }); + + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /failed to deliver prompt via stdin/); +}); diff --git a/packages/runtime/src/harness-process.ts b/packages/runtime/src/harness-process.ts new file mode 100644 index 00000000..271c1797 --- /dev/null +++ b/packages/runtime/src/harness-process.ts @@ -0,0 +1,179 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { NonInteractivePrompt } from '@agentworkforce/persona-kit'; + +export interface CapturedProcessResult { + output: string; + stderr: string; + exitCode: number; +} + +export interface SpawnAndCaptureArgs { + bin: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + timeoutMs?: number; + stdin?: string; +} + +/** Spawn a process and capture its output, optionally delivering one complete + * prompt through stdin. A failed pipe is folded into the process result so an + * early child exit cannot become either an unhandled EPIPE or a false success. */ +export async function spawnAndCapture( + args: SpawnAndCaptureArgs +): Promise { + return new Promise((resolve) => { + const hasStdin = args.stdin !== undefined; + const child = spawn(args.bin, args.args, { + cwd: args.cwd, + env: args.env, + stdio: [hasStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], + shell: false + }); + let stdout = ''; + let stderr = ''; + let timeout: NodeJS.Timeout | undefined; + let forceKillTimeout: NodeJS.Timeout | undefined; + let processResult: CapturedProcessResult | undefined; + let stdinSettled = !hasStdin; + let stdinError: Error | undefined; + let resolved = false; + + const clearTimers = () => { + if (timeout) clearTimeout(timeout); + if (forceKillTimeout) clearTimeout(forceKillTimeout); + }; + const finish = () => { + if (resolved || !processResult || !stdinSettled) return; + resolved = true; + clearTimers(); + if (stdinError) { + const detail = `failed to deliver prompt via stdin: ${stdinError.message}\n`; + resolve({ + ...processResult, + stderr: `${processResult.stderr}${detail}`, + exitCode: processResult.exitCode === 0 ? 1 : processResult.exitCode + }); + return; + } + resolve(processResult); + }; + const settleStdin = (err?: Error) => { + // A writable's end callback can run before a late EPIPE notification. + // Preserve that error as long as the child result has not been settled. + if (err && !stdinError) stdinError = err; + if (stdinSettled) return; + stdinSettled = true; + finish(); + }; + + child.stdout?.setEncoding('utf8'); + child.stderr?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + + if (hasStdin) { + const input = child.stdin; + if (!input) { + settleStdin(new Error('child stdin pipe was not created')); + } else { + input.on('error', (err) => settleStdin(err)); + input.on('close', () => { + if (!input.writableFinished) { + settleStdin(new Error('child stdin closed before the prompt was written')); + } + }); + input.end(args.stdin, 'utf8', () => settleStdin()); + } + } + + timeout = + args.timeoutMs !== undefined + ? setTimeout(() => { + child.kill('SIGTERM'); + forceKillTimeout = setTimeout(() => child.kill('SIGKILL'), 1000); + }, args.timeoutMs) + : undefined; + child.on('error', (err) => { + processResult = { output: stdout, stderr: `${stderr}${err.message}\n`, exitCode: 1 }; + // spawn failures cannot consume stdin; the process error already carries + // the actionable cause and must not wait on a pipe callback that may not fire. + stdinSettled = true; + finish(); + }); + child.on('close', (code, signal) => { + processResult = { + output: stdout, + stderr, + exitCode: typeof code === 'number' ? code : signal ? signalExitCode(signal) : 1 + }; + finish(); + }); + }); +} + +export interface SpawnNonInteractiveAndCaptureArgs + extends Omit { + prompt: NonInteractivePrompt; +} + +/** Deliver a non-interactive prompt using the harness-specific off-argv mode. + * Grok's prompt file is private and is removed even when spawning or the child + * process fails. */ +export async function spawnNonInteractiveAndCapture( + args: SpawnNonInteractiveAndCaptureArgs +): Promise { + const { prompt, ...spawnArgs } = args; + if (prompt.mode === 'stdin') { + return spawnAndCapture({ ...spawnArgs, stdin: prompt.contents }); + } + + const promptDir = await mkdtemp(path.join(tmpdir(), 'agentworkforce-prompt-')); + const promptPath = path.join(promptDir, 'prompt.txt'); + try { + await writeFile(promptPath, prompt.contents, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx' + }); + return await spawnAndCapture({ + ...spawnArgs, + args: [...spawnArgs.args, prompt.flag, promptPath] + }); + } finally { + await rm(promptDir, { recursive: true, force: true }); + } +} + +function signalExitCode(signal: NodeJS.Signals): number { + const code = signal.startsWith('SIG') ? signalCode(signal.slice(3)) : undefined; + return code ? 128 + code : 1; +} + +function signalCode(name: string): number | undefined { + const signals: Record = { + HUP: 1, + INT: 2, + QUIT: 3, + ILL: 4, + TRAP: 5, + ABRT: 6, + BUS: 7, + FPE: 8, + KILL: 9, + USR1: 10, + SEGV: 11, + USR2: 12, + PIPE: 13, + ALRM: 14, + TERM: 15 + }; + return signals[name]; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b09dc710..f4c15aa0 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -71,6 +71,16 @@ export { // builders and tests. export { buildRelayContext, DEFAULT_RELAYCAST_URL } from './relay.js'; +// Shared process launcher for one-shot harness commands. Prompts are delivered +// off argv through stdin or a private temporary file according to the spec. +export { + spawnAndCapture, + spawnNonInteractiveAndCapture, + type CapturedProcessResult, + type SpawnAndCaptureArgs, + type SpawnNonInteractiveAndCaptureArgs +} from './harness-process.js'; + // Runtime envelope helpers shared by provider-triggered agents. export { unwrapResourceRecord diff --git a/packages/runtime/src/runner.test.ts b/packages/runtime/src/runner.test.ts index 4d96bac3..9e83dbca 100644 --- a/packages/runtime/src/runner.test.ts +++ b/packages/runtime/src/runner.test.ts @@ -203,8 +203,12 @@ test('cloud harness runner materializes AGENTS.md for grok personas', async () = '#!/usr/bin/env node', 'const fs = require("node:fs");', 'const path = require("node:path");', + 'const args = process.argv.slice(2);', + 'const promptFlag = args.indexOf("--prompt-file");', + 'const promptPath = args[promptFlag + 1];', + 'const prompt = fs.readFileSync(promptPath, "utf8");', 'const agents = fs.readFileSync(path.join(process.cwd(), "AGENTS.md"), "utf8");', - 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), agents }));' + 'process.stdout.write(JSON.stringify({ args, agents, prompt, promptPath }));' ].join('\n'), 'utf8' ); @@ -233,8 +237,13 @@ test('cloud harness runner materializes AGENTS.md for grok personas', async () = const result = await defaults.harnessRunner({ prompt: 'say hello' }); assert.equal(result.exitCode, 0); - const parsed = JSON.parse(result.output) as { args: string[]; agents: string }; - assert.deepEqual(parsed.args, [ + const parsed = JSON.parse(result.output) as { + args: string[]; + agents: string; + prompt: string; + promptPath: string; + }; + assert.deepEqual(parsed.args.slice(0, -2), [ '--no-auto-update', '--model', 'grok-build-0.1', @@ -242,10 +251,12 @@ test('cloud harness runner materializes AGENTS.md for grok personas', async () = 'plain', '--cwd', root, - '--always-approve', - '--single', - 'Grok system prompt\n\nUser task:\nsay hello' + '--always-approve' ]); + assert.equal(parsed.args.at(-2), '--prompt-file'); + assert.equal(parsed.args.at(-1), parsed.promptPath); + assert.equal(parsed.prompt, 'Grok system prompt\n\nUser task:\nsay hello'); + await assert.rejects(() => readFile(parsed.promptPath, 'utf8'), { code: 'ENOENT' }); assert.equal(parsed.agents, 'Grok agents sidecar\n'); assert.ok(logs.find((l) => l.message === 'harness.sidecar.materialized')); } finally { @@ -278,8 +289,13 @@ async function writeArgCaptureHarness( [ '#!/usr/bin/env node', "const fs = require('node:fs');", - `fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2));`, - "process.stdout.write('ok\\n');" + "process.stdin.setEncoding('utf8');", + "let stdin = '';", + "process.stdin.on('data', (chunk) => { stdin += chunk; });", + "process.stdin.on('end', () => {", + ` fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ argv: process.argv.slice(2), stdin }, null, 2));`, + " process.stdout.write('ok\\n');", + "});" ].join('\n'), 'utf8' ); @@ -408,7 +424,10 @@ test('cloud default codex harness injects agent-relay MCP args from broker helpe const result = await defaults.harnessRunner({ prompt: 'do the work' }); assert.equal(result.exitCode, 0); - const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { argv: string[] }; + const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { + argv: string[]; + stdin: string; + }; assert.deepEqual(captured.argv.slice(0, 9), [ 'exec', '--config', @@ -450,7 +469,8 @@ test('cloud default codex harness injects agent-relay MCP args from broker helpe JSON.parse(brokerCaptured.argv[existingArgsIdx + 1]), captured.argv.slice(11, -1) ); - assert.equal(captured.argv.at(-1), 'coordinate with the team\n\nUser task:\ndo the work'); + assert.equal(captured.argv.at(-1), '-'); + assert.equal(captured.stdin, 'coordinate with the team\n\nUser task:\ndo the work'); assert.equal( brokerCaptured.argv[brokerCaptured.argv.indexOf('--workspaces-json') + 1], '{"workspaces":[{"id":"ws-relay"}]}' @@ -585,7 +605,10 @@ test('cloud default codex harness falls back when broker returns no args', async const result = await defaults.harnessRunner({ prompt: 'do the work' }); assert.equal(result.exitCode, 0); - const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { argv: string[] }; + const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { + argv: string[]; + stdin: string; + }; assert.ok(captured.argv.includes('mcp_servers.relaycast.command="npx"')); assert.equal(captured.argv.includes('mcp_servers.agent-relay.command="npx"'), false); } finally { @@ -634,12 +657,16 @@ test('cloud default claude harness merges agent-relay MCP config from broker hel const result = await defaults.harnessRunner({ prompt: 'do the work' }); assert.equal(result.exitCode, 0); - const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { argv: string[] }; + const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { + argv: string[]; + stdin: string; + }; assert.equal(captured.argv.filter((arg) => arg === '--mcp-config').length, 1); assert.ok(captured.argv.includes('--strict-mcp-config')); assert.ok(captured.argv.includes('--print')); assert.ok(captured.argv.includes('--output-format')); - assert.equal(captured.argv.at(-1), 'do the work'); + assert.notEqual(captured.argv.at(-1), 'do the work'); + assert.equal(captured.stdin, 'do the work'); const mcpConfigIdx = captured.argv.indexOf('--mcp-config'); const payload = JSON.parse(captured.argv[mcpConfigIdx + 1]) as {