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,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)}`);
}
}