spawn/packages/cli/src/commands/run.ts
A 187595283e
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
fix: resolve 4 production TypeScript type errors (#3266)
- local.ts: spread ReadonlyArray into mutable array for Bun.spawn
- run.ts: capture optional fields in local vars for proper narrowing
- delete.ts: filter SpawnRecordSchema output for required id field

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-11 17:16:47 +07:00

1292 lines
39 KiB
TypeScript

import type { Manifest } from "../manifest.js";
import { spawn, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { buildDashboardHint, EXIT_CODE_GUIDANCE, SIGNAL_GUIDANCE } from "../guidance-data.js";
import { generateSpawnId, getActiveServers, loadHistory, saveSpawnRecord } from "../history.js";
import { loadManifest, RAW_BASE, REPO, SPAWN_CDN } from "../manifest.js";
import {
validateConnectionIP,
validateIdentifier,
validatePrompt,
validateScriptContent,
validateServerIdentifier,
validateUsername,
} from "../security.js";
import { asyncTryCatch, isFileError, tryCatch, tryCatchIf } from "../shared/result.js";
import { getLocalShell, isWindows } from "../shared/shell.js";
import { maybeShowStarPrompt } from "../shared/star-prompt.js";
import { logError, logInfo, logStep, prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
import { promptSetupOptions, promptSpawnName } from "./interactive.js";
import { handleRecordAction } from "./list.js";
import {
buildRetryCommand,
collectMissingCredentials,
credentialHints,
FETCH_TIMEOUT,
formatCredStatusLine,
getAuthHint,
getErrorMessage,
loadManifestWithSpinner,
parseAuthEnvVars,
preflightCredentialCheck,
resolveAgentKey,
resolveCloudKey,
validateEntities,
validateRunSecurity,
} from "./shared.js";
// ── Dry-run helpers ──────────────────────────────────────────────────────────
/** Resolve display names / casing and log if resolved to a different key */
function resolveAndLog(
manifest: Manifest,
agent: string,
cloud: string,
): {
agent: string;
cloud: string;
} {
const resolvedAgent = resolveAgentKey(manifest, agent);
const resolvedCloud = resolveCloudKey(manifest, cloud);
if (resolvedAgent && resolvedAgent !== agent) {
p.log.info(`Resolved "${agent}" to ${pc.cyan(resolvedAgent)}`);
agent = resolvedAgent;
}
if (resolvedCloud && resolvedCloud !== cloud) {
p.log.info(`Resolved "${cloud}" to ${pc.cyan(resolvedCloud)}`);
cloud = resolvedCloud;
}
return {
agent,
cloud,
};
}
/** Detect and fix swapped arguments: "spawn <cloud> <agent>" -> "spawn <agent> <cloud>" */
function detectAndFixSwappedArgs(
manifest: Manifest,
agent: string,
cloud: string,
): {
agent: string;
cloud: string;
} {
if (!manifest.agents[agent] && manifest.clouds[agent] && manifest.agents[cloud]) {
p.log.info("It looks like you swapped the agent and cloud arguments.");
p.log.info(`Running: ${pc.cyan(`spawn ${cloud} ${agent}`)}`);
return {
agent: cloud,
cloud: agent,
};
}
return {
agent,
cloud,
};
}
/** Print a labeled section: bold header, body lines, then a blank line */
function printDryRunSection(title: string, lines: string[]): void {
p.log.step(pc.bold(title));
for (const line of lines) {
console.log(line);
}
console.log();
}
function buildAgentLines(agentInfo: {
name: string;
description: string;
install?: string;
launch?: string;
}): string[] {
const lines = [
` Name: ${agentInfo.name}`,
` Description: ${agentInfo.description}`,
];
if (agentInfo.install) {
lines.push(` Install: ${agentInfo.install}`);
}
if (agentInfo.launch) {
lines.push(` Launch: ${agentInfo.launch}`);
}
return lines;
}
function buildCloudLines(cloudInfo: {
name: string;
price: string;
description: string;
defaults?: Record<string, unknown>;
}): string[] {
const lines = [
` Name: ${cloudInfo.name}`,
` Price: ${cloudInfo.price}`,
` Description: ${cloudInfo.description}`,
];
if (cloudInfo.defaults) {
lines.push(" Defaults:");
for (const [k, val] of Object.entries(cloudInfo.defaults)) {
lines.push(` ${k}: ${String(val)}`);
}
}
return lines;
}
/** Build credential status lines for dry-run preview showing which env vars are set/missing */
function buildCredentialStatusLines(manifest: Manifest, cloud: string): string[] {
const cloudAuth = manifest.clouds[cloud].auth;
const authVars = parseAuthEnvVars(cloudAuth);
const cloudUrl = manifest.clouds[cloud].url;
const lines = [
formatCredStatusLine("OPENROUTER_API_KEY", "https://openrouter.ai/settings/keys"),
];
for (let i = 0; i < authVars.length; i++) {
lines.push(formatCredStatusLine(authVars[i], i === 0 ? cloudUrl : undefined));
}
return lines;
}
function buildEnvironmentLines(manifest: Manifest, agent: string): string[] | null {
const env = manifest.agents[agent].env;
if (!env) {
return null;
}
return Object.entries(env).map(([k, v]) => {
const display = v.includes("OPENROUTER_API_KEY") ? "(from OpenRouter)" : v;
return ` ${k}=${display}`;
});
}
function buildPromptLines(prompt: string): string[] {
const preview = prompt.length > 100 ? prompt.slice(0, 100) + "..." : prompt;
const lines = [
` ${preview}`,
];
if (prompt.length > 100) {
lines.push(pc.dim(` (${prompt.length} characters total)`));
}
return lines;
}
export function showDryRunPreview(manifest: Manifest, agent: string, cloud: string, prompt?: string): void {
p.log.info(pc.bold("Dry run -- no resources will be provisioned\n"));
printDryRunSection("Agent", buildAgentLines(manifest.agents[agent]));
printDryRunSection("Cloud", buildCloudLines(manifest.clouds[cloud]));
printDryRunSection("Script", [
` URL: ${SPAWN_CDN}/${cloud}/${agent}.sh`,
]);
const envLines = buildEnvironmentLines(manifest, agent);
if (envLines) {
printDryRunSection("Environment variables", envLines);
}
// Show credential readiness
const credLines = buildCredentialStatusLines(manifest, cloud);
printDryRunSection("Credentials", credLines);
const allSet = credLines.every((l) => l.includes("-- set"));
if (!allSet) {
p.log.warn("Some credentials are missing. Set them before launching.");
p.log.info(`Run ${pc.cyan(`spawn ${cloud}`)} for setup instructions.`);
console.log();
}
if (prompt) {
printDryRunSection("Prompt", buildPromptLines(prompt));
}
p.log.success("Dry run complete -- no resources were provisioned");
}
// ── Script download ──────────────────────────────────────────────────────────
async function downloadScriptWithFallback(primaryUrl: string, fallbackUrl: string): Promise<string> {
logStep("Downloading spawn script...");
const r = await asyncTryCatch(async () => {
const res = await fetch(primaryUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (res.ok) {
const text = await res.text();
logInfo("Script downloaded");
return text;
}
// Fallback to GitHub raw
logStep("Trying fallback source...");
const ghRes = await fetch(fallbackUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (!ghRes.ok) {
logError("Download failed");
reportDownloadFailure(res.status, ghRes.status);
process.exit(1);
}
const text = await ghRes.text();
logInfo("Script downloaded (fallback)");
return text;
});
if (!r.ok) {
logError("Download failed");
throw r.error;
}
return r.data;
}
// Report 404 errors (script not found)
function report404Failure(): void {
p.log.error("Script not found (HTTP 404)");
console.error("\nThe spawn script doesn't exist at the expected location.");
console.error("\nThis usually means:");
console.error(" \u2022 The agent + cloud combination hasn't been implemented yet");
console.error(" \u2022 The script is currently being deployed (rare)");
console.error(" \u2022 There's a temporary issue with the file server");
console.error(`\n${pc.bold("Next steps:")}`);
console.error(` 1. Verify it's implemented: ${pc.cyan("spawn matrix")}`);
console.error(" 2. If the matrix shows \u2713, wait 1-2 minutes and retry");
console.error(` 3. Still broken? Report it: ${pc.cyan(`https://github.com/${REPO}/issues`)}`);
}
// Report HTTP errors (non-404)
function reportHTTPFailure(primaryStatus: number, fallbackStatus: number): void {
const hasServerError = primaryStatus >= 500 || fallbackStatus >= 500;
p.log.error("Script download failed");
console.error(
`\nCouldn't download the spawn script (HTTP ${primaryStatus} from primary, ${fallbackStatus} from fallback).`,
);
if (hasServerError) {
console.error("\nThe servers are experiencing issues or temporarily unavailable.");
}
console.error(`\n${pc.bold("Next steps:")}`);
console.error(" 1. Check your internet connection");
console.error(" 2. Wait a moment and try again");
console.error(` 3. Check GitHub's status: ${pc.cyan("https://www.githubstatus.com")}`);
if (hasServerError) {
console.error(" 4. If GitHub is down, retry when it's back up");
}
}
function reportDownloadFailure(primaryStatus: number, fallbackStatus: number): void {
if (primaryStatus === 404 && fallbackStatus === 404) {
report404Failure();
} else {
reportHTTPFailure(primaryStatus, fallbackStatus);
}
}
// Detect error type from error message
function classifyNetworkError(errMsg: string): "timeout" | "connection" | "unknown" {
if (errMsg.toLowerCase().includes("timeout")) {
return "timeout";
}
if (errMsg.toLowerCase().includes("connect") || errMsg.toLowerCase().includes("enotfound")) {
return "connection";
}
return "unknown";
}
interface ErrorGuidance {
causes: string[];
steps: (ghUrl: string) => string[];
}
const NETWORK_ERROR_GUIDANCE: Record<"timeout" | "connection" | "unknown", ErrorGuidance> = {
timeout: {
causes: [
" \u2022 Slow or unstable internet connection",
" \u2022 Download server not responding (possibly overloaded)",
" \u2022 Firewall blocking or slowing the connection",
],
steps: (ghUrl) => [
" 2. Verify combination exists: " + pc.cyan("spawn matrix"),
" 3. Wait a moment and retry",
" 4. Test URL directly: " + pc.dim(ghUrl),
],
},
connection: {
causes: [
" \u2022 No internet connection",
" \u2022 Firewall or proxy blocking GitHub access",
" \u2022 DNS not resolving GitHub's domain",
],
steps: () => [
" 2. Test github.com access in your browser",
" 3. Check firewall/VPN settings",
" 4. Try disabling proxy temporarily",
],
},
unknown: {
causes: [
" \u2022 Internet connection issue",
" \u2022 GitHub's servers temporarily down",
],
steps: (ghUrl) => [
" 2. Verify combination exists: " + pc.cyan("spawn matrix"),
" 3. Wait a moment and retry",
" 4. Test URL directly: " + pc.dim(ghUrl),
],
},
};
function reportDownloadError(ghUrl: string, err: unknown): never {
p.log.error("Script download failed");
const errMsg = getErrorMessage(err);
console.error("\nNetwork error:", errMsg);
const errorType = classifyNetworkError(errMsg);
const guidance = NETWORK_ERROR_GUIDANCE[errorType];
console.error(`\n${pc.bold("Possible causes:")}`);
for (const cause of guidance.causes) {
console.error(cause);
}
console.error(`\n${pc.bold("Next steps:")}`);
console.error(" 1. Check your internet connection");
for (const step of guidance.steps(ghUrl)) {
console.error(step);
}
process.exit(1);
}
// ── Script failure guidance ──────────────────────────────────────────────────
export function getSignalGuidance(signal: string, dashboardUrl?: string): string[] {
const entry = SIGNAL_GUIDANCE[signal];
if (entry) {
const lines = [
entry.header,
...entry.causes,
];
if (entry.includeDashboard) {
lines.push(buildDashboardHint(dashboardUrl));
}
return lines;
}
return [
`Script was killed by signal ${signal}.`,
" - The process was terminated by the system or another process",
buildDashboardHint(dashboardUrl),
];
}
function optionalDashboardLine(dashboardUrl?: string): string[] {
return dashboardUrl
? [
` - Check your dashboard: ${pc.cyan(dashboardUrl)}`,
]
: [];
}
export function getScriptFailureGuidance(
exitCode: number | null,
cloud: string,
authHint?: string,
dashboardUrl?: string,
): string[] {
const entry = exitCode !== null ? EXIT_CODE_GUIDANCE[exitCode] : null;
if (!entry) {
// Default/unknown exit code
return [
`${pc.bold("Common causes:")}`,
...credentialHints(cloud, authHint, "Missing"),
" - Cloud provider API rate limit or quota exceeded",
" - Missing local dependencies (SSH, curl, jq)",
...optionalDashboardLine(dashboardUrl),
];
}
const lines = [
pc.bold(entry.header),
...entry.lines,
];
// Apply special handling if defined for this exit code
if (entry.specialHandling) {
// Exit code 1 special case: needs credentialHints
if (exitCode === 1) {
lines.push(
...credentialHints(cloud, authHint),
" - Cloud provider API error (quota, rate limit, or region issue)",
" - Server provisioning failed (try again or pick a different region)",
);
} else {
lines.push(...entry.specialHandling(cloud, authHint, dashboardUrl));
}
}
if (entry.includeDashboard) {
lines.push(buildDashboardHint(dashboardUrl));
}
return lines;
}
/** Check if an error message indicates an SSH connection failure (exit code 255). */
export function isRetryableExitCode(errMsg: string): boolean {
const exitCodeMatch = errMsg.match(/exited with code (\d+)/);
if (!exitCodeMatch) {
return false;
}
const code = Number.parseInt(exitCodeMatch[1], 10);
// Exit 255 = SSH connection failure (the standard SSH error exit code)
return code === 255;
}
function reportScriptFailure(
errMsg: string,
cloud: string,
agent: string,
authHint?: string,
prompt?: string,
dashboardUrl?: string,
spawnName?: string,
): never {
p.log.error("Spawn script failed");
console.error("\nError:", errMsg);
const exitCodeMatch = errMsg.match(/exited with code (\d+)/);
const exitCode = exitCodeMatch ? Number.parseInt(exitCodeMatch[1], 10) : null;
// Check for signal-killed messages (e.g. "killed by SIGKILL")
const signalMatch = errMsg.match(/killed by (SIG\w+)/);
const signal = signalMatch ? signalMatch[1] : null;
const lines = signal
? getSignalGuidance(signal, dashboardUrl)
: getScriptFailureGuidance(exitCode, cloud, authHint, dashboardUrl);
console.error("");
for (const line of lines) {
console.error(line);
}
console.error("");
console.error(`Retry: ${pc.cyan(buildRetryCommand(agent, cloud, prompt, spawnName))}`);
process.exit(1);
}
function handleUserInterrupt(errMsg: string, dashboardUrl?: string): void {
if (!errMsg.includes("interrupted by user") && !errMsg.includes("killed by SIGINT")) {
return;
}
console.error();
p.log.warn("Script interrupted (Ctrl+C).");
p.log.warn("If a server was already created, it may still be running.");
if (dashboardUrl) {
p.log.warn(` Check your dashboard: ${pc.cyan(dashboardUrl)}`);
} else {
p.log.warn(" Check your cloud provider dashboard to stop or delete any unused servers.");
}
process.exit(130);
}
// ── Script execution ─────────────────────────────────────────────────────────
function spawnScript(script: string, env: Record<string, string | undefined>): void {
const [shell, flag] = getLocalShell();
const result = spawnSync(
shell,
[
flag,
script,
],
{
stdio: "inherit",
env,
},
);
if (result.error) {
throw result.error;
}
const code = result.status;
const signal = result.signal;
if (code === 0) {
return;
}
if (code !== null) {
const msg = code === 130 ? "Script interrupted by user (Ctrl+C)" : `Script exited with code ${code}`;
throw new Error(msg);
}
// code is null when killed by a signal (SIGKILL, SIGTERM, etc.)
const sig = signal ?? "unknown signal";
throw new Error(`Script was killed by ${sig}`);
}
function runBash(script: string, prompt?: string, debug?: boolean, spawnName?: string): void {
// SECURITY: Validate script content before execution
validateScriptContent(script);
// Set environment variables for non-interactive mode
const env = {
...process.env,
};
if (prompt) {
env.SPAWN_PROMPT = prompt;
env.SPAWN_MODE = "non-interactive";
}
if (debug) {
env.SPAWN_DEBUG = "1";
}
if (spawnName) {
env.SPAWN_NAME = spawnName;
env.SPAWN_NAME_KEBAB = toKebabCase(spawnName);
}
if (process.env.SPAWN_CUSTOM === "1") {
env.SPAWN_CUSTOM = "1";
}
// Clean up stdin state left by @clack/prompts so the child process
// gets a pristine file descriptor (prevents silent hangs / early exit)
prepareStdinForHandoff();
spawnScript(script, env);
}
/**
* Run a bash script once. Does NOT retry — the script includes server creation
* and an interactive session, so retrying would create duplicate servers.
* On SSH disconnect (exit 255), shows a reconnect hint instead.
*/
function runBashScript(
script: string,
prompt?: string,
dashboardUrl?: string,
debug?: boolean,
spawnName?: string,
): string | undefined {
const r = tryCatch(() => runBash(script, prompt, debug, spawnName));
if (r.ok) {
return undefined;
}
const errMsg = getErrorMessage(r.error);
handleUserInterrupt(errMsg, dashboardUrl);
// SSH disconnect after the server was already created — don't retry
if (isRetryableExitCode(errMsg)) {
console.error();
p.log.warn("SSH connection lost. Your server is likely still running.");
p.log.warn("To reconnect, re-run the same spawn command.");
return undefined; // Don't report as failure — user already has clear guidance
}
return errMsg;
}
// ── Windows bundle execution ─────────────────────────────────────────────────
/**
* On Windows, bash wrappers can't run. Instead, download the pre-built JS
* bundle from GitHub releases and run it directly with bun.
* The bash wrapper ultimately does: `bun run {cloud}.js {agent}` — we replicate that.
*/
async function downloadBundle(cloud: string): Promise<string> {
const bundleUrl = `https://github.com/${REPO}/releases/download/${cloud}-latest/${cloud}.js`;
logStep("Downloading spawn bundle...");
const r = await asyncTryCatch(async () => {
const res = await fetch(bundleUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
redirect: "follow",
});
if (!res.ok) {
logError("Download failed");
p.log.error(`Bundle not found at ${bundleUrl} (HTTP ${res.status})`);
process.exit(2);
}
const text = await res.text();
logInfo("Bundle downloaded");
return text;
});
if (!r.ok) {
logError("Download failed");
throw r.error;
}
return r.data;
}
function runBundleSync(
bundleContent: string,
cloud: string,
agent: string,
env: Record<string, string | undefined>,
): void {
const tmpFile = path.join(fs.mkdtempSync(path.join(tmpdir(), "spawn-")), `${cloud}.js`);
fs.writeFileSync(tmpFile, bundleContent);
const result = spawnSync(
"bun",
[
"run",
tmpFile,
agent,
],
{
stdio: "inherit",
env,
},
);
// Best-effort cleanup
tryCatchIf(isFileError, () => fs.unlinkSync(tmpFile));
if (result.error) {
throw result.error;
}
const code = result.status;
const signal = result.signal;
if (code === 0) {
return;
}
if (code !== null) {
const msg = code === 130 ? "Script interrupted by user (Ctrl+C)" : `Script exited with code ${code}`;
throw new Error(msg);
}
const sig = signal ?? "unknown signal";
throw new Error(`Script was killed by ${sig}`);
}
export async function execScript(
cloud: string,
agent: string,
prompt?: string,
authHint?: string,
dashboardUrl?: string,
debug?: boolean,
spawnName?: string,
): Promise<boolean> {
// Generate a unique spawn ID and record the spawn before execution
const spawnId = generateSpawnId();
const parentId = process.env.SPAWN_PARENT_ID || undefined;
const depth = process.env.SPAWN_DEPTH ? Number(process.env.SPAWN_DEPTH) : undefined;
const saveResult = tryCatchIf(isFileError, () =>
saveSpawnRecord({
id: spawnId,
agent,
cloud,
timestamp: new Date().toISOString(),
...(spawnName
? {
name: spawnName,
}
: {}),
...(prompt
? {
prompt,
}
: {}),
...(parentId
? {
parent_id: parentId,
}
: {}),
...(depth !== undefined && !Number.isNaN(depth)
? {
depth,
}
: {}),
}),
);
if (!saveResult.ok && debug) {
console.error(pc.dim(`Warning: Failed to save spawn record: ${getErrorMessage(saveResult.error)}`));
}
process.env.SPAWN_ID = spawnId;
if (isWindows()) {
// Windows: download the pre-built JS bundle and run directly with bun
// (bash wrappers contain bash syntax that PowerShell cannot parse)
const dlResult = await asyncTryCatch(() => downloadBundle(cloud));
if (!dlResult.ok) {
const ghUrl = `https://github.com/${REPO}/releases/download/${cloud}-latest/${cloud}.js`;
reportDownloadError(ghUrl, dlResult.error);
return false;
}
const env: Record<string, string | undefined> = {
...process.env,
};
if (prompt) {
env.SPAWN_PROMPT = prompt;
env.SPAWN_MODE = "non-interactive";
}
if (debug) {
env.SPAWN_DEBUG = "1";
}
if (spawnName) {
env.SPAWN_NAME = spawnName;
env.SPAWN_NAME_KEBAB = toKebabCase(spawnName);
}
prepareStdinForHandoff();
const r = tryCatch(() => runBundleSync(dlResult.data, cloud, agent, env));
if (!r.ok) {
const errMsg = getErrorMessage(r.error);
handleUserInterrupt(errMsg, dashboardUrl);
reportScriptFailure(errMsg, cloud, agent, authHint, prompt, dashboardUrl, spawnName);
return false;
}
return true;
}
// macOS/Linux: prefer the checked-in wrapper when running from a local checkout.
let scriptContent = "";
const cliDir = process.env.SPAWN_CLI_DIR;
const localScriptResolved = cliDir ? resolveLocalWrapperScript(cliDir, cloud, agent) : "";
if (localScriptResolved) {
scriptContent = fs.readFileSync(localScriptResolved, "utf-8");
if (debug) {
console.error(`[run] Using local script: ${localScriptResolved}`);
}
} else {
const url = `https://openrouter.ai/labs/spawn/${cloud}/${agent}.sh`;
const ghUrl = `${RAW_BASE}/sh/${cloud}/${agent}.sh`;
const dlResult = await asyncTryCatch(() => downloadScriptWithFallback(url, ghUrl));
if (!dlResult.ok) {
reportDownloadError(ghUrl, dlResult.error);
return false;
}
scriptContent = dlResult.data;
}
const lastErr = runBashScript(scriptContent, prompt, dashboardUrl, debug, spawnName);
if (lastErr) {
reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl, spawnName);
return false;
}
return true;
}
// ── Headless Mode ────────────────────────────────────────────────────────────
/** Exit codes for headless mode:
* 0 = success
* 1 = script execution error (provisioning/setup failed)
* 2 = script download error (network/404)
* 3 = validation error (bad inputs, missing credentials) */
export interface HeadlessOptions {
prompt?: string;
debug?: boolean;
outputFormat?: string;
spawnName?: string;
}
interface SpawnResult {
status: "success" | "error";
cloud: string;
agent: string;
server_id?: string;
server_name?: string;
ip_address?: string;
ssh_user?: string;
error_message?: string;
error_code?: string;
cli_updated?: boolean;
}
function headlessOutput(result: SpawnResult, outputFormat?: string): void {
if (outputFormat === "json") {
console.log(JSON.stringify(result));
} else {
// Plain text output for headless without --output json
if (result.status === "success") {
console.error(`Success: ${result.agent} on ${result.cloud}`);
if (result.ip_address) {
console.error(` IP: ${result.ip_address}`);
}
if (result.ssh_user) {
console.error(` User: ${result.ssh_user}`);
}
if (result.server_id) {
console.error(` Server ID: ${result.server_id}`);
}
} else {
console.error(`Error: ${result.error_message}`);
}
}
}
function headlessError(
agent: string,
cloud: string,
errorCode: string,
errorMessage: string,
outputFormat?: string,
exitCode = 1,
): never {
headlessOutput(
{
status: "error",
cloud,
agent,
error_code: errorCode,
error_message: errorMessage,
},
outputFormat,
);
process.exit(exitCode);
}
/**
* Resolve a trusted local Spawn checkout path for SPAWN_CLI_DIR.
*
* On macOS, `/tmp` commonly resolves to `/private/tmp`, so compare against
* the checkout's real path instead of the raw env var spelling.
*/
function resolveTrustedCliDir(cliDir: string): string {
const resolvedCliDir = path.resolve(cliDir);
const realCliDir = tryCatchIf(isFileError, () => fs.realpathSync(resolvedCliDir));
return realCliDir.ok ? realCliDir.data : resolvedCliDir;
}
/**
* Resolve a checked-in shell wrapper from a trusted local Spawn checkout.
*
* This lets unreleased provider work run from the current branch instead of
* depending on the CDN / raw GitHub copy being published already.
*/
function resolveLocalWrapperScript(cliDir: string, cloud: string, agent: string): string {
const hasBadChars = (s: string) => s.includes("..") || s.includes("/") || s.includes("\\");
if (hasBadChars(cloud) || hasBadChars(agent)) {
return "";
}
const resolvedCliDir = resolveTrustedCliDir(cliDir);
const candidatePath = path.join(resolvedCliDir, "sh", cloud, `${agent}.sh`);
const realResult = tryCatchIf(isFileError, () => fs.realpathSync(candidatePath));
if (!realResult.ok) {
return "";
}
const prefix = resolvedCliDir.endsWith(path.sep) ? resolvedCliDir : resolvedCliDir + path.sep;
if (!realResult.data.startsWith(prefix)) {
return "";
}
return realResult.data;
}
/** Run a script in headless mode (all output to stderr, no interactive session) */
function runScriptHeadless(script: string, prompt?: string, debug?: boolean, spawnName?: string): Promise<number> {
validateScriptContent(script);
const env = {
...process.env,
};
env.SPAWN_HEADLESS = "1";
env.SPAWN_MODE = "non-interactive";
env.SPAWN_NON_INTERACTIVE = "1";
if (prompt) {
env.SPAWN_PROMPT = prompt;
}
if (debug) {
env.SPAWN_DEBUG = "1";
}
if (spawnName) {
env.SPAWN_NAME = spawnName;
env.SPAWN_NAME_KEBAB = toKebabCase(spawnName);
}
const [shell, flag] = getLocalShell();
return new Promise<number>((resolve, reject) => {
const child = spawn(
shell,
[
flag,
script,
],
{
stdio: [
"ignore",
"pipe",
"inherit",
],
env,
},
);
// Forward stdout to stderr so JSON output stays clean on stdout
if (child.stdout) {
child.stdout.pipe(process.stderr);
}
child.on("close", (code: number | null) => {
resolve(code ?? 1);
});
child.on("error", reject);
});
}
/** Run a JS bundle with bun in headless mode (Windows — no bash wrapper) */
function runBundleHeadless(
bundlePath: string,
agent: string,
prompt?: string,
debug?: boolean,
spawnName?: string,
): Promise<number> {
const env: Record<string, string | undefined> = {
...process.env,
};
env.SPAWN_HEADLESS = "1";
env.SPAWN_MODE = "non-interactive";
env.SPAWN_NON_INTERACTIVE = "1";
if (prompt) {
env.SPAWN_PROMPT = prompt;
}
if (debug) {
env.SPAWN_DEBUG = "1";
}
if (spawnName) {
env.SPAWN_NAME = spawnName;
env.SPAWN_NAME_KEBAB = toKebabCase(spawnName);
}
return new Promise<number>((resolve, reject) => {
const child = spawn(
"bun",
[
"run",
bundlePath,
agent,
],
{
stdio: [
"ignore",
"pipe",
"inherit",
],
env,
},
);
if (child.stdout) {
child.stdout.pipe(process.stderr);
}
child.on("close", (code: number | null) => {
resolve(code ?? 1);
});
child.on("error", reject);
});
}
export async function cmdRunHeadless(agent: string, cloud: string, opts: HeadlessOptions = {}): Promise<void> {
const { prompt, debug, outputFormat, spawnName } = opts;
// Phase 1: Validate inputs (exit code 3)
const validationResult = tryCatch(() => {
validateIdentifier(agent, "Agent name");
validateIdentifier(cloud, "Cloud name");
if (prompt) {
validatePrompt(prompt);
}
});
if (!validationResult.ok) {
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);
}
if (!manifest.clouds[resolvedCloud]) {
headlessError(resolvedAgent, resolvedCloud, "UNKNOWN_CLOUD", `Unknown cloud: ${resolvedCloud}`, outputFormat, 3);
}
const matrixKey = `${resolvedCloud}/${resolvedAgent}`;
if (manifest.matrix[matrixKey] !== "implemented") {
headlessError(
resolvedAgent,
resolvedCloud,
"NOT_IMPLEMENTED",
`${resolvedAgent} on ${resolvedCloud} is not implemented`,
outputFormat,
3,
);
}
// Check credentials upfront
const cloudAuth = manifest.clouds[resolvedCloud].auth;
if (cloudAuth.toLowerCase() !== "none") {
const authVars = parseAuthEnvVars(cloudAuth);
const missing = collectMissingCredentials(authVars, resolvedCloud);
if (missing.length > 0) {
headlessError(
resolvedAgent,
resolvedCloud,
"MISSING_CREDENTIALS",
`Missing required credentials: ${missing.join(", ")}`,
outputFormat,
3,
);
}
}
// Phase 2+3: Load and execute
let exitCode: number;
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;
}
}
}
}
if (debug) {
console.error(`[headless] Executing ${resolvedAgent} on ${resolvedCloud} (Windows bundle mode)...`);
}
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));
}
} 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}`);
}
} 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();
});
if (!fetchResult.ok) {
headlessError(
resolvedAgent,
resolvedCloud,
"DOWNLOAD_ERROR",
`Failed to download script: ${getErrorMessage(fetchResult.error)}`,
outputFormat,
2,
);
}
scriptContent = fetchResult.data;
}
if (debug) {
console.error(`[headless] Executing ${resolvedAgent} on ${resolvedCloud}...`);
}
exitCode = await runScriptHeadless(scriptContent, prompt, debug, spawnName);
}
if (exitCode !== 0) {
headlessError(
resolvedAgent,
resolvedCloud,
"EXECUTION_ERROR",
`Script exited with code ${exitCode}`,
outputFormat,
1,
);
}
// 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.
const history = loadHistory();
const record = history
.filter((r) => r.agent === resolvedAgent && r.cloud === resolvedCloud && r.connection && !r.connection.deleted)
.pop();
const connectionFields: Partial<Pick<SpawnResult, "ip_address" | "ssh_user" | "server_id" | "server_name">> = {};
if (record?.connection) {
const conn = record.connection;
if (conn.ip && tryCatch(() => validateConnectionIP(conn.ip)).ok) {
connectionFields.ip_address = conn.ip;
}
if (conn.user && tryCatch(() => validateUsername(conn.user)).ok) {
connectionFields.ssh_user = conn.user;
}
const serverId = conn.server_id;
if (serverId && tryCatch(() => validateServerIdentifier(serverId)).ok) {
connectionFields.server_id = serverId;
}
const serverName = conn.server_name;
if (serverName && tryCatch(() => validateServerIdentifier(serverName)).ok) {
connectionFields.server_name = serverName;
}
}
const result: SpawnResult = {
status: "success",
cloud: resolvedCloud,
agent: resolvedAgent,
...connectionFields,
...(process.env.SPAWN_CLI_UPDATED === "1"
? {
cli_updated: true,
}
: {}),
};
headlessOutput(result, outputFormat);
}
// ── cmdRun ───────────────────────────────────────────────────────────────────
export async function cmdRun(
agent: string,
cloud: string,
prompt?: string,
dryRun?: boolean,
debug?: boolean,
): Promise<void> {
const manifest = await loadManifestWithSpinner();
({ agent, cloud } = resolveAndLog(manifest, agent, cloud));
validateRunSecurity(agent, cloud, prompt);
({ agent, cloud } = detectAndFixSwappedArgs(manifest, agent, cloud));
validateEntities(manifest, agent, cloud);
if (dryRun) {
showDryRunPreview(manifest, agent, cloud, prompt);
return;
}
await preflightCredentialCheck(manifest, cloud);
// Skip setup prompt if steps already set via --steps or --config
if (!process.env.SPAWN_ENABLED_STEPS) {
const enabledSteps = await promptSetupOptions(agent);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
}
}
const spawnName = await promptSpawnName();
// If a name was given, check whether an active instance with that name already
// exists for this agent + cloud combination. When it does, route the user into
// the same action picker they get from `spawn ls` instead of blindly creating a
// second VM.
if (spawnName) {
const activeServers = getActiveServers();
const existingRecord = activeServers.find((r) => r.name === spawnName && r.agent === agent && r.cloud === cloud);
if (existingRecord) {
p.log.warn(
`An active instance named ${pc.bold(spawnName)} already exists on ${pc.bold(manifest.clouds[cloud].name)}.`,
);
await handleRecordAction(existingRecord, manifest);
return;
}
}
const agentName = manifest.agents[agent].name;
const cloudName = manifest.clouds[cloud].name;
const suffix = prompt ? " with prompt..." : "...";
p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}${suffix}`);
const success = await execScript(
cloud,
agent,
prompt,
getAuthHint(manifest, cloud),
manifest.clouds[cloud].url,
debug,
spawnName,
);
if (success) {
maybeShowStarPrompt();
}
}