Skip to content

Commit 4ac4a7e

Browse files
la14-1AhmedTMMclaudelouisgv
authored
feat: recursive spawn tree passback (#3023)
* feat: pull child spawn history back to parent for `spawn tree` When the interactive session ends (or headless mode completes), the parent downloads the child VM's history.json and merges records into local history. Before downloading, it runs `spawn pull-history` on the child, which recursively pulls from all grandchildren — so the full tree collapses up to the root regardless of depth. Changes: - Add getParentFields() — sets parent_id/depth on saveSpawnRecord calls - Add pullChildHistory() — downloads + merges child history after session - Add `spawn pull-history` command for recursive SSH-based history pull - Add 11 tests for parseAndMergeChildHistory Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: trigger CI recompute Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): validate user/ip params before SSH exec in pull-history Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): use shared validators for SSH params in pull-history and delete Replace inline regex checks in pull-history.ts with validateUsername() and validateConnectionIP() from security.ts, matching the pattern used across connect.ts, fix.ts, and link.ts. Also add the same validation to delete.ts:pullChildHistory which had no SSH parameter validation. orchestrate.ts uses the runner abstraction (not raw user@ip), so its SSH params come from the cloud provider, not untrusted history records. Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
1 parent a8e6364 commit 4ac4a7e

7 files changed

Lines changed: 584 additions & 6 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openrouter/spawn",
3-
"version": "0.26.13",
3+
"version": "0.27.0",
44
"type": "module",
55
"bin": {
66
"spawn": "cli.js"
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
2+
import { mkdirSync, writeFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { cmdPullHistory, parseAndMergeChildHistory } from "../commands/pull-history.js";
5+
import * as historyModule from "../history.js";
6+
import { loadHistory } from "../history.js";
7+
8+
// ─── parseAndMergeChildHistory tests ─────────────────────────────────────────
9+
10+
describe("parseAndMergeChildHistory", () => {
11+
let origSpawnHome: string | undefined;
12+
13+
beforeEach(() => {
14+
origSpawnHome = process.env.SPAWN_HOME;
15+
// Use isolated temp dir for history (preload sets HOME to a temp dir)
16+
const tmpHome = process.env.HOME ?? "/tmp";
17+
const spawnDir = join(tmpHome, `.spawn-test-${Date.now()}-${Math.random()}`);
18+
mkdirSync(spawnDir, {
19+
recursive: true,
20+
});
21+
process.env.SPAWN_HOME = spawnDir;
22+
// Write empty history
23+
writeFileSync(
24+
join(spawnDir, "history.json"),
25+
JSON.stringify({
26+
version: 1,
27+
records: [],
28+
}),
29+
);
30+
});
31+
32+
afterEach(() => {
33+
if (origSpawnHome === undefined) {
34+
delete process.env.SPAWN_HOME;
35+
} else {
36+
process.env.SPAWN_HOME = origSpawnHome;
37+
}
38+
});
39+
40+
it("returns 0 for empty string", () => {
41+
expect(parseAndMergeChildHistory("", "parent-123")).toBe(0);
42+
});
43+
44+
it("returns 0 for empty object", () => {
45+
expect(parseAndMergeChildHistory("{}", "parent-123")).toBe(0);
46+
});
47+
48+
it("returns 0 for invalid JSON", () => {
49+
expect(parseAndMergeChildHistory("not json", "parent-123")).toBe(0);
50+
});
51+
52+
it("returns 0 for empty records array", () => {
53+
const json = JSON.stringify({
54+
version: 1,
55+
records: [],
56+
});
57+
expect(parseAndMergeChildHistory(json, "parent-123")).toBe(0);
58+
});
59+
60+
it("parses and merges valid child records", () => {
61+
const json = JSON.stringify({
62+
version: 1,
63+
records: [
64+
{
65+
id: "child-1",
66+
agent: "claude",
67+
cloud: "hetzner",
68+
timestamp: "2026-03-26T00:00:00Z",
69+
},
70+
{
71+
id: "child-2",
72+
agent: "codex",
73+
cloud: "digitalocean",
74+
timestamp: "2026-03-26T00:01:00Z",
75+
name: "test-spawn",
76+
},
77+
],
78+
});
79+
80+
const count = parseAndMergeChildHistory(json, "parent-123");
81+
expect(count).toBe(2);
82+
83+
// Verify records were merged into history
84+
const history = loadHistory();
85+
const child1 = history.find((r) => r.id === "child-1");
86+
const child2 = history.find((r) => r.id === "child-2");
87+
expect(child1).toBeDefined();
88+
expect(child1!.agent).toBe("claude");
89+
expect(child1!.parent_id).toBe("parent-123");
90+
expect(child2).toBeDefined();
91+
expect(child2!.name).toBe("test-spawn");
92+
expect(child2!.parent_id).toBe("parent-123");
93+
});
94+
95+
it("preserves existing parent_id from child records", () => {
96+
const json = JSON.stringify({
97+
version: 1,
98+
records: [
99+
{
100+
id: "grandchild-1",
101+
agent: "claude",
102+
cloud: "aws",
103+
timestamp: "2026-03-26T00:00:00Z",
104+
parent_id: "child-abc",
105+
depth: 2,
106+
},
107+
],
108+
});
109+
110+
const count = parseAndMergeChildHistory(json, "parent-123");
111+
expect(count).toBe(1);
112+
113+
const history = loadHistory();
114+
const gc = history.find((r) => r.id === "grandchild-1");
115+
expect(gc).toBeDefined();
116+
// parent_id should be preserved from the child record, not overwritten
117+
// (mergeChildHistory only sets parent_id if it's not already set)
118+
expect(gc!.parent_id).toBe("child-abc");
119+
expect(gc!.depth).toBe(2);
120+
});
121+
122+
it("skips records without an id", () => {
123+
const json = JSON.stringify({
124+
version: 1,
125+
records: [
126+
{
127+
agent: "claude",
128+
cloud: "hetzner",
129+
timestamp: "2026-03-26T00:00:00Z",
130+
},
131+
{
132+
id: "valid-1",
133+
agent: "codex",
134+
cloud: "gcp",
135+
timestamp: "2026-03-26T00:01:00Z",
136+
},
137+
],
138+
});
139+
140+
const count = parseAndMergeChildHistory(json, "parent-123");
141+
expect(count).toBe(1);
142+
});
143+
144+
it("preserves connection info from child records", () => {
145+
const json = JSON.stringify({
146+
version: 1,
147+
records: [
148+
{
149+
id: "child-conn",
150+
agent: "claude",
151+
cloud: "digitalocean",
152+
timestamp: "2026-03-26T00:00:00Z",
153+
connection: {
154+
ip: "10.0.0.1",
155+
user: "root",
156+
server_id: "12345",
157+
},
158+
},
159+
],
160+
});
161+
162+
const count = parseAndMergeChildHistory(json, "parent-123");
163+
expect(count).toBe(1);
164+
165+
const history = loadHistory();
166+
const child = history.find((r) => r.id === "child-conn");
167+
expect(child!.connection?.ip).toBe("10.0.0.1");
168+
expect(child!.connection?.server_id).toBe("12345");
169+
});
170+
171+
it("deduplicates — calling twice with same records only merges once", () => {
172+
const json = JSON.stringify({
173+
version: 1,
174+
records: [
175+
{
176+
id: "dedup-1",
177+
agent: "claude",
178+
cloud: "hetzner",
179+
timestamp: "2026-03-26T00:00:00Z",
180+
},
181+
],
182+
});
183+
184+
parseAndMergeChildHistory(json, "parent-123");
185+
parseAndMergeChildHistory(json, "parent-123");
186+
187+
const history = loadHistory();
188+
const matches = history.filter((r) => r.id === "dedup-1");
189+
expect(matches.length).toBe(1);
190+
});
191+
192+
it("handles whitespace-only input", () => {
193+
expect(parseAndMergeChildHistory(" \n ", "parent-123")).toBe(0);
194+
});
195+
196+
it("handles history without version field", () => {
197+
const json = JSON.stringify({
198+
records: [
199+
{
200+
id: "no-version",
201+
agent: "hermes",
202+
cloud: "sprite",
203+
timestamp: "2026-03-26T00:00:00Z",
204+
},
205+
],
206+
});
207+
208+
const count = parseAndMergeChildHistory(json, "parent-123");
209+
expect(count).toBe(1);
210+
});
211+
});
212+
213+
// ─── cmdPullHistory tests ───────────────────────────────────────────────────
214+
215+
describe("cmdPullHistory", () => {
216+
it("returns immediately when no active servers", async () => {
217+
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([]);
218+
await cmdPullHistory();
219+
expect(spy).toHaveBeenCalledTimes(1);
220+
spy.mockRestore();
221+
});
222+
223+
it("skips servers without connection info", async () => {
224+
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([
225+
{
226+
id: "test-1",
227+
agent: "claude",
228+
cloud: "hetzner",
229+
timestamp: "2026-03-26T00:00:00Z",
230+
},
231+
]);
232+
// Should not throw — just skips the record with no connection
233+
await cmdPullHistory();
234+
spy.mockRestore();
235+
});
236+
237+
it("skips servers with missing ip", async () => {
238+
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([
239+
{
240+
id: "test-2",
241+
agent: "claude",
242+
cloud: "hetzner",
243+
timestamp: "2026-03-26T00:00:00Z",
244+
connection: {
245+
ip: "",
246+
user: "root",
247+
},
248+
},
249+
]);
250+
await cmdPullHistory();
251+
spy.mockRestore();
252+
});
253+
});

packages/cli/src/commands/delete.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ import {
1616
import { ensureHcloudToken, destroyServer as hetznerDestroyServer } from "../hetzner/hetzner.js";
1717
import { getActiveServers, loadHistory, markRecordDeleted, mergeChildHistory, SpawnRecordSchema } from "../history.js";
1818
import { loadManifest } from "../manifest.js";
19-
import { validateMetadataValue, validateServerIdentifier } from "../security.js";
19+
import {
20+
validateConnectionIP,
21+
validateMetadataValue,
22+
validateServerIdentifier,
23+
validateUsername,
24+
} from "../security.js";
2025
import { getHistoryPath } from "../shared/paths.js";
2126
import { asyncTryCatch, asyncTryCatchIf, isNetworkError, tryCatch } from "../shared/result.js";
2227
import { ensureSpriteAuthenticated, ensureSpriteCli, destroyServer as spriteDestroyServer } from "../sprite/sprite.js";
@@ -250,6 +255,14 @@ export async function pullChildHistory(record: SpawnRecord): Promise<void> {
250255
return;
251256
}
252257

258+
const connValidation = tryCatch(() => {
259+
validateUsername(conn.user);
260+
validateConnectionIP(conn.ip);
261+
});
262+
if (!connValidation.ok) {
263+
return;
264+
}
265+
253266
const { ensureSshKeys, getSshKeyOpts } = await import("../shared/ssh-keys.js");
254267
const { SSH_BASE_OPTS } = await import("../shared/ssh.js");
255268

packages/cli/src/commands/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export {
3434
} from "./list.js";
3535
// pick.ts — cmdPick
3636
export { cmdPick } from "./pick.js";
37+
// pull-history.ts — cmdPullHistory (recursive child history pull)
38+
export { cmdPullHistory } from "./pull-history.js";
3739
// run.ts — cmdRun, cmdRunHeadless, script failure guidance
3840
export {
3941
cmdRun,

0 commit comments

Comments
 (0)