diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index ec5d3208d..eec328cec 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -985,17 +985,19 @@ function runBundleHeadless( }); } -export async function cmdRunHeadless(agent: string, cloud: string, opts: HeadlessOptions = {}): Promise { - const { prompt, debug, outputFormat, spawnName } = opts; - - // Funnel entry for headless runs. No picker to instrument — headless either - // validates and proceeds straight to runOrchestration, or it errors out. - // The orchestrate.ts funnel_* events cover the rest. - captureEvent("spawn_launched", { - mode: "headless", - }); +interface HeadlessValidated { + manifest: Manifest; + resolvedAgent: string; + resolvedCloud: string; +} - // Phase 1: Validate inputs (exit code 3) +/** Phase 1: validate inputs, load manifest, resolve names, check credentials */ +async function validateHeadlessInputs( + agent: string, + cloud: string, + prompt: string | undefined, + outputFormat: string | undefined, +): Promise { const validationResult = tryCatch(() => { validateIdentifier(agent, "Agent name"); validateIdentifier(cloud, "Cloud name"); @@ -1007,18 +1009,15 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles headlessError(agent, cloud, "VALIDATION_ERROR", getErrorMessage(validationResult.error), outputFormat, 3); } - // Load manifest (silently - no spinner in headless mode) const manifestResult = await asyncTryCatch(loadManifest); if (!manifestResult.ok) { headlessError(agent, cloud, "MANIFEST_ERROR", getErrorMessage(manifestResult.error), outputFormat, 3); } const manifest = manifestResult.data; - // Resolve agent/cloud names const resolvedAgent = resolveAgentKey(manifest, agent) ?? agent; const resolvedCloud = resolveCloudKey(manifest, cloud) ?? cloud; - // Validate entities exist if (!manifest.agents[resolvedAgent]) { headlessError(resolvedAgent, resolvedCloud, "UNKNOWN_AGENT", `Unknown agent: ${resolvedAgent}`, outputFormat, 3); } @@ -1038,7 +1037,6 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles ); } - // Check credentials upfront const cloudAuth = manifest.clouds[resolvedCloud].auth; if (cloudAuth.toLowerCase() !== "none") { const authVars = parseAuthEnvVars(cloudAuth); @@ -1055,164 +1053,220 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles } } - // Phase 2+3: Load and execute - let exitCode: number; + return { + manifest, + resolvedAgent, + resolvedCloud, + }; +} - if (isWindows()) { - // Windows: download JS bundle and run with bun (bash wrappers won't work) - const cliDir = process.env.SPAWN_CLI_DIR; - let localMainResolved = ""; - - if (cliDir) { - const hasBadChars = (s: string) => s.includes("..") || s.includes("/") || s.includes("\\"); - if (!hasBadChars(resolvedCloud) && !hasBadChars(resolvedAgent)) { - const resolvedCliDir = resolveTrustedCliDir(cliDir); - const candidatePath = path.join(resolvedCliDir, "packages", "cli", "src", resolvedCloud, "main.ts"); - const realResult = tryCatchIf(isFileError, () => fs.realpathSync(candidatePath)); - if (realResult.ok) { - const prefix = resolvedCliDir.endsWith(path.sep) ? resolvedCliDir : resolvedCliDir + path.sep; - if (realResult.data.startsWith(prefix)) { - localMainResolved = realResult.data; - } +/** Windows path: resolve local bundle or download from GitHub, then run with bun */ +async function fetchAndRunWindows( + resolvedAgent: string, + resolvedCloud: string, + opts: { + prompt?: string; + debug?: boolean; + outputFormat?: string; + spawnName?: string; + }, +): Promise { + const { prompt, debug, outputFormat, spawnName } = opts; + const cliDir = process.env.SPAWN_CLI_DIR; + let localMainResolved = ""; + + if (cliDir) { + const hasBadChars = (s: string) => s.includes("..") || s.includes("/") || s.includes("\\"); + if (!hasBadChars(resolvedCloud) && !hasBadChars(resolvedAgent)) { + const resolvedCliDir = resolveTrustedCliDir(cliDir); + const candidatePath = path.join(resolvedCliDir, "packages", "cli", "src", resolvedCloud, "main.ts"); + const realResult = tryCatchIf(isFileError, () => fs.realpathSync(candidatePath)); + if (realResult.ok) { + const prefix = resolvedCliDir.endsWith(path.sep) ? resolvedCliDir : resolvedCliDir + path.sep; + if (realResult.data.startsWith(prefix)) { + localMainResolved = realResult.data; } } } + } - if (debug) { - console.error(`[headless] Executing ${resolvedAgent} on ${resolvedCloud} (Windows bundle mode)...`); + if (debug) { + console.error(`[headless] Executing ${resolvedAgent} on ${resolvedCloud} (Windows bundle mode)...`); + } + + if (localMainResolved) { + return runBundleHeadless(localMainResolved, resolvedAgent, prompt, debug, spawnName); + } + + const bundleUrl = `https://github.com/${REPO}/releases/download/${resolvedCloud}-latest/${resolvedCloud}.js`; + const fetchResult = await asyncTryCatch(async () => { + const res = await fetch(bundleUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), + redirect: "follow", + }); + if (!res.ok) { + headlessError( + resolvedAgent, + resolvedCloud, + "DOWNLOAD_ERROR", + `Bundle not found (HTTP ${res.status})`, + outputFormat, + 2, + ); } + return res.text(); + }); + if (!fetchResult.ok) { + headlessError( + resolvedAgent, + resolvedCloud, + "DOWNLOAD_ERROR", + `Failed to download bundle: ${getErrorMessage(fetchResult.error)}`, + outputFormat, + 2, + ); + } - if (localMainResolved) { - exitCode = await runBundleHeadless(localMainResolved, resolvedAgent, prompt, debug, spawnName); - } else { - const bundleUrl = `https://github.com/${REPO}/releases/download/${resolvedCloud}-latest/${resolvedCloud}.js`; - const fetchResult = await asyncTryCatch(async () => { - const res = await fetch(bundleUrl, { - signal: AbortSignal.timeout(FETCH_TIMEOUT), - redirect: "follow", - }); - if (!res.ok) { - headlessError( - resolvedAgent, - resolvedCloud, - "DOWNLOAD_ERROR", - `Bundle not found (HTTP ${res.status})`, - outputFormat, - 2, - ); - } - return res.text(); - }); - if (!fetchResult.ok) { - headlessError( - resolvedAgent, - resolvedCloud, - "DOWNLOAD_ERROR", - `Failed to download bundle: ${getErrorMessage(fetchResult.error)}`, - outputFormat, - 2, - ); - } - // Write bundle to temp file and run with bun - const tmpFile = path.join(fs.mkdtempSync(path.join(tmpdir(), "spawn-")), `${resolvedCloud}.js`); - fs.writeFileSync(tmpFile, fetchResult.data); - exitCode = await runBundleHeadless(tmpFile, resolvedAgent, prompt, debug, spawnName); - tryCatchIf(isFileError, () => fs.unlinkSync(tmpFile)); + const tmpFile = path.join(fs.mkdtempSync(path.join(tmpdir(), "spawn-")), `${resolvedCloud}.js`); + fs.writeFileSync(tmpFile, fetchResult.data); + const exitCode = await runBundleHeadless(tmpFile, resolvedAgent, prompt, debug, spawnName); + tryCatchIf(isFileError, () => fs.unlinkSync(tmpFile)); + return exitCode; +} + +/** macOS/Linux path: resolve local script or download from CDN/GitHub, then run via shell */ +async function fetchAndRunUnix( + resolvedAgent: string, + resolvedCloud: string, + opts: { + prompt?: string; + debug?: boolean; + outputFormat?: string; + spawnName?: string; + }, +): Promise { + const { prompt, debug, outputFormat, spawnName } = opts; + let scriptContent: string; + const cliDir = process.env.SPAWN_CLI_DIR; + const localScriptResolved = cliDir ? resolveLocalWrapperScript(cliDir, resolvedCloud, resolvedAgent) : ""; + + if (localScriptResolved) { + scriptContent = fs.readFileSync(localScriptResolved, "utf-8"); + if (debug) { + console.error(`[headless] Using local script: ${localScriptResolved}`); } } else { - // macOS/Linux: download bash wrapper script - let scriptContent: string; - const cliDir = process.env.SPAWN_CLI_DIR; - const localScriptResolved = cliDir ? resolveLocalWrapperScript(cliDir, resolvedCloud, resolvedAgent) : ""; - - if (localScriptResolved) { - scriptContent = fs.readFileSync(localScriptResolved, "utf-8"); - if (debug) { - console.error(`[headless] Using local script: ${localScriptResolved}`); + const url = `https://openrouter.ai/labs/spawn/${resolvedCloud}/${resolvedAgent}.sh`; + const ghUrl = `${RAW_BASE}/sh/${resolvedCloud}/${resolvedAgent}.sh`; + + const fetchResult = await asyncTryCatch(async () => { + const res = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + if (res.ok) { + return res.text(); } - } else { - const url = `https://openrouter.ai/labs/spawn/${resolvedCloud}/${resolvedAgent}.sh`; - const ghUrl = `${RAW_BASE}/sh/${resolvedCloud}/${resolvedAgent}.sh`; - - const fetchResult = await asyncTryCatch(async () => { - const res = await fetch(url, { - signal: AbortSignal.timeout(FETCH_TIMEOUT), - }); - if (res.ok) { - return res.text(); - } - const ghRes = await fetch(ghUrl, { - signal: AbortSignal.timeout(FETCH_TIMEOUT), - }); - if (!ghRes.ok) { - headlessError( - resolvedAgent, - resolvedCloud, - "DOWNLOAD_ERROR", - `Script not found (HTTP ${res.status} primary, ${ghRes.status} fallback)`, - outputFormat, - 2, - ); - } - return ghRes.text(); + const ghRes = await fetch(ghUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), }); - if (!fetchResult.ok) { + if (!ghRes.ok) { headlessError( resolvedAgent, resolvedCloud, "DOWNLOAD_ERROR", - `Failed to download script: ${getErrorMessage(fetchResult.error)}`, + `Script not found (HTTP ${res.status} primary, ${ghRes.status} fallback)`, outputFormat, 2, ); } - scriptContent = fetchResult.data; - } - - if (debug) { - console.error(`[headless] Executing ${resolvedAgent} on ${resolvedCloud}...`); + return ghRes.text(); + }); + if (!fetchResult.ok) { + headlessError( + resolvedAgent, + resolvedCloud, + "DOWNLOAD_ERROR", + `Failed to download script: ${getErrorMessage(fetchResult.error)}`, + outputFormat, + 2, + ); } - - exitCode = await runScriptHeadless(scriptContent, prompt, debug, spawnName); + scriptContent = fetchResult.data; } - if (exitCode !== 0) { - headlessError( - resolvedAgent, - resolvedCloud, - "EXECUTION_ERROR", - `Script exited with code ${exitCode}`, - outputFormat, - 1, - ); + if (debug) { + console.error(`[headless] Executing ${resolvedAgent} on ${resolvedCloud}...`); } - // Read the spawn record saved during orchestration to populate connection fields. - // Validate each field individually — silently omit any that fail validation to avoid - // surfacing attacker-controlled data from a tampered history file in headless output. + return runScriptHeadless(scriptContent, prompt, debug, spawnName); +} + +/** Extract validated connection fields from the most recent matching spawn record */ +function extractConnectionFields( + resolvedAgent: string, + resolvedCloud: string, +): Partial> { const history = loadHistory(); const record = history .filter((r) => r.agent === resolvedAgent && r.cloud === resolvedCloud && r.connection && !r.connection.deleted) .pop(); - const connectionFields: Partial> = {}; + const fields: Partial> = {}; if (record?.connection) { const conn = record.connection; if (conn.ip && tryCatch(() => validateConnectionIP(conn.ip)).ok) { - connectionFields.ip_address = conn.ip; + fields.ip_address = conn.ip; } if (conn.user && tryCatch(() => validateUsername(conn.user)).ok) { - connectionFields.ssh_user = conn.user; + fields.ssh_user = conn.user; } const serverId = conn.server_id; if (serverId && tryCatch(() => validateServerIdentifier(serverId)).ok) { - connectionFields.server_id = serverId; + fields.server_id = serverId; } const serverName = conn.server_name; if (serverName && tryCatch(() => validateServerIdentifier(serverName)).ok) { - connectionFields.server_name = serverName; + fields.server_name = serverName; } } + return fields; +} + +export async function cmdRunHeadless(agent: string, cloud: string, opts: HeadlessOptions = {}): Promise { + const { prompt, debug, outputFormat, spawnName } = opts; + + captureEvent("spawn_launched", { + mode: "headless", + }); + + const { resolvedAgent, resolvedCloud } = await validateHeadlessInputs(agent, cloud, prompt, outputFormat); + + const exitCode = isWindows() + ? await fetchAndRunWindows(resolvedAgent, resolvedCloud, { + prompt, + debug, + outputFormat, + spawnName, + }) + : await fetchAndRunUnix(resolvedAgent, resolvedCloud, { + prompt, + debug, + outputFormat, + spawnName, + }); + + if (exitCode !== 0) { + headlessError( + resolvedAgent, + resolvedCloud, + "EXECUTION_ERROR", + `Script exited with code ${exitCode}`, + outputFormat, + 1, + ); + } + + const connectionFields = extractConnectionFields(resolvedAgent, resolvedCloud); const result: SpawnResult = { status: "success",