mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-09 11:10:10 +00:00
feat: add "Enter agent" option to spawn ls (#1662)
When selecting a previous spawn from `spawn ls`, the first option is now "Enter <agent>" which SSHes into the VM and launches the agent directly, instead of just opening a plain SSH shell. The exact launch command is captured at spawn time and stored in the connection record, so dynamic state (PATH setup, env sourcing) is preserved for reconnection. Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef7b67752e
commit
8eedcd8553
5 changed files with 150 additions and 2 deletions
|
|
@ -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<void> {
|
|||
);
|
||||
}
|
||||
|
||||
/** SSH into a VM and launch the agent directly */
|
||||
async function cmdEnterAgent(
|
||||
connection: VMConnection,
|
||||
agentKey: string,
|
||||
manifest: Manifest | null
|
||||
): Promise<void> {
|
||||
// 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[] {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface VMConnection {
|
|||
cloud?: string;
|
||||
deleted?: boolean;
|
||||
deleted_at?: string;
|
||||
launch_cmd?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue