mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
* fix: add unique spawn IDs to prevent history record corruption
History records were matched by heuristic ("most recent record for this
cloud without a connection"), which caused saveVmConnection and
saveLaunchCmd to overwrite the wrong record during concurrent or failed
spawns.
Fix: every SpawnRecord now has a unique `id` (UUID). All history
operations (saveVmConnection, saveLaunchCmd, removeRecord,
markRecordDeleted, mergeLastConnection) match by id when available,
falling back to the old heuristic for pre-migration records.
The orchestrator (TS path) now creates the history record AFTER server
creation succeeds, not before — so failed provisions don't leave orphan
entries.
Also adds "Remove from history" option to the spawn ls action picker,
restoring the ability to soft-delete entries without destroying the VM.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add 18 unit tests for spawn ID history behavior
Tests cover:
- generateSpawnId returns unique UUIDs
- saveSpawnRecord auto-generates id when not provided
- saveVmConnection matches by spawnId (not heuristic)
- saveVmConnection does not cross-contaminate concurrent spawns
- saveVmConnection falls back to heuristic without spawnId
- saveLaunchCmd matches by spawnId (not heuristic)
- saveLaunchCmd falls back without spawnId
- removeRecord matches by id, not by timestamp+agent+cloud
- removeRecord handles duplicate timestamps correctly
- removeRecord falls back for legacy records without id
- markRecordDeleted targets correct record by id
- mergeLastConnection uses spawn_id from last-connection.json
- mergeLastConnection falls back to heuristic without spawn_id
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: enable biome import sorting with grouped imports
Adds organizeImports to biome assist config with groups:
1. Type imports
2. Node built-ins
3. Third-party packages
4. @openrouter/* packages
5. Aliases
Auto-fixed import order and lint issues across all TypeScript files,
including .claude/skills/ and packages/cli/src/.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
374 lines
16 KiB
TypeScript
374 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
|
import { loadManifest } from "../manifest";
|
|
import { isString } from "../shared/type-guards";
|
|
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
|
|
|
|
/**
|
|
* Tests for commands/ error/validation paths that call process.exit(1).
|
|
*
|
|
* - cmdRun with invalid identifiers (injection characters, path traversal)
|
|
* - cmdRun with unknown agent or cloud names
|
|
* - cmdRun with unimplemented agent/cloud combinations
|
|
* - cmdRun with invalid prompts (command injection patterns)
|
|
* - cmdAgentInfo with unknown agent
|
|
* - cmdAgentInfo with invalid identifier
|
|
* - validateNonEmptyString triggering process.exit for empty inputs
|
|
* - validateImplementation showing available clouds when combination is missing
|
|
*/
|
|
|
|
const mockManifest = createMockManifest();
|
|
|
|
const {
|
|
logError: mockLogError,
|
|
logInfo: mockLogInfo,
|
|
logStep: mockLogStep,
|
|
logWarn: mockLogWarn,
|
|
spinnerStart: mockSpinnerStart,
|
|
spinnerStop: mockSpinnerStop,
|
|
} = mockClackPrompts();
|
|
|
|
// Import commands after @clack/prompts mock is set up
|
|
const { cmdRun, cmdAgentInfo } = await import("../commands.js");
|
|
|
|
describe("Commands Error Paths", () => {
|
|
let consoleMocks: ReturnType<typeof createConsoleMocks>;
|
|
let originalFetch: typeof global.fetch;
|
|
let processExitSpy: ReturnType<typeof spyOn>;
|
|
|
|
beforeEach(async () => {
|
|
consoleMocks = createConsoleMocks();
|
|
mockLogError.mockClear();
|
|
mockLogInfo.mockClear();
|
|
mockLogStep.mockClear();
|
|
mockLogWarn.mockClear();
|
|
mockSpinnerStart.mockClear();
|
|
mockSpinnerStop.mockClear();
|
|
|
|
// Mock process.exit to throw instead of exiting
|
|
processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => {
|
|
throw new Error("process.exit");
|
|
});
|
|
|
|
// Mock fetch to return our controlled manifest data
|
|
originalFetch = global.fetch;
|
|
global.fetch = mock(async () => new Response(JSON.stringify(mockManifest)));
|
|
|
|
// Force-refresh the manifest cache
|
|
await loadManifest(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
processExitSpy.mockRestore();
|
|
restoreMocks(consoleMocks.log, consoleMocks.error);
|
|
});
|
|
|
|
// ── cmdRun: identifier validation ─────────────────────────────────────
|
|
|
|
describe("cmdRun - identifier validation", () => {
|
|
it("should reject agent name with path traversal characters", async () => {
|
|
await expect(cmdRun("../etc/passwd", "sprite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject agent name with uppercase letters", async () => {
|
|
await expect(cmdRun("Claude", "sprite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject agent name with spaces", async () => {
|
|
await expect(cmdRun("claude code", "sprite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject agent name with shell metacharacters", async () => {
|
|
await expect(cmdRun("claude;rm", "sprite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject cloud name with path traversal", async () => {
|
|
await expect(cmdRun("claude", "../../root")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject cloud name with special characters", async () => {
|
|
await expect(cmdRun("claude", "spr$ite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject agent name exceeding 64 characters", async () => {
|
|
const longName = "a".repeat(65);
|
|
await expect(cmdRun(longName, "sprite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should accept agent name at exactly 64 characters", async () => {
|
|
const name64 = "a".repeat(64);
|
|
// This will pass identifier validation but fail at validateEntity (unknown agent)
|
|
await expect(cmdRun(name64, "sprite")).rejects.toThrow("process.exit");
|
|
// It should get past identifier validation -- the error should be from validateEntity
|
|
expect(mockLogError).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── cmdRun: unknown agent/cloud ───────────────────────────────────────
|
|
|
|
describe("cmdRun - unknown agent or cloud", () => {
|
|
it("should exit with error for unknown agent", async () => {
|
|
await expect(cmdRun("nonexistent", "sprite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
|
|
// Should show "Unknown agent" error via @clack/prompts log.error
|
|
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(errorCalls.some((msg: string) => msg.includes("Unknown agent"))).toBe(true);
|
|
});
|
|
|
|
it("should suggest spawn agents command for unknown agent", async () => {
|
|
await expect(cmdRun("nonexistent", "sprite")).rejects.toThrow("process.exit");
|
|
|
|
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(infoCalls.some((msg: string) => msg.includes("spawn agents"))).toBe(true);
|
|
});
|
|
|
|
it("should exit with error for unknown cloud", async () => {
|
|
await expect(cmdRun("claude", "nonexistent")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
|
|
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(errorCalls.some((msg: string) => msg.includes("Unknown cloud"))).toBe(true);
|
|
});
|
|
|
|
it("should suggest spawn clouds command for unknown cloud", async () => {
|
|
await expect(cmdRun("claude", "nonexistent")).rejects.toThrow("process.exit");
|
|
|
|
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(infoCalls.some((msg: string) => msg.includes("spawn clouds"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── cmdRun: unimplemented combination ─────────────────────────────────
|
|
|
|
describe("cmdRun - unimplemented combination", () => {
|
|
it("should exit with error for unimplemented agent/cloud combination", async () => {
|
|
// hetzner/codex is "missing" in mock manifest
|
|
await expect(cmdRun("codex", "hetzner")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should suggest available clouds when combination is not implemented", async () => {
|
|
// hetzner/codex is "missing", but sprite/codex is "implemented"
|
|
await expect(cmdRun("codex", "hetzner")).rejects.toThrow("process.exit");
|
|
|
|
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
// Should suggest sprite as an alternative
|
|
expect(infoCalls.some((msg: string) => msg.includes("spawn codex sprite"))).toBe(true);
|
|
});
|
|
|
|
it("should show how many clouds are available", async () => {
|
|
await expect(cmdRun("codex", "hetzner")).rejects.toThrow("process.exit");
|
|
|
|
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
// codex has 1 implemented cloud (sprite)
|
|
expect(infoCalls.some((msg: string) => msg.includes("1 cloud"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── cmdRun: prompt validation ─────────────────────────────────────────
|
|
|
|
describe("cmdRun - prompt validation", () => {
|
|
it("should reject prompt with command substitution $()", async () => {
|
|
await expect(cmdRun("claude", "sprite", "$(rm -rf /)")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject prompt with backtick command substitution", async () => {
|
|
await expect(cmdRun("claude", "sprite", "`whoami`")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject prompt piping to bash", async () => {
|
|
await expect(cmdRun("claude", "sprite", "echo test | bash")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject prompt with rm -rf chain", async () => {
|
|
await expect(cmdRun("claude", "sprite", "fix bugs; rm -rf /")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject empty prompt", async () => {
|
|
await expect(cmdRun("claude", "sprite", "")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject prompt exceeding 10KB", async () => {
|
|
const largePrompt = "a".repeat(10 * 1024 + 1);
|
|
await expect(cmdRun("claude", "sprite", largePrompt)).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
});
|
|
|
|
// ── cmdAgentInfo: error paths ─────────────────────────────────────────
|
|
|
|
describe("cmdAgentInfo - error paths", () => {
|
|
it("should exit with error for unknown agent", async () => {
|
|
await expect(cmdAgentInfo("nonexistent")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
|
|
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(errorCalls.some((msg: string) => msg.includes("Unknown agent"))).toBe(true);
|
|
});
|
|
|
|
it("should reject agent with invalid identifier characters", async () => {
|
|
await expect(cmdAgentInfo("../hack")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject agent with uppercase letters", async () => {
|
|
await expect(cmdAgentInfo("Claude")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject empty agent name", async () => {
|
|
await expect(cmdAgentInfo("")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject whitespace-only agent name", async () => {
|
|
await expect(cmdAgentInfo(" ")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
});
|
|
|
|
// ── cmdRun: empty input validation ────────────────────────────────────
|
|
|
|
describe("cmdRun - empty input handling", () => {
|
|
it("should reject empty cloud name", async () => {
|
|
await expect(cmdRun("claude", "")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject whitespace-only cloud name", async () => {
|
|
await expect(cmdRun("claude", " ")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("should reject empty agent name", async () => {
|
|
await expect(cmdRun("", "sprite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
});
|
|
|
|
// ── cmdRun: valid input reaches script download ───────────────────────
|
|
|
|
describe("cmdRun - valid inputs proceed past validation", () => {
|
|
it("should pass validation for valid agent and cloud and attempt download", async () => {
|
|
// Mock fetch to simulate script download failure (not a valid script)
|
|
global.fetch = mock(async (url: string) => {
|
|
if (isString(url) && url.includes("manifest.json")) {
|
|
return new Response(JSON.stringify(mockManifest));
|
|
}
|
|
// Script download returns non-script content
|
|
return new Response("not a valid script");
|
|
});
|
|
|
|
// Force refresh manifest with updated fetch
|
|
await loadManifest(true);
|
|
|
|
// cmdRun should pass validation and attempt to download + run the script.
|
|
// It will fail at validateScriptContent because "not a valid script" lacks shebang.
|
|
try {
|
|
await cmdRun("claude", "sprite");
|
|
} catch {
|
|
// Expected - either process.exit from validateScriptContent or Error thrown
|
|
}
|
|
|
|
// The log.step should have been called with the launch message
|
|
// (meaning validation passed and it attempted to download)
|
|
const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(stepCalls.some((msg: string) => msg.includes("Claude Code") && msg.includes("Sprite"))).toBe(true);
|
|
});
|
|
|
|
it("should show prompt indicator when prompt is provided", async () => {
|
|
global.fetch = mock(async (url: string) => {
|
|
if (isString(url) && url.includes("manifest.json")) {
|
|
return new Response(JSON.stringify(mockManifest));
|
|
}
|
|
return new Response("not a valid script");
|
|
});
|
|
|
|
await loadManifest(true);
|
|
|
|
try {
|
|
await cmdRun("claude", "sprite", "Fix all bugs");
|
|
} catch {
|
|
// Expected
|
|
}
|
|
|
|
const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(stepCalls.some((msg: string) => msg.includes("with prompt"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── cmdRun: batch validation (both errors at once) ──────────────────
|
|
|
|
describe("cmdRun - batch validation shows all errors at once", () => {
|
|
it("should show both unknown agent AND unknown cloud errors together", async () => {
|
|
await expect(cmdRun("badagent", "badcloud")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
|
|
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
const hasAgentError = errorCalls.some((msg: string) => msg.includes("Unknown agent"));
|
|
const hasCloudError = errorCalls.some((msg: string) => msg.includes("Unknown cloud"));
|
|
// Both errors should be reported, not just the first one
|
|
expect(hasAgentError).toBe(true);
|
|
expect(hasCloudError).toBe(true);
|
|
});
|
|
|
|
it("should show agent error and cloud-is-actually-agent error together", async () => {
|
|
// "spawn badagent codex" - badagent is unknown, codex is an agent not a cloud
|
|
await expect(cmdRun("badagent", "codex")).rejects.toThrow("process.exit");
|
|
|
|
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
const hasAgentError = errorCalls.some((msg: string) => msg.includes("Unknown agent"));
|
|
const hasCloudError = errorCalls.some((msg: string) => msg.includes("Unknown cloud"));
|
|
expect(hasAgentError).toBe(true);
|
|
expect(hasCloudError).toBe(true);
|
|
});
|
|
|
|
it("should only call process.exit once even with multiple errors", async () => {
|
|
try {
|
|
await cmdRun("badagent", "badcloud");
|
|
} catch {
|
|
// Expected
|
|
}
|
|
// process.exit should be called exactly once (not twice, once per error)
|
|
expect(processExitSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// ── cmdRun: two agents or two clouds ────────────────────────────────
|
|
|
|
describe("cmdRun - mismatched argument types", () => {
|
|
it("should tell user when cloud arg is actually an agent", async () => {
|
|
// "spawn claude codex" - both are agents, not cloud
|
|
await expect(cmdRun("claude", "codex")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
|
|
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(infoCalls.some((msg: string) => msg.includes('"codex" is an agent'))).toBe(true);
|
|
expect(infoCalls.some((msg: string) => msg.includes("spawn <agent> <cloud>"))).toBe(true);
|
|
});
|
|
|
|
it("should tell user when agent arg is actually a cloud (not swappable)", async () => {
|
|
// "spawn hetzner sprite" - both are clouds, swap detection won't fire
|
|
// because sprite is not an agent
|
|
await expect(cmdRun("hetzner", "sprite")).rejects.toThrow("process.exit");
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
|
|
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
|
|
expect(infoCalls.some((msg: string) => msg.includes('"hetzner" is a cloud provider'))).toBe(true);
|
|
expect(infoCalls.some((msg: string) => msg.includes("spawn <agent> <cloud>"))).toBe(true);
|
|
});
|
|
});
|
|
});
|