mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-22 03:14:57 +00:00
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:
parent
a8e63648da
commit
4ac4a7e0cf
7 changed files with 584 additions and 6 deletions
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue