diff --git a/packages/cli/src/__tests__/history-spawn-id.test.ts b/packages/cli/src/__tests__/history-spawn-id.test.ts index c0079e98..4160b188 100644 --- a/packages/cli/src/__tests__/history-spawn-id.test.ts +++ b/packages/cli/src/__tests__/history-spawn-id.test.ts @@ -3,7 +3,6 @@ * * Verifies that: * - Every saved record gets a unique id - * - saveVmConnection matches by spawnId (not heuristic) * - saveLaunchCmd matches by spawnId (not heuristic) * - removeRecord / markRecordDeleted match by id * - Concurrent spawns on the same cloud don't cross-contaminate @@ -13,20 +12,17 @@ import type { SpawnRecord } from "../history.js"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { generateSpawnId, - getActiveServers, - getConnectionPath, getHistoryPath, loadHistory, markRecordDeleted, removeRecord, saveLaunchCmd, saveSpawnRecord, - saveVmConnection, } from "../history.js"; describe("history spawn IDs", () => { @@ -120,94 +116,27 @@ describe("history spawn IDs", () => { expect(history).toHaveLength(2); expect(history[0].id).not.toBe(history[1].id); }); - }); - // ── saveVmConnection matches by spawnId ────────────────────────────── - - describe("saveVmConnection with spawnId", () => { - it("attaches connection to the correct record by spawnId", () => { - const id1 = generateSpawnId(); - const id2 = generateSpawnId(); - - // Save two records for the same cloud - saveSpawnRecord({ - id: id1, - agent: "claude", - cloud: "gcp", - timestamp: "2026-01-01T00:00:00.000Z", - }); - saveSpawnRecord({ - id: id2, - agent: "codex", - cloud: "gcp", - timestamp: "2026-01-01T00:01:00.000Z", - }); - - // Attach connection to the FIRST record by id - saveVmConnection("1.2.3.4", "root", "srv-1", "my-server", "gcp", undefined, undefined, id1); - - const history = loadHistory(); - expect(history[0].connection?.ip).toBe("1.2.3.4"); - expect(history[0].connection?.server_name).toBe("my-server"); - // Second record should NOT have a connection - expect(history[1].connection).toBeUndefined(); - }); - - it("does not cross-contaminate concurrent spawns on the same cloud", () => { - const id1 = generateSpawnId(); - const id2 = generateSpawnId(); - - saveSpawnRecord({ - id: id1, - agent: "claude", - cloud: "hetzner", - timestamp: "2026-01-01T00:00:00.000Z", - }); - saveSpawnRecord({ - id: id2, - agent: "codex", - cloud: "hetzner", - timestamp: "2026-01-01T00:01:00.000Z", - }); - - // Each connection targets its own record - saveVmConnection("10.0.0.1", "root", "srv-a", "server-a", "hetzner", undefined, undefined, id1); - saveVmConnection("10.0.0.2", "root", "srv-b", "server-b", "hetzner", undefined, undefined, id2); - - const history = loadHistory(); - expect(history[0].connection?.ip).toBe("10.0.0.1"); - expect(history[0].connection?.server_name).toBe("server-a"); - expect(history[1].connection?.ip).toBe("10.0.0.2"); - expect(history[1].connection?.server_name).toBe("server-b"); - }); - - it("writes spawn_id to last-connection.json", () => { + it("saves connection data atomically with the record", () => { const id = generateSpawnId(); saveSpawnRecord({ id, agent: "claude", cloud: "gcp", timestamp: "2026-01-01T00:00:00.000Z", + connection: { + ip: "1.2.3.4", + user: "root", + server_name: "my-server", + cloud: "gcp", + }, }); - saveVmConnection("1.2.3.4", "root", "", "srv", "gcp", undefined, undefined, id); - - const connFile = JSON.parse(readFileSync(getConnectionPath(), "utf-8")); - expect(connFile.spawn_id).toBe(id); - }); - - it("falls back to heuristic when spawnId is not provided", () => { - saveSpawnRecord({ - id: generateSpawnId(), - agent: "claude", - cloud: "gcp", - timestamp: "2026-01-01T00:00:00.000Z", - }); - - // No spawnId — should match the most recent gcp record without connection - saveVmConnection("5.6.7.8", "user", "", "fallback-srv", "gcp"); const history = loadHistory(); - expect(history[0].connection?.ip).toBe("5.6.7.8"); + expect(history).toHaveLength(1); + expect(history[0].connection?.ip).toBe("1.2.3.4"); + expect(history[0].connection?.server_name).toBe("my-server"); + expect(history[0].connection?.cloud).toBe("gcp"); }); }); @@ -223,18 +152,26 @@ describe("history spawn IDs", () => { agent: "claude", cloud: "gcp", timestamp: "2026-01-01T00:00:00.000Z", + connection: { + ip: "1.1.1.1", + user: "root", + server_name: "srv1", + cloud: "gcp", + }, }); saveSpawnRecord({ id: id2, agent: "codex", cloud: "gcp", timestamp: "2026-01-01T00:01:00.000Z", + connection: { + ip: "2.2.2.2", + user: "root", + server_name: "srv2", + cloud: "gcp", + }, }); - // Attach connections to both - saveVmConnection("1.1.1.1", "root", "", "srv1", "gcp", undefined, undefined, id1); - saveVmConnection("2.2.2.2", "root", "", "srv2", "gcp", undefined, undefined, id2); - // Update launch command for the FIRST record only saveLaunchCmd("claude --start", id1); @@ -250,8 +187,13 @@ describe("history spawn IDs", () => { agent: "claude", cloud: "gcp", timestamp: "2026-01-01T00:00:00.000Z", + connection: { + ip: "1.1.1.1", + user: "root", + server_name: "srv", + cloud: "gcp", + }, }); - saveVmConnection("1.1.1.1", "root", "", "srv", "gcp", undefined, undefined, id); saveLaunchCmd("fallback-cmd"); @@ -369,18 +311,28 @@ describe("history spawn IDs", () => { agent: "claude", cloud: "gcp", timestamp: "2026-01-01T00:00:00.000Z", + connection: { + ip: "1.1.1.1", + user: "root", + server_id: "srv1", + server_name: "server1", + cloud: "gcp", + }, }); saveSpawnRecord({ id: id2, agent: "codex", cloud: "gcp", timestamp: "2026-01-01T00:01:00.000Z", + connection: { + ip: "2.2.2.2", + user: "root", + server_id: "srv2", + server_name: "server2", + cloud: "gcp", + }, }); - // Attach connections to both - saveVmConnection("1.1.1.1", "root", "srv1", "server1", "gcp", undefined, undefined, id1); - saveVmConnection("2.2.2.2", "root", "srv2", "server2", "gcp", undefined, undefined, id2); - // Mark only the first as deleted const result = markRecordDeleted({ id: id1, @@ -414,71 +366,4 @@ describe("history spawn IDs", () => { expect(result).toBe(false); }); }); - - // ── mergeLastConnection uses spawn_id ───────────────────────────────── - - describe("mergeLastConnection via getActiveServers", () => { - it("merges connection to correct record using spawn_id in last-connection.json", () => { - const id1 = generateSpawnId(); - const id2 = generateSpawnId(); - - saveSpawnRecord({ - id: id1, - agent: "claude", - cloud: "gcp", - timestamp: "2026-01-01T00:00:00.000Z", - }); - saveSpawnRecord({ - id: id2, - agent: "codex", - cloud: "gcp", - timestamp: "2026-01-01T00:01:00.000Z", - }); - - // Manually write last-connection.json with spawn_id targeting the second record - const connData = { - ip: "9.9.9.9", - user: "root", - server_name: "targeted-srv", - cloud: "gcp", - spawn_id: id2, - }; - writeFileSync(getConnectionPath(), JSON.stringify(connData) + "\n"); - - // getActiveServers triggers mergeLastConnection - const servers = loadHistory(); - // Force merge by calling getActiveServers (it calls mergeLastConnection internally) - getActiveServers(); - - const history = loadHistory(); - // The first record should NOT have the connection - expect(history[0].connection).toBeUndefined(); - // The second record should have it - expect(history[1].connection?.ip).toBe("9.9.9.9"); - expect(history[1].connection?.server_name).toBe("targeted-srv"); - }); - - it("falls back to heuristic when last-connection.json has no spawn_id", () => { - const id1 = generateSpawnId(); - saveSpawnRecord({ - id: id1, - agent: "claude", - cloud: "gcp", - timestamp: "2026-01-01T00:00:00.000Z", - }); - - // Write last-connection.json WITHOUT spawn_id - const connData = { - ip: "8.8.8.8", - user: "root", - cloud: "gcp", - }; - writeFileSync(getConnectionPath(), JSON.stringify(connData) + "\n"); - - getActiveServers(); - - const history = loadHistory(); - expect(history[0].connection?.ip).toBe("8.8.8.8"); - }); - }); }); diff --git a/packages/cli/src/__tests__/orchestrate.test.ts b/packages/cli/src/__tests__/orchestrate.test.ts index 292ba4c0..c9b03311 100644 --- a/packages/cli/src/__tests__/orchestrate.test.ts +++ b/packages/cli/src/__tests__/orchestrate.test.ts @@ -44,11 +44,17 @@ function createMockCloud(overrides: Partial = {}): CloudOrche runner: mockRunner, authenticate: mock(() => Promise.resolve()), promptSize: mock(() => Promise.resolve()), - createServer: mock(() => Promise.resolve()), + createServer: mock(() => + Promise.resolve({ + ip: "10.0.0.1", + user: "root", + server_name: "test-server-1", + cloud: "testcloud", + }), + ), getServerName: mock(() => Promise.resolve("test-server-1")), waitForReady: mock(() => Promise.resolve()), interactiveSession: mock(() => Promise.resolve(0)), - saveLaunchCmd: mock(() => {}), ...overrides, }; } @@ -126,6 +132,12 @@ describe("runOrchestration", () => { }), createServer: mock(async () => { callOrder.push("createServer"); + return { + ip: "10.0.0.1", + user: "root", + server_name: "srv", + cloud: "testcloud", + }; }), waitForReady: mock(async () => { callOrder.push("waitForReady"); @@ -134,9 +146,6 @@ describe("runOrchestration", () => { callOrder.push("interactiveSession"); return 0; }), - saveLaunchCmd: mock(() => { - callOrder.push("saveLaunchCmd"); - }), }); const agent = createMockAgent({ install: mock(async () => { @@ -151,8 +160,7 @@ describe("runOrchestration", () => { expect(callOrder.indexOf("getServerName")).toBeLessThan(callOrder.indexOf("createServer")); expect(callOrder.indexOf("createServer")).toBeLessThan(callOrder.indexOf("waitForReady")); expect(callOrder.indexOf("waitForReady")).toBeLessThan(callOrder.indexOf("install")); - expect(callOrder.indexOf("install")).toBeLessThan(callOrder.indexOf("saveLaunchCmd")); - expect(callOrder.indexOf("saveLaunchCmd")).toBeLessThan(callOrder.indexOf("interactiveSession")); + expect(callOrder.indexOf("install")).toBeLessThan(callOrder.indexOf("interactiveSession")); stderrSpy.mockRestore(); exitSpy.mockRestore(); }); @@ -430,24 +438,23 @@ describe("runOrchestration", () => { exitSpy.mockRestore(); }); - // ── saveLaunchCmd ─────────────────────────────────────────────────── + // ── createServer returns VMConnection ──────────────────────────────── - it("saves the raw launch command (not the restart-wrapped one)", async () => { - const saveLaunchCmd = mock(() => {}); + it("createServer return value is used (VMConnection)", async () => { const cloud = createMockCloud({ cloudName: "hetzner", - saveLaunchCmd, - }); - const agent = createMockAgent({ - launchCmd: mock(() => "my-agent --start"), + createServer: mock(async () => ({ + ip: "5.5.5.5", + user: "root", + server_name: "my-hetzner", + cloud: "hetzner", + })), }); + const agent = createMockAgent(); await runOrchestrationSafe(cloud, agent, "testagent"); - expect(saveLaunchCmd).toHaveBeenCalledTimes(1); - const args = saveLaunchCmd.mock.calls[0]; - expect(args[0]).toBe("my-agent --start"); - expect(typeof args[1]).toBe("string"); // spawnId + expect(cloud.createServer).toHaveBeenCalledTimes(1); stderrSpy.mockRestore(); exitSpy.mockRestore(); }); diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index 66d6aecd..969bf324 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -1,11 +1,11 @@ // aws/aws.ts — Core AWS Lightsail provider: auth, provisioning, SSH execution +import type { VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents"; import { createHash, createHmac } from "node:crypto"; import { existsSync, mkdirSync, readFileSync } from "node:fs"; import * as v from "valibot"; -import { saveVmConnection } from "../history.js"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; import { parseJsonWith } from "../shared/parse"; @@ -828,7 +828,7 @@ function getCloudInitUserdata(tier: CloudInitTier = "full"): string { // ─── Provisioning ─────────────────────────────────────────────────────────── -export async function createInstance(name: string, tier?: CloudInitTier): Promise { +export async function createInstance(name: string, tier?: CloudInitTier): Promise { const bundle = _state.selectedBundle; const region = _state.region; const az = `${region}a`; @@ -886,7 +886,7 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis ]); _state.instanceName = name; logInfo(`Instance creation initiated: ${name}`); - return; + return await waitForInstance(); } } else { showNonBillingError("aws", [ @@ -937,7 +937,7 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis ); _state.instanceName = name; logInfo(`Instance creation initiated: ${name}`); - return; + return await waitForInstance(); } } else { showNonBillingError("aws", [ @@ -954,11 +954,14 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis _state.instanceName = name; logInfo(`Instance creation initiated: ${name}`); + + // Wait for instance to become running and get IP + return await waitForInstance(); } // ─── Wait for Instance ────────────────────────────────────────────────────── -export async function waitForInstance(maxAttempts = 60): Promise { +export async function waitForInstance(maxAttempts = 60): Promise { logStep("Waiting for instance to become running..."); const pollDelay = 5000; @@ -1024,18 +1027,12 @@ export async function waitForInstance(maxAttempts = 60): Promise { logStepDone(); logInfo(`Instance running: IP=${_state.instanceIp}`); - // Save connection info - saveVmConnection( - _state.instanceIp, - SSH_USER, - "", - _state.instanceName, - "aws", - undefined, - undefined, - process.env.SPAWN_ID || undefined, - ); - return; + return { + ip: _state.instanceIp, + user: SSH_USER, + server_name: _state.instanceName, + cloud: "aws", + }; } logStepInline(`Instance state: ${state || "pending"} (${attempt}/${maxAttempts})`); diff --git a/packages/cli/src/aws/main.ts b/packages/cli/src/aws/main.ts index ed81a812..7fcf4d3c 100644 --- a/packages/cli/src/aws/main.ts +++ b/packages/cli/src/aws/main.ts @@ -4,7 +4,6 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; -import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; @@ -21,7 +20,6 @@ import { runServer, uploadFile, waitForCloudInit, - waitForInstance, } from "./aws"; async function main() { @@ -52,17 +50,14 @@ async function main() { async promptSize() { // Bundle selection handled during authenticate() }, - async createServer(name: string, spawnId?: string) { - process.env.SPAWN_ID = spawnId || ""; - await createInstance(name, agent.cloudInitTier); + async createServer(name: string) { + return await createInstance(name, agent.cloudInitTier); }, getServerName, async waitForReady() { - await waitForInstance(); await waitForCloudInit(); }, interactiveSession, - saveLaunchCmd: (cmd: string, sid?: string) => saveLaunchCmd(cmd, sid), }; await runOrchestration(cloud, agent, agentName); diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 9874b4b3..0a30dacd 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -8,14 +8,7 @@ import pc from "picocolors"; import { buildDashboardHint, EXIT_CODE_GUIDANCE, SIGNAL_GUIDANCE } from "../guidance-data.js"; import { generateSpawnId, getActiveServers, 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 { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js"; import { prepareStdinForHandoff, toKebabCase } from "../shared/ui.js"; import { promptSpawnName } from "./interactive.js"; import { handleRecordAction } from "./list.js"; @@ -910,79 +903,10 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles ); } - // Read connection info from last-connection.json - const { getConnectionPath } = await import("../history.js"); - const connectionInfo: { - ip?: string; - user?: string; - server_id?: string; - server_name?: string; - } = {}; - try { - const connPath = getConnectionPath(); - const { readFileSync, existsSync } = await import("node:fs"); - if (existsSync(connPath)) { - const raw = JSON.parse(readFileSync(connPath, "utf-8")); - - try { - // SECURITY: Validate connection fields before including in output - // Prevents injection via tampered last-connection.json files - if (raw.ip) { - validateConnectionIP(raw.ip); - connectionInfo.ip = raw.ip; - } - if (raw.user) { - validateUsername(raw.user); - connectionInfo.user = raw.user; - } - if (raw.server_id) { - validateServerIdentifier(raw.server_id); - connectionInfo.server_id = raw.server_id; - } - if (raw.server_name) { - validateServerIdentifier(raw.server_name); - connectionInfo.server_name = raw.server_name; - } - } catch (validationErr) { - // Validation failure is a security issue - report via headless error - headlessError( - resolvedAgent, - resolvedCloud, - "VALIDATION_ERROR", - `Connection info validation failed: ${getErrorMessage(validationErr)}`, - outputFormat, - 1, - ); - } - } - } catch { - // File read/parse errors - not fatal, just omit connection info - } - const result: SpawnResult = { status: "success", cloud: resolvedCloud, agent: resolvedAgent, - ...(connectionInfo.ip - ? { - ip_address: connectionInfo.ip, - } - : {}), - ...(connectionInfo.user - ? { - ssh_user: connectionInfo.user, - } - : {}), - ...(connectionInfo.server_id - ? { - server_id: connectionInfo.server_id, - } - : {}), - ...(connectionInfo.server_name - ? { - server_name: connectionInfo.server_name, - } - : {}), }; headlessOutput(result, outputFormat); diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 8dea23c1..c6440ddd 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -1,9 +1,9 @@ // digitalocean/digitalocean.ts — Core DigitalOcean provider: API, auth, SSH, provisioning +import type { VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents"; import { mkdirSync, readFileSync } from "node:fs"; -import { saveVmConnection } from "../history.js"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; import { OAUTH_CSS } from "../shared/oauth"; @@ -830,7 +830,7 @@ export async function createServer( dropletSize?: string, region?: string, snapshotId?: string, -): Promise { +): Promise { const size = dropletSize || process.env.DO_DROPLET_SIZE || "s-2vcpu-2gb"; const effectiveRegion = region || process.env.DO_REGION || "nyc3"; @@ -887,17 +887,13 @@ export async function createServer( _state.dropletId = String(retryData.droplet.id); logInfo(`Droplet created: ID=${_state.dropletId}`); await waitForDropletActive(_state.dropletId); - saveVmConnection( - _state.serverIp, - "root", - _state.dropletId, - name, - "digitalocean", - undefined, - undefined, - process.env.SPAWN_ID || undefined, - ); - return; + return { + ip: _state.serverIp, + user: "root", + server_id: _state.dropletId, + server_name: name, + cloud: "digitalocean", + }; } const retryErr = String(retryData?.message || "Unknown error"); logError(`Retry failed: ${retryErr}`); @@ -917,16 +913,13 @@ export async function createServer( // Wait for droplet to become active and get IP await waitForDropletActive(_state.dropletId); - saveVmConnection( - _state.serverIp, - "root", - _state.dropletId, - name, - "digitalocean", - undefined, - undefined, - process.env.SPAWN_ID || undefined, - ); + return { + ip: _state.serverIp, + user: "root", + server_id: _state.dropletId, + server_name: name, + cloud: "digitalocean", + }; } async function waitForDropletActive(dropletId: string, maxAttempts = 60): Promise { diff --git a/packages/cli/src/digitalocean/main.ts b/packages/cli/src/digitalocean/main.ts index c98ff520..c134b3be 100644 --- a/packages/cli/src/digitalocean/main.ts +++ b/packages/cli/src/digitalocean/main.ts @@ -4,7 +4,6 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; -import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; @@ -59,14 +58,13 @@ async function main() { dropletSize = await promptDropletSize(); region = await promptDoRegion(); }, - async createServer(name: string, spawnId?: string) { - process.env.SPAWN_ID = spawnId || ""; + async createServer(name: string) { // Check for a pre-built snapshot before provisioning snapshotId = await findSpawnSnapshot(agentName); if (snapshotId) { cloud.skipAgentInstall = true; } - await createDroplet(name, agent.cloudInitTier, dropletSize, region, snapshotId ?? undefined); + return await createDroplet(name, agent.cloudInitTier, dropletSize, region, snapshotId ?? undefined); }, getServerName, async waitForReady() { @@ -77,7 +75,6 @@ async function main() { } }, interactiveSession, - saveLaunchCmd: (cmd: string, sid?: string) => saveLaunchCmd(cmd, sid), }; await runOrchestration(cloud, agent, agentName); diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index ba68466a..ed2339fc 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -1,11 +1,11 @@ // gcp/gcp.ts — Core GCP Compute Engine provider: gcloud CLI wrapper, auth, provisioning, SSH +import type { VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { saveVmConnection } from "../history.js"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; import { @@ -720,7 +720,7 @@ export async function createInstance( zone: string, machineType: string, tier?: CloudInitTier, -): Promise { +): Promise { const username = resolveUsername(); const pubKeys = await ensureSshKey(); // Build ssh-keys metadata: one "user:key" entry per line @@ -842,20 +842,16 @@ export async function createInstance( logInfo(`Instance created: IP=${_state.serverIp}`); - // Save connection info with zone/project for later deletion - saveVmConnection( - _state.serverIp, - username, - "", - name, - "gcp", - undefined, - { + return { + ip: _state.serverIp, + user: username, + server_name: name, + cloud: "gcp", + metadata: { zone, project: _state.project, }, - process.env.SPAWN_ID || undefined, - ); + }; } // ─── SSH Operations ───────────────────────────────────────────────────────── diff --git a/packages/cli/src/gcp/main.ts b/packages/cli/src/gcp/main.ts index 3a8f5028..9433d3a1 100644 --- a/packages/cli/src/gcp/main.ts +++ b/packages/cli/src/gcp/main.ts @@ -4,7 +4,6 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; -import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; @@ -57,16 +56,14 @@ async function main() { machineType = await promptMachineType(); zone = await promptZone(); }, - async createServer(name: string, spawnId?: string) { - process.env.SPAWN_ID = spawnId || ""; - await createInstance(name, zone, machineType, agent.cloudInitTier); + async createServer(name: string) { + return await createInstance(name, zone, machineType, agent.cloudInitTier); }, getServerName, async waitForReady() { await waitForCloudInit(); }, interactiveSession, - saveLaunchCmd: (cmd: string, sid?: string) => saveLaunchCmd(cmd, sid), }; await runOrchestration(cloud, agent, agentName); diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index aa52a283..7954a696 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -1,9 +1,9 @@ // hetzner/hetzner.ts — Core Hetzner Cloud provider: API, auth, SSH, provisioning +import type { VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents"; import { mkdirSync, readFileSync } from "node:fs"; -import { saveVmConnection } from "../history.js"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; import { parseJsonObj } from "../shared/parse"; @@ -389,7 +389,7 @@ export async function createServer( serverType?: string, location?: string, tier?: CloudInitTier, -): Promise { +): Promise { const sType = serverType || process.env.HETZNER_SERVER_TYPE || DEFAULT_SERVER_TYPE; const loc = location || process.env.HETZNER_LOCATION || "fsn1"; const image = "ubuntu-24.04"; @@ -443,17 +443,13 @@ export async function createServer( _state.serverIp = isString(retryIpv4?.ip) ? retryIpv4.ip : ""; if (_state.serverId && _state.serverId !== "null" && _state.serverIp && _state.serverIp !== "null") { logInfo(`Server created: ID=${_state.serverId}, IP=${_state.serverIp}`); - saveVmConnection( - _state.serverIp, - "root", - _state.serverId, - name, - "hetzner", - undefined, - undefined, - process.env.SPAWN_ID || undefined, - ); - return; + return { + ip: _state.serverIp, + user: "root", + server_id: _state.serverId, + server_name: name, + cloud: "hetzner", + }; } } const retryErr = String(toRecord(retryData?.error)?.message || "Unknown error"); @@ -483,16 +479,13 @@ export async function createServer( } logInfo(`Server created: ID=${_state.serverId}, IP=${_state.serverIp}`); - saveVmConnection( - _state.serverIp, - "root", - _state.serverId, - name, - "hetzner", - undefined, - undefined, - process.env.SPAWN_ID || undefined, - ); + return { + ip: _state.serverIp, + user: "root", + server_id: _state.serverId, + server_name: name, + cloud: "hetzner", + }; } // ─── SSH Execution ─────────────────────────────────────────────────────────── diff --git a/packages/cli/src/hetzner/main.ts b/packages/cli/src/hetzner/main.ts index 35565bdc..07739484 100644 --- a/packages/cli/src/hetzner/main.ts +++ b/packages/cli/src/hetzner/main.ts @@ -4,7 +4,6 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; -import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; @@ -51,16 +50,14 @@ async function main() { serverType = await promptServerType(); location = await promptLocation(); }, - async createServer(name: string, spawnId?: string) { - process.env.SPAWN_ID = spawnId || ""; - await createHetznerServer(name, serverType, location, agent.cloudInitTier); + async createServer(name: string) { + return await createHetznerServer(name, serverType, location, agent.cloudInitTier); }, getServerName, async waitForReady() { await waitForCloudInit(); }, interactiveSession, - saveLaunchCmd: (cmd: string, sid?: string) => saveLaunchCmd(cmd, sid), }; await runOrchestration(cloud, agent, agentName); diff --git a/packages/cli/src/history.ts b/packages/cli/src/history.ts index 4723e103..276e98fc 100644 --- a/packages/cli/src/history.ts +++ b/packages/cli/src/history.ts @@ -3,8 +3,6 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from " import { homedir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import * as v from "valibot"; -import { validateConnectionIP, validateLaunchCmd, validateServerIdentifier, validateUsername } from "./security.js"; -import { getErrorMessage, isString } from "./shared/type-guards"; export interface VMConnection { ip: string; @@ -98,10 +96,6 @@ export function getHistoryPath(): string { return join(getSpawnDir(), "history.json"); } -export function getConnectionPath(): string { - return join(getSpawnDir(), "last-connection.json"); -} - /** Write history records to disk in v1 format: { version: 1, records: [...] } */ function writeHistory(records: SpawnRecord[]): void { writeFileSync( @@ -120,89 +114,6 @@ function writeHistory(records: SpawnRecord[]): void { ); } -/** Save VM connection info directly into history.json. - * Matches by spawnId for exact targeting. Falls back to heuristic matching - * for backward compatibility with records that have no id. */ -export function saveVmConnection( - ip: string, - user: string, - serverId: string, - serverName: string, - cloud: string, - launchCmd?: string, - metadata?: Record, - spawnId?: string, -): void { - const dir = getSpawnDir(); - mkdirSync(dir, { - recursive: true, - mode: 0o700, - }); - - const connData: VMConnection = { - ip, - user, - server_id: serverId || undefined, - server_name: serverName || undefined, - cloud: cloud || undefined, - launch_cmd: launchCmd || undefined, - metadata: metadata && Object.keys(metadata).length > 0 ? metadata : undefined, - }; - - const history = loadHistory(); - let merged = false; - - if (spawnId) { - // Exact match by spawn ID - const idx = history.findIndex((r) => r.id === spawnId); - if (idx >= 0) { - history[idx].connection = connData; - merged = true; - } - } else { - // Fallback: heuristic match for backward compatibility - for (let i = history.length - 1; i >= 0; i--) { - const r = history[i]; - if (r.cloud === cloud && !r.connection) { - r.connection = connData; - merged = true; - break; - } - } - } - - if (merged) { - writeHistory(history); - } - - // Also write last-connection.json for backward compatibility - const json: Record = { - ip, - user, - }; - if (serverId) { - json.server_id = serverId; - } - if (serverName) { - json.server_name = serverName; - } - if (cloud) { - json.cloud = cloud; - } - if (launchCmd) { - json.launch_cmd = launchCmd; - } - if (metadata && Object.keys(metadata).length > 0) { - json.metadata = metadata; - } - if (spawnId) { - json.spawn_id = spawnId; - } - writeFileSync(join(dir, "last-connection.json"), JSON.stringify(json) + "\n", { - mode: 0o600, - }); -} - /** Save launch command to a history record's connection. * Matches by spawnId when provided; falls back to most recent record with a connection. */ export function saveLaunchCmd(launchCmd: string, spawnId?: string): void { @@ -234,18 +145,6 @@ export function saveLaunchCmd(launchCmd: string, spawnId?: string): void { } catch { // non-fatal } - - // Also update last-connection.json for backward compatibility - const connFile = getConnectionPath(); - try { - const data = JSON.parse(readFileSync(connFile, "utf-8")); - data.launch_cmd = launchCmd; - writeFileSync(connFile, JSON.stringify(data) + "\n", { - mode: 0o600, - }); - } catch { - // non-fatal - } } export function loadHistory(): SpawnRecord[] { @@ -366,99 +265,6 @@ export function clearHistory(): number { return count; } -/** Check for pending connection data and merge it into the last history entry. - * Bash scripts write connection info to last-connection.json after successful spawn. - * This function merges that data into the history and persists it. */ -function mergeLastConnection(): void { - const connPath = getConnectionPath(); - if (!existsSync(connPath)) { - return; - } - - try { - const raw: unknown = JSON.parse(readFileSync(connPath, "utf-8")); - if (!raw || typeof raw !== "object" || !("ip" in raw) || !("user" in raw)) { - unlinkSync(connPath); - return; - } - const entries = Object.fromEntries(Object.entries(raw)); - // Parse metadata if present - let metadata: Record | undefined; - if (entries.metadata && typeof entries.metadata === "object" && !Array.isArray(entries.metadata)) { - metadata = {}; - for (const [k, val] of Object.entries(entries.metadata)) { - if (isString(val)) { - metadata[k] = val; - } - } - if (Object.keys(metadata).length === 0) { - metadata = undefined; - } - } - - const connData: VMConnection = { - ip: String(entries.ip ?? ""), - user: String(entries.user ?? ""), - server_id: isString(entries.server_id) ? entries.server_id : undefined, - server_name: isString(entries.server_name) ? entries.server_name : undefined, - cloud: isString(entries.cloud) ? entries.cloud : undefined, - launch_cmd: isString(entries.launch_cmd) ? entries.launch_cmd : undefined, - metadata, - }; - - // SECURITY: Validate connection data before merging into history - // This prevents malicious bash scripts from injecting invalid data - try { - validateConnectionIP(connData.ip); - validateUsername(connData.user); - if (connData.server_id) { - validateServerIdentifier(connData.server_id); - } - if (connData.server_name) { - validateServerIdentifier(connData.server_name); - } - if (connData.launch_cmd) { - validateLaunchCmd(connData.launch_cmd); - } - } catch (err) { - // Log validation failure and skip merging - console.error(`Warning: Invalid connection data from bash script, skipping merge: ${getErrorMessage(err)}`); - unlinkSync(connPath); - return; - } - - const history = loadHistory(); - - // Match by spawn_id if present in the connection file, else fall back to - // heuristic matching (most recent entry without a connection). - const spawnId = isString(entries.spawn_id) ? entries.spawn_id : undefined; - let merged = false; - if (spawnId) { - const idx = history.findIndex((r) => r.id === spawnId); - if (idx >= 0) { - history[idx].connection = connData; - merged = true; - } - } else { - for (let i = history.length - 1; i >= 0; i--) { - if (!history[i].connection) { - history[i].connection = connData; - merged = true; - break; - } - } - } - if (merged) { - writeHistory(history); - } - - // Clean up the connection file after merging - unlinkSync(connPath); - } catch { - // Ignore errors - connection data is optional - } -} - /** Find a record's index by id, falling back to timestamp+agent+cloud for old records. */ function findRecordIndex(history: SpawnRecord[], record: SpawnRecord): number { if (record.id) { @@ -502,15 +308,11 @@ export function markRecordDeleted(record: SpawnRecord): boolean { } export function getActiveServers(): SpawnRecord[] { - mergeLastConnection(); const records = loadHistory(); return records.filter((r) => r.connection?.cloud && r.connection.cloud !== "local" && !r.connection.deleted); } export function filterHistory(agentFilter?: string, cloudFilter?: string): SpawnRecord[] { - // Merge any pending connection data before filtering - mergeLastConnection(); - let records = loadHistory(); if (agentFilter) { const lower = agentFilter.toLowerCase(); diff --git a/packages/cli/src/local/local.ts b/packages/cli/src/local/local.ts index 85e29082..fc49b5c4 100644 --- a/packages/cli/src/local/local.ts +++ b/packages/cli/src/local/local.ts @@ -3,7 +3,6 @@ import { copyFileSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { dirname } from "node:path"; -import { getSpawnDir } from "../history.js"; import { spawnInteractive } from "../shared/ssh"; // ─── Execution ─────────────────────────────────────────────────────────────── @@ -52,33 +51,3 @@ export async function interactiveSession(cmd: string): Promise { cmd, ]); } - -// ─── Connection Tracking ───────────────────────────────────────────────────── - -export function saveLocalConnection(): void { - const dir = getSpawnDir(); - mkdirSync(dir, { - recursive: true, - }); - const hostname = Bun.spawnSync( - [ - "hostname", - ], - { - stdio: [ - "ignore", - "pipe", - "ignore", - ], - }, - ); - const name = new TextDecoder().decode(hostname.stdout).trim() || "local"; - const user = process.env.USER || "unknown"; - const json = JSON.stringify({ - ip: "localhost", - user, - server_name: name, - cloud: "local", - }); - Bun.write(`${dir}/last-connection.json`, json + "\n"); -} diff --git a/packages/cli/src/local/main.ts b/packages/cli/src/local/main.ts index 101c7ec6..19cdb272 100644 --- a/packages/cli/src/local/main.ts +++ b/packages/cli/src/local/main.ts @@ -4,11 +4,10 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; -import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; -import { interactiveSession, runLocal, saveLocalConnection, uploadFile } from "./local"; +import { interactiveSession, runLocal, uploadFile } from "./local"; async function main() { const agentName = process.argv[2]; @@ -27,12 +26,14 @@ async function main() { runServer: runLocal, uploadFile: async (l: string, r: string) => uploadFile(l, r), }, - async authenticate() { - saveLocalConnection(); - }, + async authenticate() {}, async promptSize() {}, - async createServer(_name: string, spawnId?: string) { - process.env.SPAWN_ID = spawnId || ""; + async createServer(_name: string) { + return { + ip: "localhost", + user: process.env.USER || "local", + cloud: "local", + }; }, async getServerName() { const result = Bun.spawnSync( @@ -51,7 +52,6 @@ async function main() { }, async waitForReady() {}, interactiveSession, - saveLaunchCmd: (cmd: string, sid?: string) => saveLaunchCmd(cmd, sid), }; await runOrchestration(cloud, agent, agentName); diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 518e8df0..f930d797 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -1,10 +1,11 @@ // shared/orchestrate.ts — Shared orchestration pipeline for deploying agents // Each cloud implements CloudOrchestrator and calls runOrchestration(). +import type { VMConnection } from "../history.js"; import type { CloudRunner } from "./agent-setup"; import type { AgentConfig } from "./agents"; -import { generateSpawnId, saveSpawnRecord } from "../history.js"; +import { generateSpawnId, saveLaunchCmd, saveSpawnRecord } from "../history.js"; import { offerGithubAuth, wrapSshCall } from "./agent-setup"; import { tryTarballInstall } from "./agent-tarball"; import { generateEnvConfig } from "./agents"; @@ -20,11 +21,10 @@ export interface CloudOrchestrator { authenticate(): Promise; checkAccountReady?(): Promise; promptSize(): Promise; - createServer(name: string, spawnId?: string): Promise; + createServer(name: string): Promise; getServerName(): Promise; waitForReady(): Promise; interactiveSession(cmd: string): Promise; - saveLaunchCmd(launchCmd: string, spawnId?: string): void; } /** @@ -101,9 +101,9 @@ export async function runOrchestration( // 6. Provision server const spawnId = generateSpawnId(); const serverName = await cloud.getServerName(); - await cloud.createServer(serverName, spawnId); + const connection = await cloud.createServer(serverName); - // 6b. Record the spawn now that the server exists + // 6b. Record the spawn atomically with connection data const spawnName = process.env.SPAWN_NAME_KEBAB || process.env.SPAWN_NAME || undefined; saveSpawnRecord({ id: spawnId, @@ -115,6 +115,7 @@ export async function runOrchestration( name: spawnName, } : {}), + connection, }); // 7. Wait for readiness @@ -192,7 +193,7 @@ export async function runOrchestration( prepareStdinForHandoff(); const launchCmd = agent.launchCmd(); - cloud.saveLaunchCmd(launchCmd, spawnId); + saveLaunchCmd(launchCmd, spawnId); // Wrap in restart loop for cloud VMs — not for local execution const sessionCmd = cloud.cloudName === "local" ? launchCmd : wrapWithRestartLoop(launchCmd); diff --git a/packages/cli/src/sprite/main.ts b/packages/cli/src/sprite/main.ts index 124ba20d..dafc15c8 100644 --- a/packages/cli/src/sprite/main.ts +++ b/packages/cli/src/sprite/main.ts @@ -4,7 +4,6 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; -import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; @@ -13,10 +12,10 @@ import { ensureSpriteAuthenticated, ensureSpriteCli, getServerName, + getVmConnection, interactiveSession, promptSpawnName, runSprite, - saveVmConnection, setupShellEnvironment, uploadFileSprite, verifySpriteConnectivity, @@ -45,17 +44,15 @@ async function main() { await ensureSpriteAuthenticated(); }, async promptSize() {}, - async createServer(name: string, spawnId?: string) { - process.env.SPAWN_ID = spawnId || ""; + async createServer(name: string) { await createSprite(name); await verifySpriteConnectivity(); await setupShellEnvironment(); - saveVmConnection(); + return getVmConnection(); }, getServerName, async waitForReady() {}, interactiveSession, - saveLaunchCmd: (cmd: string, sid?: string) => saveLaunchCmd(cmd, sid), }; await runOrchestration(cloud, agent, agentName); diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index 230c02db..dfcea606 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -1,9 +1,10 @@ // sprite/sprite.ts — Core Sprite provider: CLI installation, auth, provisioning, execution +import type { VMConnection } from "../history.js"; + import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { saveVmConnection as saveVmConnectionToHistory } from "../history.js"; import { killWithTimeout, sleep, spawnInteractive } from "../shared/ssh"; import { getErrorMessage } from "../shared/type-guards"; import { @@ -433,17 +434,13 @@ export async function setupShellEnvironment(): Promise { // ─── Connection Tracking ───────────────────────────────────────────────────── -export function saveVmConnection(): void { - saveVmConnectionToHistory( - "sprite-console", - process.env.USER || "root", - "", - _state.name, - "sprite", - undefined, - undefined, - process.env.SPAWN_ID || undefined, - ); +export function getVmConnection(): VMConnection { + return { + ip: "sprite-console", + user: process.env.USER || "root", + server_name: _state.name, + cloud: "sprite", + }; } // ─── Execution ───────────────────────────────────────────────────────────────