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>
This commit is contained in:
A 2026-03-26 15:21:50 -07:00 committed by GitHub
parent a8e63648da
commit 4ac4a7e0cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 584 additions and 6 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.26.13",
"version": "0.27.0",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { cmdPullHistory, parseAndMergeChildHistory } from "../commands/pull-history.js";
import * as historyModule from "../history.js";
import { loadHistory } from "../history.js";
// ─── parseAndMergeChildHistory tests ─────────────────────────────────────────
describe("parseAndMergeChildHistory", () => {
let origSpawnHome: string | undefined;
beforeEach(() => {
origSpawnHome = process.env.SPAWN_HOME;
// Use isolated temp dir for history (preload sets HOME to a temp dir)
const tmpHome = process.env.HOME ?? "/tmp";
const spawnDir = join(tmpHome, `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(spawnDir, {
recursive: true,
});
process.env.SPAWN_HOME = spawnDir;
// Write empty history
writeFileSync(
join(spawnDir, "history.json"),
JSON.stringify({
version: 1,
records: [],
}),
);
});
afterEach(() => {
if (origSpawnHome === undefined) {
delete process.env.SPAWN_HOME;
} else {
process.env.SPAWN_HOME = origSpawnHome;
}
});
it("returns 0 for empty string", () => {
expect(parseAndMergeChildHistory("", "parent-123")).toBe(0);
});
it("returns 0 for empty object", () => {
expect(parseAndMergeChildHistory("{}", "parent-123")).toBe(0);
});
it("returns 0 for invalid JSON", () => {
expect(parseAndMergeChildHistory("not json", "parent-123")).toBe(0);
});
it("returns 0 for empty records array", () => {
const json = JSON.stringify({
version: 1,
records: [],
});
expect(parseAndMergeChildHistory(json, "parent-123")).toBe(0);
});
it("parses and merges valid child records", () => {
const json = JSON.stringify({
version: 1,
records: [
{
id: "child-1",
agent: "claude",
cloud: "hetzner",
timestamp: "2026-03-26T00:00:00Z",
},
{
id: "child-2",
agent: "codex",
cloud: "digitalocean",
timestamp: "2026-03-26T00:01:00Z",
name: "test-spawn",
},
],
});
const count = parseAndMergeChildHistory(json, "parent-123");
expect(count).toBe(2);
// Verify records were merged into history
const history = loadHistory();
const child1 = history.find((r) => r.id === "child-1");
const child2 = history.find((r) => r.id === "child-2");
expect(child1).toBeDefined();
expect(child1!.agent).toBe("claude");
expect(child1!.parent_id).toBe("parent-123");
expect(child2).toBeDefined();
expect(child2!.name).toBe("test-spawn");
expect(child2!.parent_id).toBe("parent-123");
});
it("preserves existing parent_id from child records", () => {
const json = JSON.stringify({
version: 1,
records: [
{
id: "grandchild-1",
agent: "claude",
cloud: "aws",
timestamp: "2026-03-26T00:00:00Z",
parent_id: "child-abc",
depth: 2,
},
],
});
const count = parseAndMergeChildHistory(json, "parent-123");
expect(count).toBe(1);
const history = loadHistory();
const gc = history.find((r) => r.id === "grandchild-1");
expect(gc).toBeDefined();
// parent_id should be preserved from the child record, not overwritten
// (mergeChildHistory only sets parent_id if it's not already set)
expect(gc!.parent_id).toBe("child-abc");
expect(gc!.depth).toBe(2);
});
it("skips records without an id", () => {
const json = JSON.stringify({
version: 1,
records: [
{
agent: "claude",
cloud: "hetzner",
timestamp: "2026-03-26T00:00:00Z",
},
{
id: "valid-1",
agent: "codex",
cloud: "gcp",
timestamp: "2026-03-26T00:01:00Z",
},
],
});
const count = parseAndMergeChildHistory(json, "parent-123");
expect(count).toBe(1);
});
it("preserves connection info from child records", () => {
const json = JSON.stringify({
version: 1,
records: [
{
id: "child-conn",
agent: "claude",
cloud: "digitalocean",
timestamp: "2026-03-26T00:00:00Z",
connection: {
ip: "10.0.0.1",
user: "root",
server_id: "12345",
},
},
],
});
const count = parseAndMergeChildHistory(json, "parent-123");
expect(count).toBe(1);
const history = loadHistory();
const child = history.find((r) => r.id === "child-conn");
expect(child!.connection?.ip).toBe("10.0.0.1");
expect(child!.connection?.server_id).toBe("12345");
});
it("deduplicates — calling twice with same records only merges once", () => {
const json = JSON.stringify({
version: 1,
records: [
{
id: "dedup-1",
agent: "claude",
cloud: "hetzner",
timestamp: "2026-03-26T00:00:00Z",
},
],
});
parseAndMergeChildHistory(json, "parent-123");
parseAndMergeChildHistory(json, "parent-123");
const history = loadHistory();
const matches = history.filter((r) => r.id === "dedup-1");
expect(matches.length).toBe(1);
});
it("handles whitespace-only input", () => {
expect(parseAndMergeChildHistory(" \n ", "parent-123")).toBe(0);
});
it("handles history without version field", () => {
const json = JSON.stringify({
records: [
{
id: "no-version",
agent: "hermes",
cloud: "sprite",
timestamp: "2026-03-26T00:00:00Z",
},
],
});
const count = parseAndMergeChildHistory(json, "parent-123");
expect(count).toBe(1);
});
});
// ─── cmdPullHistory tests ───────────────────────────────────────────────────
describe("cmdPullHistory", () => {
it("returns immediately when no active servers", async () => {
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([]);
await cmdPullHistory();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
it("skips servers without connection info", async () => {
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([
{
id: "test-1",
agent: "claude",
cloud: "hetzner",
timestamp: "2026-03-26T00:00:00Z",
},
]);
// Should not throw — just skips the record with no connection
await cmdPullHistory();
spy.mockRestore();
});
it("skips servers with missing ip", async () => {
const spy = spyOn(historyModule, "getActiveServers").mockReturnValue([
{
id: "test-2",
agent: "claude",
cloud: "hetzner",
timestamp: "2026-03-26T00:00:00Z",
connection: {
ip: "",
user: "root",
},
},
]);
await cmdPullHistory();
spy.mockRestore();
});
});

View file

@ -16,7 +16,12 @@ import {
import { ensureHcloudToken, destroyServer as hetznerDestroyServer } from "../hetzner/hetzner.js";
import { getActiveServers, loadHistory, markRecordDeleted, mergeChildHistory, SpawnRecordSchema } from "../history.js";
import { loadManifest } from "../manifest.js";
import { validateMetadataValue, validateServerIdentifier } from "../security.js";
import {
validateConnectionIP,
validateMetadataValue,
validateServerIdentifier,
validateUsername,
} from "../security.js";
import { getHistoryPath } from "../shared/paths.js";
import { asyncTryCatch, asyncTryCatchIf, isNetworkError, tryCatch } from "../shared/result.js";
import { ensureSpriteAuthenticated, ensureSpriteCli, destroyServer as spriteDestroyServer } from "../sprite/sprite.js";
@ -250,6 +255,14 @@ export async function pullChildHistory(record: SpawnRecord): Promise<void> {
return;
}
const connValidation = tryCatch(() => {
validateUsername(conn.user);
validateConnectionIP(conn.ip);
});
if (!connValidation.ok) {
return;
}
const { ensureSshKeys, getSshKeyOpts } = await import("../shared/ssh-keys.js");
const { SSH_BASE_OPTS } = await import("../shared/ssh.js");

View file

@ -34,6 +34,8 @@ export {
} from "./list.js";
// pick.ts — cmdPick
export { cmdPick } from "./pick.js";
// pull-history.ts — cmdPullHistory (recursive child history pull)
export { cmdPullHistory } from "./pull-history.js";
// run.ts — cmdRun, cmdRunHeadless, script failure guidance
export {
cmdRun,

View file

@ -0,0 +1,178 @@
// commands/pull-history.ts — `spawn pull-history`: recursively pull child spawn history
// Called automatically by the parent after a session ends, or manually.
// SSHes into each active child, tells it to pull from ITS children first,
// then downloads its history.json and merges into local history.
import type { SpawnRecord } from "../history.js";
import * as v from "valibot";
import { getActiveServers, mergeChildHistory, SpawnRecordSchema } from "../history.js";
import { validateConnectionIP, validateUsername } from "../security.js";
import { parseJsonWith } from "../shared/parse.js";
import { asyncTryCatch, tryCatch } from "../shared/result.js";
import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js";
import { logDebug, logInfo } from "../shared/ui.js";
const ChildHistorySchema = v.object({
version: v.optional(v.number()),
records: v.array(SpawnRecordSchema),
});
/**
* Parse a child's history.json content and merge valid records into local history.
* Exported for testing the SSH transport is in cmdPullHistory/pullFromChild.
*/
export function parseAndMergeChildHistory(json: string, parentSpawnId: string): number {
if (!json.trim() || json.trim() === "{}") {
return 0;
}
const parsed = parseJsonWith(json, ChildHistorySchema);
if (!parsed || parsed.records.length === 0) {
return 0;
}
const validRecords: SpawnRecord[] = [];
for (const r of parsed.records) {
if (r.id) {
validRecords.push({
id: r.id,
agent: r.agent,
cloud: r.cloud,
timestamp: r.timestamp,
...(r.name
? {
name: r.name,
}
: {}),
...(r.parent_id
? {
parent_id: r.parent_id,
}
: {}),
...(r.depth !== undefined
? {
depth: r.depth,
}
: {}),
...(r.connection
? {
connection: r.connection,
}
: {}),
});
}
}
if (validRecords.length > 0) {
mergeChildHistory(parentSpawnId, validRecords);
}
return validRecords.length;
}
/**
* Pull history from all active child VMs recursively.
* For each active child:
* 1. SSH in, run `spawn pull-history` (recurse into grandchildren)
* 2. Download the child's history.json
* 3. Merge into local history with parent_id links
*/
export async function cmdPullHistory(): Promise<void> {
const active = getActiveServers();
if (active.length === 0) {
return;
}
const keysResult = await asyncTryCatch(() => ensureSshKeys());
if (!keysResult.ok) {
logDebug("Could not load SSH keys for history pull");
return;
}
const sshKeyOpts = getSshKeyOpts(keysResult.data);
for (const record of active) {
if (!record.connection?.ip || !record.connection?.user) {
continue;
}
const { ip, user } = record.connection;
const spawnId = record.id;
const validation = tryCatch(() => {
validateUsername(user);
validateConnectionIP(ip);
});
if (!validation.ok) {
logDebug(`Skipping record with invalid connection: ${user}@${ip}`);
continue;
}
await pullFromChild(ip, user, spawnId, sshKeyOpts);
}
}
async function pullFromChild(ip: string, user: string, parentSpawnId: string, sshKeyOpts: string[]): Promise<void> {
const result = await asyncTryCatch(async () => {
const sshBase = [
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"ConnectTimeout=10",
"-o",
"BatchMode=yes",
...sshKeyOpts,
`${user}@${ip}`,
];
// Step 1: Tell the child to recursively pull from its own children
const recurseProc = Bun.spawnSync(
[
...sshBase,
'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"; spawn pull-history 2>/dev/null || true',
],
{
stdio: [
"ignore",
"ignore",
"ignore",
],
timeout: 60_000,
},
);
if (recurseProc.exitCode !== 0) {
logDebug(`Recursive pull on ${ip} returned ${recurseProc.exitCode} (may not support pull-history)`);
}
// Step 2: Download the child's history.json via SSH + cat
const catProc = Bun.spawnSync(
[
...sshBase,
"cat ~/.spawn/history.json 2>/dev/null || cat ~/.config/spawn/history.json 2>/dev/null || echo '{}'",
],
{
stdio: [
"ignore",
"pipe",
"ignore",
],
timeout: 30_000,
},
);
if (catProc.exitCode !== 0) {
return;
}
const json = new TextDecoder().decode(catProc.stdout);
const merged = parseAndMergeChildHistory(json, parentSpawnId);
if (merged > 0) {
logInfo(`Pulled ${merged} record(s) from ${ip}`);
}
});
if (!result.ok) {
logDebug(`Could not pull history from ${ip}`);
}
}

View file

@ -23,6 +23,7 @@ import {
cmdListClear,
cmdMatrix,
cmdPick,
cmdPullHistory,
cmdRun,
cmdRunHeadless,
cmdStatus,
@ -744,6 +745,10 @@ async function dispatchCommand(
await cmdTree(jsonFlag);
return;
}
if (cmd === "pull-history") {
await cmdPullHistory();
return;
}
if (LIST_COMMANDS.has(cmd)) {
// Handle "history export" subcommand
if (cmd === "history" && filteredArgs[1] === "export") {

View file

@ -1,20 +1,28 @@
// shared/orchestrate.ts — Shared orchestration pipeline for deploying agents
// Each cloud implements CloudOrchestrator and calls runOrchestration().
import type { VMConnection } from "../history.js";
import type { SpawnRecord, VMConnection } from "../history.js";
import type { CloudRunner } from "./agent-setup.js";
import type { AgentConfig } from "./agents.js";
import type { SshTunnelHandle } from "./ssh.js";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { getErrorMessage } from "@openrouter/spawn-shared";
import * as v from "valibot";
import { generateSpawnId, saveLaunchCmd, saveMetadata, saveSpawnRecord } from "../history.js";
import {
generateSpawnId,
mergeChildHistory,
SpawnRecordSchema,
saveLaunchCmd,
saveMetadata,
saveSpawnRecord,
} from "../history.js";
import { offerGithubAuth, setupAutoUpdate, wrapSshCall } from "./agent-setup.js";
import { tryTarballInstall } from "./agent-tarball.js";
import { generateEnvConfig } from "./agents.js";
import { getOrPromptApiKey } from "./oauth.js";
import { getSpawnCloudConfigPath, getSpawnPreferencesPath } from "./paths.js";
import { parseJsonWith } from "./parse.js";
import { getSpawnCloudConfigPath, getSpawnPreferencesPath, getTmpDir } from "./paths.js";
import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatch } from "./result.js";
import { isWindows } from "./shell.js";
import { injectSpawnSkill } from "./spawn-skill.js";
@ -204,6 +212,25 @@ export async function delegateCloudCredentials(runner: CloudRunner): Promise<voi
logInfo("Cloud credentials delegated to VM");
}
/** Get parent_id and depth fields for spawn records (set when running inside a child VM). */
function getParentFields(): {
parent_id?: string;
depth?: number;
} {
const parentId = process.env.SPAWN_PARENT_ID;
const depth = Number(process.env.SPAWN_DEPTH) || 0;
return parentId
? {
parent_id: parentId,
depth,
}
: depth > 0
? {
depth,
}
: {};
}
/** Append recursive-spawn env vars to the envPairs array when --beta recursive is active. */
export function appendRecursiveEnvVars(envPairs: string[], spawnId: string): void {
const currentDepth = Number(process.env.SPAWN_DEPTH) || 0;
@ -299,6 +326,7 @@ export async function runOrchestration(
name: spawnName,
}
: {}),
...getParentFields(),
connection: conn,
});
await cloud.waitForReady();
@ -341,6 +369,7 @@ export async function runOrchestration(
name: spawnName2,
}
: {}),
...getParentFields(),
connection,
});
await cloud.waitForReady();
@ -448,6 +477,7 @@ export async function runOrchestration(
name: spawnName,
}
: {}),
...getParentFields(),
connection,
});
@ -689,6 +719,9 @@ async function postInstall(
if (tunnelHandle) {
tunnelHandle.stop();
}
if (cloud.cloudName !== "local") {
await pullChildHistory(cloud.runner, spawnId);
}
process.exit(0);
}
@ -735,5 +768,99 @@ async function postInstall(
if (tunnelHandle) {
tunnelHandle.stop();
}
// Pull child's spawn history back to the parent for `spawn tree`
if (cloud.cloudName !== "local") {
await pullChildHistory(cloud.runner, spawnId);
}
process.exit(exitCode);
}
/**
* Pull spawn history from a child VM and merge it into local history.
* First tells the child to recursively pull from ITS children via
* `spawn pull-history`, then downloads the child's history.json.
* This enables `spawn tree` to show the full recursive hierarchy.
*/
async function pullChildHistory(runner: CloudRunner, parentSpawnId: string): Promise<void> {
const result = await asyncTryCatch(async () => {
const tmpPath = `${getTmpDir()}/child-history-${parentSpawnId}.json`;
// Recursive pull: tell the child to pull from ALL its children first.
const recursePull = await asyncTryCatch(() =>
runner.runServer(
'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"; spawn pull-history 2>/dev/null || true',
120,
),
);
if (!recursePull.ok) {
logDebug("Recursive history pull skipped");
}
// Copy the child's history to a temp location then download
const copyResult = await asyncTryCatch(() =>
runner.runServer(
"cp ~/.spawn/history.json /tmp/_spawn_history.json 2>/dev/null || cp ~/.config/spawn/history.json /tmp/_spawn_history.json 2>/dev/null || echo '{}' > /tmp/_spawn_history.json",
),
);
if (!copyResult.ok) {
return;
}
await runner.downloadFile("/tmp/_spawn_history.json", tmpPath);
const json = readFileSync(tmpPath, "utf-8");
const ChildHistorySchema = v.object({
version: v.optional(v.number()),
records: v.array(SpawnRecordSchema),
});
const parsed = parseJsonWith(json, ChildHistorySchema);
if (!parsed || parsed.records.length === 0) {
return;
}
const validRecords: SpawnRecord[] = [];
for (const r of parsed.records) {
if (r.id) {
validRecords.push({
id: r.id,
agent: r.agent,
cloud: r.cloud,
timestamp: r.timestamp,
...(r.name
? {
name: r.name,
}
: {}),
...(r.parent_id
? {
parent_id: r.parent_id,
}
: {}),
...(r.depth !== undefined
? {
depth: r.depth,
}
: {}),
...(r.connection
? {
connection: r.connection,
}
: {}),
});
}
}
if (validRecords.length > 0) {
mergeChildHistory(parentSpawnId, validRecords);
logInfo(`Pulled ${validRecords.length} spawn record(s) from child VM`);
}
tryCatch(() => unlinkSync(tmpPath));
});
if (!result.ok) {
logDebug(`Could not pull child history: ${getErrorMessage(result.error)}`);
}
}