diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 79fbcd37..c4705fd4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -61,6 +61,14 @@ import { type SidecarMdMode, type SkillMaterializationPlan } from '@agentworkforce/persona-kit'; +import { + claudeMcpConfigHasRelayOverride, + codexExistingArgs, + injectClaudeAgentRelayMcpConfig, + injectCodexSubcommandArgs, + resolveAgentRelayBrokerMcpArgs, + resolveRelayMcpFromEnv as resolveRelayMcpFromEnvShared +} from '@agentworkforce/runtime'; import { listBuiltInPersonas, personaCatalog, @@ -534,27 +542,9 @@ function emitDropWarnings(lines: string[]): void { * relaycast wiring to inject into the harness's MCP config so the persona can * message the team — the same capability a non-persona broker spawn gets. * - * The broker sets `RELAY_API_KEY` (+ optional `RELAY_BASE_URL` / - * `RELAY_DEFAULT_WORKSPACE`) on every worker child, and the broker-assigned - * worker name on `RELAY_AGENT_NAME`. Both the key and the name are required: - * the name must match what the broker routes to so the relaycast identity and - * the PTY worker are the same agent. Absent either, we're not under a broker - * (or it's too old to expose the name) and return undefined — a plain - * `agentworkforce agent` run is unaffected. + * Delegates to the shared `resolveRelayMcpFromEnv` in `@agentworkforce/runtime`. */ -function resolveRelayMcpFromEnv(env: NodeJS.ProcessEnv): RelayMcpConfig | undefined { - const apiKey = env.RELAY_API_KEY?.trim(); - const agentName = env.RELAY_AGENT_NAME?.trim(); - if (!apiKey || !agentName) return undefined; - const baseUrl = env.RELAY_BASE_URL?.trim(); - const defaultWorkspace = env.RELAY_DEFAULT_WORKSPACE?.trim(); - return { - apiKey, - agentName, - ...(baseUrl ? { baseUrl } : {}), - ...(defaultWorkspace ? { defaultWorkspace } : {}) - }; -} +const resolveRelayMcpFromEnv = resolveRelayMcpFromEnvShared; /** * Build the `ai-hist` MCP config for a session. Only called when the persona @@ -1854,19 +1844,76 @@ async function runInteractive( const aiHist = aiMemory.enabled ? resolveAiHistConfig(process.env, aiMemory.dbPath) : undefined; - const spec = buildInteractiveSpec({ + const brokerRelayHarness = relayMcp && (harness === 'claude' || harness === 'codex'); + const specInput = { harness, personaId, model, systemPrompt, harnessSettings, mcpServers: resolvedMcp, - ...(relayMcp ? { relayMcp } : {}), ...(aiHist ? { aiHist } : {}), permissions: effectiveSelection.permissions, ...(installRoot !== undefined ? { pluginDirs: [installRoot] } : {}) + }; + const spec = buildInteractiveSpec({ + ...specInput, + ...(relayMcp && !brokerRelayHarness ? { relayMcp } : {}) }); for (const w of spec.warnings) process.stderr.write(`warning: ${w}\n`); + let spawnArgs = [...spec.args]; + + // Broker-aware relay MCP injection: prefer the broker binary's `mcp-args + // --register` subcommand (pre-mints RELAY_AGENT_TOKEN, sets + // RELAY_SKIP_BOOTSTRAP=1) over the legacy `@relaycast/mcp` self-registration + // path. Falls back to @relaycast/mcp when the broker binary is unavailable. + const cliRelayLog = (level: 'debug' | 'info' | 'warn' | 'error', message: string, attrs?: Record) => { + if (level === 'warn' || level === 'error') { + process.stderr.write(`warning: ${message}${attrs ? ` ${JSON.stringify(attrs)}` : ''}\n`); + } + }; + if (relayMcp && harness === 'claude') { + if (claudeMcpConfigHasRelayOverride(spawnArgs)) { + process.stderr.write('• relay: persona overrides relay MCP config\n'); + } else { + const brokerMcpArgs = await resolveAgentRelayBrokerMcpArgs({ + cli: 'claude', + env: process.env, + relayMcp, + cwd: process.cwd(), + existingArgs: [], + log: cliRelayLog + }); + const mergedArgs = brokerMcpArgs + ? injectClaudeAgentRelayMcpConfig(spawnArgs, brokerMcpArgs, cliRelayLog) + : undefined; + if (mergedArgs) { + spawnArgs = mergedArgs; + process.stderr.write('• relay: agent-relay (broker pre-registered)\n'); + } else { + const fallbackSpec = buildInteractiveSpec({ ...specInput, relayMcp }); + spawnArgs = [...fallbackSpec.args]; + process.stderr.write('• relay: @relaycast/mcp (fallback)\n'); + } + } + } else if (relayMcp && harness === 'codex') { + const brokerMcpArgs = await resolveAgentRelayBrokerMcpArgs({ + cli: 'codex', + env: process.env, + relayMcp, + cwd: process.cwd(), + existingArgs: codexExistingArgs(spawnArgs), + log: cliRelayLog + }); + if (brokerMcpArgs) { + spawnArgs = injectCodexSubcommandArgs(spawnArgs, brokerMcpArgs); + process.stderr.write('• relay: agent-relay (broker pre-registered)\n'); + } else { + const fallbackSpec = buildInteractiveSpec({ ...specInput, relayMcp }); + spawnArgs = [...fallbackSpec.args]; + process.stderr.write('• relay: @relaycast/mcp (fallback)\n'); + } + } // Config-file materialization strategy: // - Mount path (default): write each configFile into the @@ -1882,12 +1929,12 @@ async function runInteractive( // default handles this cleanly). const hasConfigFiles = spec.configFiles.length > 0; const degradeConfigFiles = hasConfigFiles && !useClean; - let effectiveArgs: readonly string[] = spec.args; + let effectiveArgs: readonly string[] = spawnArgs; if (degradeConfigFiles) { process.stderr.write( 'warning: --install-in-repo cannot safely materialize the persona agent config (would write opencode.json into your repo); launching without --agent. Drop --install-in-repo to apply the persona prompt.\n' ); - effectiveArgs = stripAgentFlag(spec.args); + effectiveArgs = stripAgentFlag(spawnArgs); } const finalArgs = spec.initialPrompt ? [...effectiveArgs, spec.initialPrompt] : [...effectiveArgs]; diff --git a/packages/runtime/src/cloud-defaults.ts b/packages/runtime/src/cloud-defaults.ts index 305c69bb..896f77ad 100644 --- a/packages/runtime/src/cloud-defaults.ts +++ b/packages/runtime/src/cloud-defaults.ts @@ -14,6 +14,15 @@ import { type PersonaSpec, type RelayMcpConfig } from '@agentworkforce/persona-kit'; +import { + claudeMcpConfigHasRelayOverride, + codexExistingArgs, + injectClaudeAgentRelayMcpConfig, + injectCodexSubcommandArgs, + relayOverrideServerNames, + resolveAgentRelayBrokerMcpArgs, + resolveRelayMcpFromEnv +} from './relay-mcp.js'; import { createDefaultLlm } from './cloud-llm.js'; import { SandboxNotAvailableError } from './errors.js'; import type { @@ -626,26 +635,6 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { }; } -interface BrokerMcpArgsOutput { - args: string[]; - sideEffectFiles?: string[]; - agentToken?: string | null; -} - -function resolveRelayMcpFromEnv(env: NodeJS.ProcessEnv): RelayMcpConfig | undefined { - const apiKey = env.RELAY_API_KEY?.trim(); - const agentName = env.RELAY_AGENT_NAME?.trim(); - if (!apiKey || !agentName) return undefined; - const baseUrl = env.RELAY_BASE_URL?.trim(); - const defaultWorkspace = env.RELAY_DEFAULT_WORKSPACE?.trim(); - return { - apiKey, - agentName, - ...(baseUrl ? { baseUrl } : {}), - ...(defaultWorkspace ? { defaultWorkspace } : {}) - }; -} - /** * Resolve the ai-hist MCP config. Only called when the persona opts into recall * via `memory.aiMemory`. The "why" read-root mirrors the runtime recorder's @@ -668,211 +657,6 @@ function resolveAiHistFromEnv( }; } -async function resolveAgentRelayBrokerMcpArgs(args: { - cli: 'claude' | 'codex'; - env: NodeJS.ProcessEnv; - relayMcp: RelayMcpConfig; - cwd: string; - existingArgs: string[]; - log: WorkforceCtx['log']; -}): Promise { - const broker = resolveAgentRelayBrokerBinary(args.env); - const brokerArgs = [ - 'mcp-args', - '--cli', - args.cli, - '--agent-name', - args.relayMcp.agentName, - '--api-key', - args.relayMcp.apiKey, - ...(args.relayMcp.baseUrl ? ['--base-url', args.relayMcp.baseUrl] : []), - '--register', - '--cwd', - args.cwd, - '--existing-args', - JSON.stringify(args.existingArgs) - ]; - const workspacesJson = args.env.RELAY_WORKSPACES_JSON?.trim(); - if (workspacesJson) brokerArgs.push('--workspaces-json', workspacesJson); - if (args.relayMcp.defaultWorkspace) { - brokerArgs.push('--default-workspace', args.relayMcp.defaultWorkspace); - } - - const result = await spawnAndCapture({ - bin: broker, - args: brokerArgs, - cwd: args.cwd, - env: args.env, - timeoutMs: 15_000 - }); - if (result.exitCode !== 0) { - args.log('warn', 'harness.relay_mcp.broker_args_failed', { - broker, - exitCode: result.exitCode, - stderr: redactRelayBrokerOutput(result.stderr.trim(), args.relayMcp) - }); - return undefined; - } - - let parsed: unknown; - try { - parsed = JSON.parse(result.output); - } catch (err) { - args.log('warn', 'harness.relay_mcp.broker_args_invalid_json', { - broker, - error: err instanceof Error ? err.message : String(err) - }); - return undefined; - } - if (!isBrokerMcpArgsOutput(parsed) || parsed.args.length === 0) { - args.log('warn', 'harness.relay_mcp.broker_args_invalid_shape', { broker }); - return undefined; - } - if (parsed.sideEffectFiles?.length) { - args.log('debug', 'harness.relay_mcp.side_effect_files', { - files: parsed.sideEffectFiles - }); - } - return parsed.args; -} - -function resolveAgentRelayBrokerBinary(env: NodeJS.ProcessEnv): string { - const configured = env.AGENT_RELAY_BIN?.trim() || env.BROKER_BINARY_PATH?.trim(); - if (configured) return configured; - const sandboxBroker = resolveSandboxAgentRelayBrokerBinary(); - return sandboxBroker ?? 'agent-relay-broker'; -} - -function resolveSandboxAgentRelayBrokerBinary(): string | undefined { - const suffix = agentRelayBrokerPlatformSuffix(); - if (!suffix) return undefined; - // Daytona cloud images install the Relay SDK smoke dependency here. This is - // a compatibility fallback; env overrides and PATH remain the general SDK - // contract for locating agent-relay-broker. - const candidate = path.join( - '/opt/relay-smoke/node_modules/@agent-relay/sdk/bin', - `agent-relay-broker-${suffix}` - ); - return canExecuteFileSync(candidate) ? candidate : undefined; -} - -function agentRelayBrokerPlatformSuffix(): string | undefined { - const arch = process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : undefined; - if (!arch) return undefined; - if (process.platform === 'linux') return `linux-${arch}`; - if (process.platform === 'darwin') return `darwin-${arch}`; - if (process.platform === 'win32') return 'win32-x64.exe'; - return undefined; -} - -function canExecuteFileSync(candidate: string): boolean { - try { - accessSync(candidate, constants.R_OK | constants.X_OK); - return statSync(candidate).isFile(); - } catch { - return false; - } -} - -function codexExistingArgs(args: string[]): string[] { - return args[0] === 'exec' ? args.slice(1, -1) : [...args]; -} - -function redactRelayBrokerOutput(value: string, relayMcp: RelayMcpConfig): string { - let redacted = value; - for (const secret of [relayMcp.apiKey]) { - if (secret) redacted = redacted.replaceAll(secret, '[REDACTED]'); - } - return redacted; -} - -function injectCodexSubcommandArgs(args: string[], injected: string[]): string[] { - if (args[0] === 'exec') return ['exec', ...injected, ...args.slice(1)]; - if (args.length === 0 || args[0]?.startsWith('-')) return [...injected, ...args]; - return [...args]; -} - -function injectClaudeAgentRelayMcpConfig( - args: string[], - injected: string[], - log: WorkforceCtx['log'] -): string[] | undefined { - const base = parseClaudeMcpConfigArg(args); - const broker = parseClaudeMcpConfigArg(injected); - if (!base || !broker) { - log('warn', 'harness.relay_mcp.claude_mcp_config_missing'); - return undefined; - } - const baseServers = readMcpServersRecord(base.payload); - const brokerServers = readMcpServersRecord(broker.payload); - const agentRelay = brokerServers?.['agent-relay']; - if (!baseServers || !brokerServers || agentRelay === undefined) { - log('warn', 'harness.relay_mcp.claude_mcp_config_invalid'); - return undefined; - } - const mergedServers: Record = { - ...baseServers, - 'agent-relay': agentRelay - }; - delete mergedServers.relaycast; - const nextPayload = { - ...base.payload, - mcpServers: mergedServers - }; - const next = [...args]; - next[base.valueIndex] = JSON.stringify(nextPayload); - return next; -} - -function claudeMcpConfigHasRelayOverride(args: string[]): boolean { - return relayOverrideServerNames(args).length > 0; -} - -function relayOverrideServerNames(args: string[]): string[] { - const parsed = parseClaudeMcpConfigArg(args); - const servers = parsed ? readMcpServersRecord(parsed.payload) : undefined; - if (!servers) return []; - return ['agent-relay', 'relaycast'].filter((name) => servers[name] !== undefined); -} - -function parseClaudeMcpConfigArg( - args: string[] -): { valueIndex: number; payload: Record } | undefined { - const flagIndex = args.indexOf('--mcp-config'); - if (flagIndex < 0) return undefined; - const valueIndex = flagIndex + 1; - const raw = args[valueIndex]; - if (typeof raw !== 'string') return undefined; - try { - const payload = JSON.parse(raw); - return isRecord(payload) ? { valueIndex, payload } : undefined; - } catch { - return undefined; - } -} - -function readMcpServersRecord( - payload: Record -): Record | undefined { - const servers = payload.mcpServers; - return isRecord(servers) ? servers : undefined; -} - -function isBrokerMcpArgsOutput(value: unknown): value is BrokerMcpArgsOutput { - if (!isRecord(value) || !Array.isArray(value.args)) return false; - if (!value.args.every((arg) => typeof arg === 'string')) return false; - if ( - value.sideEffectFiles !== undefined && - (!Array.isArray(value.sideEffectFiles) || - !value.sideEffectFiles.every((file) => typeof file === 'string')) - ) { - return false; - } - return value.agentToken === undefined || - value.agentToken === null || - typeof value.agentToken === 'string'; -} - async function materializeSidecar(args: { persona: PersonaSpec; inputValues: Record; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 57892a47..f1981f3a 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -107,6 +107,21 @@ export { SandboxNotAvailableError } from './clients/index.js'; +// Broker-aware relay MCP resolution. Shared between the cloud harness runner +// (cloud-defaults.ts) and the local CLI (packages/cli). Callers use these to +// resolve pre-registered agent-relay MCP args from the broker binary, falling +// back to the legacy @relaycast/mcp injection when the broker is unavailable. +export { + claudeMcpConfigHasRelayOverride, + codexExistingArgs, + injectClaudeAgentRelayMcpConfig, + injectCodexSubcommandArgs, + relayOverrideServerNames, + resolveAgentRelayBrokerMcpArgs, + resolveRelayMcpFromEnv, + type RelayMcpLog +} from './relay-mcp.js'; + // Re-export persona-kit types personas commonly reference at the handler // surface, so users don't need a second import for the shapes the ctx // carries. diff --git a/packages/runtime/src/relay-mcp.ts b/packages/runtime/src/relay-mcp.ts new file mode 100644 index 00000000..07016d3e --- /dev/null +++ b/packages/runtime/src/relay-mcp.ts @@ -0,0 +1,297 @@ +import { spawn } from 'node:child_process'; +import { accessSync, constants, statSync } from 'node:fs'; +import path from 'node:path'; +import type { RelayMcpConfig } from '@agentworkforce/persona-kit'; + +export type { RelayMcpConfig }; + +export type RelayMcpLog = ( + level: 'debug' | 'info' | 'warn' | 'error', + message: string, + attrs?: Record +) => void; + +export interface BrokerMcpArgsOutput { + args: string[]; + sideEffectFiles?: string[]; + agentToken?: string | null; +} + +export function resolveRelayMcpFromEnv(env: NodeJS.ProcessEnv): RelayMcpConfig | undefined { + const apiKey = env.RELAY_API_KEY?.trim(); + const agentName = env.RELAY_AGENT_NAME?.trim(); + if (!apiKey || !agentName) return undefined; + const baseUrl = env.RELAY_BASE_URL?.trim(); + const defaultWorkspace = env.RELAY_DEFAULT_WORKSPACE?.trim(); + return { + apiKey, + agentName, + ...(baseUrl ? { baseUrl } : {}), + ...(defaultWorkspace ? { defaultWorkspace } : {}) + }; +} + +export async function resolveAgentRelayBrokerMcpArgs(args: { + cli: 'claude' | 'codex'; + env: NodeJS.ProcessEnv; + relayMcp: RelayMcpConfig; + cwd: string; + existingArgs: string[]; + log: RelayMcpLog; +}): Promise { + const broker = resolveAgentRelayBrokerBinary(args.env); + const brokerArgs = [ + 'mcp-args', + '--cli', + args.cli, + '--agent-name', + args.relayMcp.agentName, + '--api-key', + args.relayMcp.apiKey, + ...(args.relayMcp.baseUrl ? ['--base-url', args.relayMcp.baseUrl] : []), + '--register', + '--cwd', + args.cwd, + '--existing-args', + JSON.stringify(args.existingArgs) + ]; + const workspacesJson = args.env.RELAY_WORKSPACES_JSON?.trim(); + if (workspacesJson) brokerArgs.push('--workspaces-json', workspacesJson); + if (args.relayMcp.defaultWorkspace) { + brokerArgs.push('--default-workspace', args.relayMcp.defaultWorkspace); + } + + const result = await spawnAndCapture({ + bin: broker, + args: brokerArgs, + cwd: args.cwd, + env: args.env, + timeoutMs: 15_000 + }); + if (result.exitCode !== 0) { + args.log('warn', 'harness.relay_mcp.broker_args_failed', { + broker, + exitCode: result.exitCode, + stderr: redactRelayBrokerOutput(result.stderr.trim(), args.relayMcp) + }); + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(result.output); + } catch (err) { + args.log('warn', 'harness.relay_mcp.broker_args_invalid_json', { + broker, + error: err instanceof Error ? err.message : String(err) + }); + return undefined; + } + if (!isBrokerMcpArgsOutput(parsed) || parsed.args.length === 0) { + args.log('warn', 'harness.relay_mcp.broker_args_invalid_shape', { broker }); + return undefined; + } + if (parsed.sideEffectFiles?.length) { + args.log('debug', 'harness.relay_mcp.side_effect_files', { + files: parsed.sideEffectFiles + }); + } + return parsed.args; +} + +export function resolveAgentRelayBrokerBinary(env: NodeJS.ProcessEnv): string { + const configured = env.AGENT_RELAY_BIN?.trim() || env.BROKER_BINARY_PATH?.trim(); + if (configured) return configured; + const sandboxBroker = resolveSandboxAgentRelayBrokerBinary(); + return sandboxBroker ?? 'agent-relay-broker'; +} + +function resolveSandboxAgentRelayBrokerBinary(): string | undefined { + const suffix = agentRelayBrokerPlatformSuffix(); + if (!suffix) return undefined; + const candidate = path.join( + '/opt/relay-smoke/node_modules/@agent-relay/sdk/bin', + `agent-relay-broker-${suffix}` + ); + return canExecuteFileSync(candidate) ? candidate : undefined; +} + +function agentRelayBrokerPlatformSuffix(): string | undefined { + const arch = process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : undefined; + if (!arch) return undefined; + if (process.platform === 'linux') return `linux-${arch}`; + if (process.platform === 'darwin') return `darwin-${arch}`; + if (process.platform === 'win32') return 'win32-x64.exe'; + return undefined; +} + +function canExecuteFileSync(candidate: string): boolean { + try { + accessSync(candidate, constants.R_OK | constants.X_OK); + return statSync(candidate).isFile(); + } catch { + return false; + } +} + +export function codexExistingArgs(args: string[]): string[] { + return args[0] === 'exec' ? args.slice(1, -1) : [...args]; +} + +export function redactRelayBrokerOutput(value: string, relayMcp: RelayMcpConfig): string { + let redacted = value; + for (const secret of [relayMcp.apiKey]) { + if (secret) redacted = redacted.replaceAll(secret, '[REDACTED]'); + } + return redacted; +} + +export function injectCodexSubcommandArgs(args: string[], injected: string[]): string[] { + if (args[0] === 'exec') return ['exec', ...injected, ...args.slice(1)]; + if (args.length === 0 || args[0]?.startsWith('-')) return [...injected, ...args]; + return [...args]; +} + +export function injectClaudeAgentRelayMcpConfig( + args: string[], + injected: string[], + log: RelayMcpLog +): string[] | undefined { + const base = parseClaudeMcpConfigArg(args); + const broker = parseClaudeMcpConfigArg(injected); + if (!base || !broker) { + log('warn', 'harness.relay_mcp.claude_mcp_config_missing'); + return undefined; + } + const baseServers = readMcpServersRecord(base.payload); + const brokerServers = readMcpServersRecord(broker.payload); + const agentRelay = brokerServers?.['agent-relay']; + if (!baseServers || !brokerServers || agentRelay === undefined) { + log('warn', 'harness.relay_mcp.claude_mcp_config_invalid'); + return undefined; + } + const mergedServers: Record = { + ...baseServers, + 'agent-relay': agentRelay + }; + delete mergedServers.relaycast; + const nextPayload = { + ...base.payload, + mcpServers: mergedServers + }; + const next = [...args]; + next[base.valueIndex] = JSON.stringify(nextPayload); + return next; +} + +export function claudeMcpConfigHasRelayOverride(args: string[]): boolean { + return relayOverrideServerNames(args).length > 0; +} + +export function relayOverrideServerNames(args: string[]): string[] { + const parsed = parseClaudeMcpConfigArg(args); + const servers = parsed ? readMcpServersRecord(parsed.payload) : undefined; + if (!servers) return []; + return ['agent-relay', 'relaycast'].filter((name) => servers[name] !== undefined); +} + +function parseClaudeMcpConfigArg( + args: string[] +): { valueIndex: number; payload: Record } | undefined { + const flagIndex = args.indexOf('--mcp-config'); + if (flagIndex < 0) return undefined; + const valueIndex = flagIndex + 1; + const raw = args[valueIndex]; + if (typeof raw !== 'string') return undefined; + try { + const payload = JSON.parse(raw); + return isRecord(payload) ? { valueIndex, payload } : undefined; + } catch { + return undefined; + } +} + +function readMcpServersRecord( + payload: Record +): Record | undefined { + const servers = payload.mcpServers; + return isRecord(servers) ? servers : undefined; +} + +function isBrokerMcpArgsOutput(value: unknown): value is BrokerMcpArgsOutput { + if (!isRecord(value) || !Array.isArray(value.args)) return false; + if (!value.args.every((arg) => typeof arg === 'string')) return false; + if ( + value.sideEffectFiles !== undefined && + (!Array.isArray(value.sideEffectFiles) || + !value.sideEffectFiles.every((file) => typeof file === 'string')) + ) { + return false; + } + return value.agentToken === undefined || + value.agentToken === null || + typeof value.agentToken === 'string'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +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 signals: Record = { + HUP: 1, INT: 2, QUIT: 3, KILL: 9, TERM: 15 + }; + const name = signal.startsWith('SIG') ? signal.slice(3) : signal; + return signals[name] !== undefined ? 128 + signals[name] : 1; +}