mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-08-16 12:03:36 +00:00
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:
parent
d9a25a4720
commit
05f744e052
17 changed files with 160 additions and 613 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue