diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 52760229..dbdd3539 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -1956,10 +1956,23 @@ async function handleRecordAction( const options: { value: string; label: string; hint?: string }[] = []; + // Prefer stored launch command (captured at spawn time), fall back to manifest + const agentDef = manifest?.agents?.[selected.agent]; + const launchCmd = conn.launch_cmd || agentDef?.launch; + + if (!conn.deleted && launchCmd) { + const agentName = agentDef?.name || selected.agent; + options.push({ + value: "enter", + label: `Enter ${agentName}`, + hint: agentDef?.launch || launchCmd, + }); + } + if (!conn.deleted) { options.push({ value: "reconnect", - label: "Reconnect to existing VM", + label: "SSH into VM", hint: conn.ip === "sprite-console" ? `sprite console -s ${conn.server_name}` : conn.ip === "fly-ssh" @@ -1993,6 +2006,16 @@ async function handleRecordAction( handleCancel(); } + if (action === "enter") { + try { + await cmdEnterAgent(selected.connection, selected.agent, manifest); + } catch (err) { + p.log.error(`Connection failed: ${getErrorMessage(err)}`); + p.log.info(`VM may no longer be running. Use ${pc.cyan(`spawn ${selected.agent}/${selected.cloud}`)} to start a new one.`); + } + return; + } + if (action === "reconnect") { try { await cmdConnect(selected.connection); @@ -2234,6 +2257,90 @@ async function cmdConnect(connection: VMConnection): Promise { ); } +/** SSH into a VM and launch the agent directly */ +async function cmdEnterAgent( + connection: VMConnection, + agentKey: string, + manifest: Manifest | null +): Promise { + // SECURITY: Validate all connection parameters before use + try { + validateConnectionIP(connection.ip); + validateUsername(connection.user); + if (connection.server_name) { + validateServerIdentifier(connection.server_name); + } + } catch (err) { + p.log.error(`Security validation failed: ${getErrorMessage(err)}`); + p.log.info(`Your spawn history file may be corrupted or tampered with.`); + p.log.info(`Location: ${getHistoryPath()}`); + p.log.info(`To fix: edit the file and remove the invalid entry, or run 'spawn list --clear'`); + process.exit(1); + } + + const agentDef = manifest?.agents?.[agentKey]; + + // Prefer the launch command stored at spawn time (captures dynamic state), + // fall back to manifest definition, then to agent key as last resort + const storedCmd = connection.launch_cmd; + let remoteCmd: string; + if (storedCmd) { + // Stored command already includes source ~/.spawnrc, PATH setup, etc. + remoteCmd = storedCmd; + } else { + const launchCmd = agentDef?.launch ?? agentKey; + const preLaunch = agentDef?.pre_launch; + const parts = ["source ~/.spawnrc 2>/dev/null"]; + if (preLaunch) parts.push(preLaunch); + parts.push(launchCmd); + remoteCmd = parts.join("; "); + } + + const agentName = agentDef?.name || agentKey; + + // Handle Sprite console connections + if (connection.ip === "sprite-console" && connection.server_name) { + p.log.step(`Entering ${pc.bold(agentName)} on sprite ${pc.bold(connection.server_name)}...`); + return runInteractiveCommand( + "sprite", + ["console", "-s", connection.server_name, "--", "bash", "-lc", remoteCmd], + `Failed to enter ${agentName}`, + `sprite console -s ${connection.server_name} -- bash -lc '${remoteCmd}'` + ); + } + + // Handle Fly.io SSH connections + if (connection.ip === "fly-ssh" && connection.server_name) { + p.log.step(`Entering ${pc.bold(agentName)} on Fly.io app ${pc.bold(connection.server_name)}...`); + return runInteractiveCommand( + "fly", + ["ssh", "console", "-a", connection.server_name, "--pty", "-C", remoteCmd], + `Failed to enter ${agentName}`, + `fly ssh console -a ${connection.server_name} --pty -C '${remoteCmd}'` + ); + } + + // Handle Daytona sandbox connections + if (connection.ip === "daytona-sandbox" && connection.server_id) { + p.log.step(`Entering ${pc.bold(agentName)} on Daytona sandbox ${pc.bold(connection.server_id)}...`); + return runInteractiveCommand( + "daytona", + ["ssh", connection.server_id, "--", "bash", "-lc", remoteCmd], + `Failed to enter ${agentName}`, + `daytona ssh ${connection.server_id} -- bash -lc '${remoteCmd}'` + ); + } + + // Standard SSH connection with agent launch + p.log.step(`Entering ${pc.bold(agentName)} on ${pc.bold(connection.ip)}...`); + return runInteractiveCommand( + "ssh", + ["-t", "-o", "StrictHostKeyChecking=accept-new", `${connection.user}@${connection.ip}`, "--", `bash -lc '${remoteCmd}'`], + `Failed to enter ${agentName}`, + `ssh -t ${connection.user}@${connection.ip} -- bash -lc '${remoteCmd}'` + ); +} + // ── Agents ───────────────────────────────────────────────────────────────────── export function getImplementedAgents(manifest: Manifest, cloud: string): string[] { diff --git a/cli/src/fly/fly.ts b/cli/src/fly/fly.ts index bea2fe77..55451ba0 100644 --- a/cli/src/fly/fly.ts +++ b/cli/src/fly/fly.ts @@ -233,6 +233,7 @@ export function saveVmConnection( serverId: string, serverName: string, cloud: string, + launchCmd?: string, ): void { const dir = `${process.env.HOME}/.spawn`; mkdirSync(dir, { recursive: true }); @@ -240,9 +241,22 @@ export function saveVmConnection( if (serverId) json.server_id = serverId; if (serverName) json.server_name = serverName; if (cloud) json.cloud = cloud; + if (launchCmd) json.launch_cmd = launchCmd; writeFileSync(`${dir}/last-connection.json`, JSON.stringify(json) + "\n"); } +/** Append launch_cmd to the last-connection.json file */ +export function saveLaunchCmd(launchCmd: string): void { + const connFile = `${process.env.HOME}/.spawn/last-connection.json`; + try { + const data = JSON.parse(readFileSync(connFile, "utf-8")); + data.launch_cmd = launchCmd; + writeFileSync(connFile, JSON.stringify(data) + "\n"); + } catch { + // Connection file may not exist — non-fatal + } +} + // ─── Authentication ────────────────────────────────────────────────────────── export async function ensureFlyCli(): Promise { diff --git a/cli/src/fly/main.ts b/cli/src/fly/main.ts index 3302f318..165a0fee 100644 --- a/cli/src/fly/main.ts +++ b/cli/src/fly/main.ts @@ -11,6 +11,7 @@ import { waitForCloudInit, runServer, interactiveSession, + saveLaunchCmd, listVolumes, FLY_VM_TIERS, DEFAULT_VM_TIER, @@ -185,7 +186,9 @@ async function main() { logStep("Starting agent..."); await new Promise((r) => setTimeout(r, 1000)); - const exitCode = await interactiveSession(agent.launchCmd()); + const launchCmd = agent.launchCmd(); + saveLaunchCmd(launchCmd); + const exitCode = await interactiveSession(launchCmd); process.exit(exitCode); } diff --git a/cli/src/history.ts b/cli/src/history.ts index 0ac579f5..a6f1f768 100644 --- a/cli/src/history.ts +++ b/cli/src/history.ts @@ -11,6 +11,7 @@ export interface VMConnection { cloud?: string; deleted?: boolean; deleted_at?: string; + launch_cmd?: string; metadata?: Record; } diff --git a/shared/common.sh b/shared/common.sh index b973865b..8602338b 100644 --- a/shared/common.sh +++ b/shared/common.sh @@ -1830,6 +1830,10 @@ spawn_agent() { log_info "${agent_name} is ready" local launch_cmd launch_cmd=$(agent_launch_cmd) + + # Save the launch command to connection file for `spawn list` → "Enter agent" + _save_launch_cmd "${launch_cmd}" + launch_session "$(cloud_label)" cloud_interactive "${launch_cmd}" } @@ -3765,6 +3769,25 @@ save_vm_connection() { printf '%s\n' "${json}" > "${conn_file}" } +# Append launch_cmd to an existing last-connection.json file. +# Called by spawn_agent after computing the agent's launch command. +# Usage: _save_launch_cmd LAUNCH_CMD +_save_launch_cmd() { + local cmd="${1:-}" + if [[ -z "${cmd}" ]]; then return 0; fi + + local conn_file="${HOME}/.spawn/last-connection.json" + if [[ ! -f "${conn_file}" ]]; then return 0; fi + + # Read existing JSON content and inject launch_cmd before the closing brace + local existing + existing=$(cat "${conn_file}") + # Strip trailing } and add launch_cmd field + existing="${existing%\}}" + existing="${existing},\"launch_cmd\":$(json_escape "${cmd}")}" + printf '%s\n' "${existing}" > "${conn_file}" +} + # ============================================================ # Auto-initialization # ============================================================