fix: atomic single-save for history records (createServer returns VMConnection) (#2388)

The two-phase save architecture was fundamentally broken: saveVmConnection()
was called inside createServer() BEFORE saveSpawnRecord() created the record,
so the merge-by-spawnId silently failed every time — resulting in records
with no connection data and `spawn ls` showing nothing.

Replace with atomic single-save: createServer() now returns VMConnection,
and the orchestrator calls saveSpawnRecord() once with connection data
included. Removes saveVmConnection(), getConnectionPath(),
mergeLastConnection(), and last-connection.json entirely.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-03-09 14:32:45 -07:00 committed by GitHub
parent d9a25a4720
commit 05f744e052
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 160 additions and 613 deletions

View file

@ -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");
});
});
});

View file

@ -44,11 +44,17 @@ function createMockCloud(overrides: Partial<CloudOrchestrator> = {}): 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();
});

View file

@ -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<void> {
export async function createInstance(name: string, tier?: CloudInitTier): Promise<VMConnection> {
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<void> {
export async function waitForInstance(maxAttempts = 60): Promise<VMConnection> {
logStep("Waiting for instance to become running...");
const pollDelay = 5000;
@ -1024,18 +1027,12 @@ export async function waitForInstance(maxAttempts = 60): Promise<void> {
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})`);

View file

@ -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);

View file

@ -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);

View file

@ -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<void> {
): Promise<VMConnection> {
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<void> {

View file

@ -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);

View file

@ -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<void> {
): Promise<VMConnection> {
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 ─────────────────────────────────────────────────────────

View file

@ -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);

View file

@ -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<void> {
): Promise<VMConnection> {
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 ───────────────────────────────────────────────────────────

View file

@ -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);

View file

@ -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<string, string>,
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<string, unknown> = {
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<string, string> | 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();

View file

@ -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<number> {
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");
}

View file

@ -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);

View file

@ -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<void>;
checkAccountReady?(): Promise<void>;
promptSize(): Promise<void>;
createServer(name: string, spawnId?: string): Promise<void>;
createServer(name: string): Promise<VMConnection>;
getServerName(): Promise<string>;
waitForReady(): Promise<void>;
interactiveSession(cmd: string): Promise<number>;
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);

View file

@ -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);

View file

@ -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<void> {
// ─── 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 ───────────────────────────────────────────────────────────────