@foldrun/core 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import { latestRevisionId } from "./history.js";
6
6
  import { recordCallIo, redactionsFor } from "./capture.js";
7
7
  import { AUTH_REFUSAL } from "./issues.js";
8
8
  import { hookNames } from "./hooks.js";
9
+ import crypto from "node:crypto";
9
10
  import fs from "node:fs";
10
11
  import os from "node:os";
11
12
  import path from "node:path";
@@ -32,16 +33,17 @@ const isolatedRun = () => {
32
33
  return mode === "container" || (!!mode && mode in platform.isolation);
33
34
  };
34
35
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
35
- import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools } from "./subagents.js";
36
+ import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools } from "./subagents.js";
37
+ import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel } from "./delegate-tools.js";
36
38
  import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
37
39
  import { sendPlainNotification } from "./notify.js";
38
40
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
39
41
  import { refNames } from "./refs.js";
40
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isRescueRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
42
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
41
43
  import { canReadBack } from "./tool-output.js";
42
44
  import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
43
45
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
44
- import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
46
+ import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.js";
45
47
  import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
46
48
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
47
49
  import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
@@ -525,6 +527,10 @@ export function buildSubagentSpecs(o) {
525
527
  try {
526
528
  const tags = o.tags ?? [];
527
529
  const own = agentContext(sub.dir, o.tenant, tags, { runId: o.runId, agent: sub.name }, o.flowTimezone, true);
530
+ // sandbox: own — delegated to as a step of its own, not inside this
531
+ // one (buildDelegateSpecs).
532
+ if (ownSandbox(own.front))
533
+ continue;
528
534
  const description = typeof own.front.description === "string" ? own.front.description.trim() : "";
529
535
  if (!description) {
530
536
  o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
@@ -556,6 +562,51 @@ export function buildSubagentSpecs(o) {
556
562
  }
557
563
  return out;
558
564
  }
565
+ /**
566
+ * The `sandbox: own` sub-agents a step may hand jobs to: a name and a
567
+ * description each, all the parent's sandbox needs. What they may hold is
568
+ * read from their own file when a job arrives — never cut to the parent's,
569
+ * and never seen by it. `canRun` is false where nothing can start their
570
+ * sandbox (a sub-agent's own step: depth one; a step outside a run).
571
+ */
572
+ export function buildDelegateSpecs(o) {
573
+ const out = [];
574
+ for (const sub of gatherSubagents(o.workspaceRoot, o.names).found) {
575
+ if (sub.name === o.self)
576
+ continue; // buildSubagentSpecs says so
577
+ let front;
578
+ try {
579
+ front = matter(fs.readFileSync(path.join(sub.dir, "agent.md"), "utf8")).data;
580
+ }
581
+ catch {
582
+ continue; // buildSubagentSpecs says so
583
+ }
584
+ if (!ownSandbox(front))
585
+ continue;
586
+ const description = typeof front.description === "string" ? front.description.trim() : "";
587
+ if (!description) {
588
+ o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
589
+ continue;
590
+ }
591
+ if (!o.canRun) {
592
+ o.push("info", `subagents: "${sub.name}" runs in its own sandbox, which nothing here can start (a sub-agent cannot delegate again, and a step outside a run has no room for it) — not offered`);
593
+ continue;
594
+ }
595
+ if (o.outward) {
596
+ let acts = true; // unreadable is treated as able to act: the safe side
597
+ const tags = o.tags ?? [];
598
+ try {
599
+ acts = agentContext(sub.dir, o.tenant ?? "default", tags, { agent: sub.name }, null, true).outward;
600
+ }
601
+ catch {
602
+ /* as above */
603
+ }
604
+ o.outward(sub.name, acts);
605
+ }
606
+ out.push({ name: sub.name, description });
607
+ }
608
+ return out;
609
+ }
559
610
  export function sharedInstructions(agentDir, tenant) {
560
611
  const sections = [
561
612
  ["Everyone in this account", readAgentsMd(accountDir(tenant))?.body],
@@ -1348,7 +1399,8 @@ grantsOnly = false) {
1348
1399
  // the agent, and the date the platform believes it is.
1349
1400
  {
1350
1401
  ...runtime.env,
1351
- ...secretEnv,
1402
+ // An unexported client certificate is the proxy's, never a script's.
1403
+ ...withoutHeldCerts(secretEnv),
1352
1404
  ...(identity.runId ? { FOLDRUN_RUN_ID: identity.runId } : {}),
1353
1405
  ...(identity.agent ? { FOLDRUN_AGENT: identity.agent } : {}),
1354
1406
  // And the workspace: a commit or a record made by a shared library
@@ -1618,6 +1670,9 @@ grantsOnly = false) {
1618
1670
  // asked for by name fails inside the container with "cannot find" while
1619
1671
  // the host path, which uses this same merged spec, works fine.
1620
1672
  runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
1673
+ // Whole: a bound client certificate goes from here into the egress
1674
+ // grant (runStep), which is where it is presented. Programs get theirs
1675
+ // filtered (withoutHeldCerts) where their environments are built.
1621
1676
  secretEnv: { ...runtime.env, ...secretEnv },
1622
1677
  formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
1623
1678
  providerEnv,
@@ -1848,7 +1903,11 @@ stepIndex,
1848
1903
  siblings = [],
1849
1904
  /** The in-process colleagues' bus for this group (localPeerBus), when
1850
1905
  * the step runs in this process rather than a pod. */
1851
- peerBus) {
1906
+ peerBus,
1907
+ /** Runs one job for a `sandbox: own` sub-agent as a step of its own and
1908
+ * answers with its reply (the drive loop's delegateFor). Absent for a
1909
+ * step that is itself a sub-agent's: depth one. */
1910
+ delegateJob) {
1852
1911
  // Secret values are injected into scripts as environment variables and
1853
1912
  // substituted into API headers, so a model that reads one back — from a
1854
1913
  // script that prints it, an API error that echoes it, or its own `env` —
@@ -1868,6 +1927,9 @@ peerBus) {
1868
1927
  let fileDir = null;
1869
1928
  let installDir = null;
1870
1929
  let disposeRuntime;
1930
+ /** The delegate door served for this step (sandbox: own sub-agents), held
1931
+ * here so the finally can close it however the step ends. */
1932
+ let serving = null;
1871
1933
  // Can this step's tools act outside the workspace? Set once its context is
1872
1934
  // read; the verdict at the end of this function reads it.
1873
1935
  let canActOutward = false;
@@ -2319,8 +2381,13 @@ peerBus) {
2319
2381
  // Colleagues this agent may DELEGATE to: each with its own context and
2320
2382
  // its own tools — resolved exactly as its own step's would be, then cut
2321
2383
  // to what THIS step holds (subagents.ts). Built host-side, like consults.
2384
+ // A sandbox: own job is a sub-agent already: depth one, so neither kind
2385
+ // of delegation is offered to it.
2386
+ if (step.subagentOf && refNames(front.subagents).length) {
2387
+ push("info", `subagents: not offered — ${step.agent} is running a job for ${step.subagentOf}, and a sub-agent does not delegate again`);
2388
+ }
2322
2389
  const subagents = buildSubagentSpecs({
2323
- names: front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
2390
+ names: step.subagentOf ? [] : front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
2324
2391
  parentAllowed: [...allowed, ...consultNames.map((n) => `mcp__${n}`)], push,
2325
2392
  });
2326
2393
  if (subagents.length) {
@@ -2330,6 +2397,34 @@ peerBus) {
2330
2397
  for (const s of subagents)
2331
2398
  push("info", `subagent: ${s.name} — ${s.tools.length ? s.tools.join(", ") : "no tools"}`);
2332
2399
  }
2400
+ // sandbox: own sub-agents: a delegate_<name> tool each. Their jobs run as
2401
+ // steps of their own (delegate-tools.ts); dropped again below where no
2402
+ // door to the worker exists.
2403
+ let delegates = buildDelegateSpecs({
2404
+ names: step.subagentOf ? [] : front.subagents, self: step.agent, workspaceRoot, canRun: !!delegateJob && stepIndex !== undefined && stepIndex >= 0, push,
2405
+ tenant,
2406
+ tags,
2407
+ outward: (_name, acts) => void (canActOutward ||= acts),
2408
+ });
2409
+ const offerDelegates = (specs) => {
2410
+ for (const n of delegateToolNames(delegates))
2411
+ if (allowed.includes(n))
2412
+ allowed.splice(allowed.indexOf(n), 1);
2413
+ if (consultNames.includes(DELEGATE_SERVER))
2414
+ consultNames.splice(consultNames.indexOf(DELEGATE_SERVER), 1);
2415
+ delegates = specs;
2416
+ if (!specs.length)
2417
+ return;
2418
+ allowed.push(...delegateToolNames(specs));
2419
+ consultNames.push(DELEGATE_SERVER);
2420
+ };
2421
+ offerDelegates(delegates);
2422
+ const delegateLine = delegates.length ? `\n\n${delegatePromptLine(delegates)}` : "";
2423
+ if (delegates.length) {
2424
+ prompt += delegateLine;
2425
+ for (const d of delegates)
2426
+ push("info", `subagent: ${d.name} — own sandbox, its own tools, secrets and network`);
2427
+ }
2333
2428
  // The last provider refusal this step saw, for the fallback decision —
2334
2429
  // watched on both paths, because a refusal reads the same from a pod
2335
2430
  // and from this process.
@@ -2490,17 +2585,55 @@ peerBus) {
2490
2585
  // to refuse; the proxy adds the install's own switch and has the last
2491
2586
  // word (egress-proxy.ts), so false here never loosens anything.
2492
2587
  grant.enforce = networkEnforceWanted(readAgentsMd(accountDir(tenant))?.data?.network_enforce, readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce);
2588
+ // Keyless cloud identities the step declared: the proxy may mint tokens
2589
+ // for these audiences only, for this workload (oidc-issuer.ts).
2590
+ // A test run withholds them as it withholds every credential
2591
+ // (test-mode.ts): a token is a credential, however it is delivered.
2592
+ const declaredIdentities = Object.entries(liveSecrets).filter(([, v]) => typeof v === "string" && isIdentityValue(v));
2593
+ const identitiesHeld = testRun
2594
+ ? withholdSecrets(Object.fromEntries(declaredIdentities), {
2595
+ outward: scriptSpecs.some((sc) => sc.outward),
2596
+ allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
2597
+ }).withheld
2598
+ : [];
2599
+ const identities = Object.fromEntries(declaredIdentities.filter(([name]) => !identitiesHeld.includes(name)).map(([name, v]) => [name, { audience: identityOf(v).audience }]));
2600
+ if (Object.keys(identities).length)
2601
+ grant.identities = identities;
2493
2602
  const lease = await platform.egress.lease({
2494
2603
  tenant,
2495
2604
  runId: runId ?? "adhoc",
2496
2605
  grant,
2497
2606
  test: testRun,
2607
+ who: { workspace: workspaceName, agent: step.agent },
2498
2608
  ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
2499
2609
  // tools: [peers] — the roster the proxy serves, and the inboxes a
2500
2610
  // colleague's message may be addressed to. Only when granted: a
2501
2611
  // step without the tools has no reason to know its siblings.
2502
2612
  ...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
2613
+ // sandbox: own sub-agents: the only names the delegate door takes.
2614
+ ...(delegates.length ? { delegates: delegates.map((d) => d.name) } : {}),
2503
2615
  });
2616
+ // Serve those jobs while this step runs: each one becomes a step of
2617
+ // its own (delegateJob), and the door answers the long-poll with its
2618
+ // reply. No proxy, or a platform without the door: not offered — the
2619
+ // tools would only fail.
2620
+ if (delegates.length) {
2621
+ // Also: the executor must be able to stop ONE job — record its
2622
+ // sandbox (the resumable ones checkpoint it) and destroy it — or a job
2623
+ // whose parent stopped waiting would run on, sending and writing.
2624
+ const canStopAJob = !!platform.killSandbox && platform.sandboxResumable(isolation);
2625
+ if (lease && platform.egress.delegations && delegateJob && stepIndex !== undefined && canStopAJob) {
2626
+ serving = platform.egress.delegations(
2627
+ // Re-attaching to a sandbox that outlived its driver: what it
2628
+ // queued before the restart is still its own, not a dead attempt's.
2629
+ { tenant, runId: runId ?? "adhoc", step: stepIndex, resuming: !!step.sandbox && step.sandbox.kind === isolation }, (j) => delegateJob(j.agent, j.job, j.cancelled ?? (() => false)));
2630
+ }
2631
+ else {
2632
+ push("error", `subagents: ${delegates.map((d) => d.name).join(", ")} run in their own sandbox, and this install ${canStopAJob ? "has no door to start one from inside a step" : "cannot stop one job's sandbox on its own"} — not offered`);
2633
+ offerDelegates([]);
2634
+ prompt = prompt.replace(delegateLine, "");
2635
+ }
2636
+ }
2504
2637
  // The forward door: every program in the sandbox sends through the
2505
2638
  // proxy, which holds the site list and fills bound secrets. A proxy
2506
2639
  // without the door (an older platform) leaves programs going direct.
@@ -2565,6 +2698,38 @@ peerBus) {
2565
2698
  push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2566
2699
  }
2567
2700
  }
2701
+ // A keyless identity holds nothing to hand over: the sandbox gets the
2702
+ // path of a token file the driver keeps fresh through the proxy's
2703
+ // /identity door. Without a lease there is no door, and so no token.
2704
+ const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]));
2705
+ for (const name of identityDeclared)
2706
+ delete sandboxSecrets[name];
2707
+ for (const name of identitiesHeld) {
2708
+ push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2709
+ }
2710
+ const identityNames = identityDeclared.filter((name) => !identitiesHeld.includes(name));
2711
+ if (identityNames.length) {
2712
+ if (lease) {
2713
+ const dir = "/tmp/foldrun-identity";
2714
+ sandboxSecrets.FOLDRUN_IDENTITIES = identityNames.join(",");
2715
+ sandboxSecrets.FOLDRUN_IDENTITY_DIR = dir;
2716
+ for (const name of identityNames)
2717
+ sandboxSecrets[`${name}_FILE`] = `${dir}/${name.toLowerCase()}.jwt`;
2718
+ push("info", `identity: ${identityNames.join(", ")} — a short-lived token in $NAME_FILE, renewed by the platform while the step runs`);
2719
+ }
2720
+ else {
2721
+ push("error", `identity: ${identityNames.join(", ")} needs a sandboxed step on the platform — no token here`);
2722
+ }
2723
+ }
2724
+ // A client certificate is the proxy's to present, to the exact sites it
2725
+ // is bound to; never the sandbox's, unless it was saved with `export`.
2726
+ for (const name of declared) {
2727
+ const v = liveSecrets[name];
2728
+ if (typeof v !== "string" || !isClientCertValue(v) || bound.includes(name) || clientCertOf(v).export)
2729
+ continue;
2730
+ delete sandboxSecrets[name];
2731
+ push("info", `secrets: ${name} is a client certificate — the egress proxy presents it to the sites it is bound to (\`foldrun secrets sites ${name} <host>\`); unbound, it stays out of the sandbox`);
2732
+ }
2568
2733
  // On a test run the http tools always go through the proxy as
2569
2734
  // placeholders, even when a script made the step materialise: the
2570
2735
  // proxy is where the outward-write policy lives, and a header
@@ -2638,6 +2803,7 @@ peerBus) {
2638
2803
  consults,
2639
2804
  ...(peers ? { peers: { self: step.agent, siblings } } : {}),
2640
2805
  ...(subagents.length ? { subagents } : {}),
2806
+ ...(delegates.length ? { delegates } : {}),
2641
2807
  timeoutSec: step.timeout,
2642
2808
  budgetUsd: stepBudgetUsd,
2643
2809
  budgetNote: stepBudgetNote,
@@ -2854,6 +3020,8 @@ peerBus) {
2854
3020
  // to decide; a second go could send or charge twice.
2855
3021
  step.image = outcome.image ?? null;
2856
3022
  step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
3023
+ if (step.subagentOf && outcome.applied?.length)
3024
+ step.wrote = [...new Set([...(step.wrote ?? []), ...outcome.applied])];
2857
3025
  if (outcome.status === "failed" && outcome.browserPod?.lost) {
2858
3026
  const decision = podLossDecision(outcome.browserPod);
2859
3027
  if (decision.rerun) {
@@ -2901,6 +3069,8 @@ peerBus) {
2901
3069
  else
2902
3070
  pushWatching("info", line.text, { effect: line.effect });
2903
3071
  }
3072
+ await serving?.close();
3073
+ serving = null;
2904
3074
  await lease.release();
2905
3075
  }
2906
3076
  // Nothing a test run left in storage/public/ reached storage/, so
@@ -2966,7 +3136,19 @@ peerBus) {
2966
3136
  for (const name of held.withheld) {
2967
3137
  push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
2968
3138
  }
2969
- const stepSecrets = held.env;
3139
+ // A client certificate not saved with `export` has no proxy here to
3140
+ // present it: it stays out of everything this step runs — tools,
3141
+ // consults, verify commands — not only out of the files.
3142
+ const stepSecrets = Object.fromEntries(Object.entries(held.env).filter(([name, v]) => {
3143
+ if (typeof v === "string" && isIdentityValue(v)) {
3144
+ push("error", `identity: ${name} needs a sandboxed step on the platform — no token here`);
3145
+ return false;
3146
+ }
3147
+ if (typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export)
3148
+ return true;
3149
+ push("info", `secrets: ${name} is a client certificate — no egress proxy here to present it; save it with export to use it as files`);
3150
+ return false;
3151
+ }));
2970
3152
  // The model credential for this machine's Claude Code — the agent's
2971
3153
  // own provider when it names one. The host allowlist drops it, so
2972
3154
  // without this every in-process step ran on the machine's login.
@@ -2982,6 +3164,16 @@ peerBus) {
2982
3164
  const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
2983
3165
  const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
2984
3166
  const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
3167
+ // sandbox: own sub-agents, here: no proxy between the step and this
3168
+ // process, so the job is handed to the drive loop directly.
3169
+ const delegateTools = delegates.length && delegateJob
3170
+ ? buildDelegateTools({ specs: delegates, channel: localDelegateChannel(delegateJob), emit: push, timeoutSec: step.timeout })
3171
+ : null;
3172
+ // Said, not hidden: on this machine no step has a sandbox, so a job is
3173
+ // its own context, tools and secrets — but the same files and network.
3174
+ if (delegateTools?.server) {
3175
+ push("info", `subagents: ${delegates.map((d) => d.name).join(", ")} run here in this process — their own tools and secrets, but no sandbox: the same files and network as this step (on the platform each job gets a sandbox of its own)`);
3176
+ }
2985
3177
  // @file secrets become 0600 paths here (host run), cleaned up in the
2986
3178
  // finally — once, however many attempts follow.
2987
3179
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -3023,6 +3215,7 @@ peerBus) {
3023
3215
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
3024
3216
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3025
3217
  ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
3218
+ ...(delegateTools?.server ? { foldrun_delegate: delegateTools.server } : {}),
3026
3219
  ...(installTools ? { foldrun_install: installTools.server } : {}),
3027
3220
  ...mcpServers,
3028
3221
  },
@@ -3146,6 +3339,16 @@ peerBus) {
3146
3339
  push("error", err instanceof Error ? err.message : String(err));
3147
3340
  }
3148
3341
  finally {
3342
+ // The delegate door's service, when the step ended by throwing before
3343
+ // its lease was released: its jobs are settled, not left hanging.
3344
+ if (serving) {
3345
+ try {
3346
+ await serving.close();
3347
+ }
3348
+ catch {
3349
+ // best-effort — the door's records expire on their own
3350
+ }
3351
+ }
3149
3352
  try {
3150
3353
  cleanupFileSecrets(fileDir);
3151
3354
  if (installDir)
@@ -3804,7 +4007,15 @@ async function harvestQuietly(tenant, workspace, run) {
3804
4007
  export const drivingRuns = new Set();
3805
4008
  export function driveRun(tenant, workspace, run, modelOverride, tags = [], opts = {}) {
3806
4009
  drivingRuns.add(run.id);
4010
+ // The run's egress leases are renewed while it is driven, by whichever
4011
+ // door drove it — the queue, or a reconcile that picked it back up — so an
4012
+ // identity token is renewed for a live step however it was started.
4013
+ const renew = platform.egress.renew;
4014
+ const beat = renew ? setInterval(() => void renew(run.id).catch(() => undefined), 5 * 60_000) : null;
4015
+ beat?.unref?.();
3807
4016
  return driveRunInner(tenant, workspace, run, modelOverride, tags, opts).finally(() => {
4017
+ if (beat)
4018
+ clearInterval(beat);
3808
4019
  drivingRuns.delete(run.id);
3809
4020
  });
3810
4021
  }
@@ -3943,6 +4154,32 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3943
4154
  // last driver got, runStep passes that as `resume`, and the step
3944
4155
  // carries on from where it was — a deploy no longer costs the run.
3945
4156
  let orphaned = false;
4157
+ // A sandbox: own job whose driver went away is over: the reply it was
4158
+ // making went to a process that no longer exists, and the step that
4159
+ // asked is told it failed (the platform settles the door's side), so
4160
+ // its model can hand the job over again. Never resumed or relaunched.
4161
+ for (const step of run.steps) {
4162
+ if (!step.subagentOf || (step.status !== "running" && step.status !== "pending"))
4163
+ continue;
4164
+ // Its sandbox may still be executing — outward work included — while
4165
+ // the parent hands the job over again: destroy it before letting go
4166
+ // of the only reference to it.
4167
+ if (step.sandbox?.ref) {
4168
+ try {
4169
+ platform.killSandbox?.(step.sandbox);
4170
+ }
4171
+ catch {
4172
+ // a sandbox we cannot reach must not stop the run from resuming
4173
+ }
4174
+ }
4175
+ step.status = "failed";
4176
+ step.sandbox = null;
4177
+ step.events.push({ t: new Date().toISOString(), type: "error", text: "interrupted — the worker driving this job restarted; the step that asked for it was told it failed" });
4178
+ const parent = run.steps.find((s) => s.delegations?.some((d) => d.id === step.delegation));
4179
+ const entry = parent?.delegations?.find((d) => d.id === step.delegation);
4180
+ if (entry && entry.status === "running")
4181
+ entry.status = "failed";
4182
+ }
3946
4183
  for (const step of run.steps) {
3947
4184
  if (step.status !== "running")
3948
4185
  continue;
@@ -4087,7 +4324,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4087
4324
  // three handoffs below read the flow's steps only: counting the rescue
4088
4325
  // too handed the next group the same output twice, or a stale one
4089
4326
  // from an evaluator loop's earlier cycle.
4090
- const flowStepsOf = (group) => group.filter((s) => !isRescueRecord(s));
4327
+ const flowStepsOf = (group) => group.filter((s) => !isSplicedRecord(s));
4091
4328
  const dataOf = (all) => {
4092
4329
  const group = flowStepsOf(all);
4093
4330
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
@@ -4486,7 +4723,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4486
4723
  save();
4487
4724
  if (freshGroup.every((s) => s.status !== "pending")) {
4488
4725
  // everything in this group was rejected
4489
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4726
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isSplicedRecord(s))) {
4490
4727
  run.status = "failed";
4491
4728
  break;
4492
4729
  }
@@ -4503,13 +4740,119 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4503
4740
  // of the flow: one left pending by a drive that died is picked up by
4504
4741
  // rescueStep below, never launched alongside the group (21 Oct 2026:
4505
4742
  // a resumed run launched 77 leftover fixers at once).
4506
- const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4743
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isSplicedRecord(s));
4507
4744
  // tools: [peers] — what each step of the group is told about the
4508
4745
  // others, and the bus they share when they run in this process.
4509
4746
  const peerBus = localPeerBus();
4510
4747
  const siblingsOf = (step) => launching
4511
4748
  .filter((s) => s !== step)
4512
4749
  .map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
4750
+ // sandbox: own sub-agents (delegate-tools.ts): one job, run as a step
4751
+ // of its own — its own sandbox, tools, secrets and network, from its
4752
+ // own file — and answered with its reply. The record is APPENDED to
4753
+ // run.steps, in the parent's group: every running step's lease and
4754
+ // inbox are addressed by index, so nothing may shift under them.
4755
+ // Marked subagentOf, it is never launched, handed on or reset as a
4756
+ // step of the flow (isSplicedRecord). It gets no delegateJob of its
4757
+ // own: depth one.
4758
+ const delegateFor = (parent) => async (agent, job, cancelled) => {
4759
+ const dir = path.join(pDir, "agents", agent);
4760
+ if (!fs.existsSync(path.join(dir, "agent.md")))
4761
+ return { status: "failed", result: `${agent} is not an agent in this workspace` };
4762
+ // A stable id on both records: an index into run.steps moves when a
4763
+ // rescue or a fan-out is spliced in before the child.
4764
+ const id = `d_${crypto.randomBytes(5).toString("hex")}`;
4765
+ const child = {
4766
+ agent,
4767
+ subagentOf: parent.agent,
4768
+ delegation: id,
4769
+ instruction: job,
4770
+ group: parent.group,
4771
+ optional: true,
4772
+ timeout: parent.timeout,
4773
+ attempts: 1,
4774
+ status: "running",
4775
+ events: [],
4776
+ result: null,
4777
+ costUsd: null,
4778
+ };
4779
+ run.steps.push(child);
4780
+ const at = run.steps.length - 1;
4781
+ const entry = { id, agent, status: "running" };
4782
+ (parent.delegations ??= []).push(entry);
4783
+ const started = Date.now();
4784
+ // The same budget rules as a step of the flow: nothing left means
4785
+ // not started (a zero ceiling would read as no cap at all), and
4786
+ // what it may spend is reserved while it runs, so two jobs at once
4787
+ // do not each get the agent's whole remaining budget.
4788
+ // The flow's budget: what is left after what has been spent AND
4789
+ // what every step and job running now holds — the parent's own
4790
+ // ceiling included. ceilingFor's equal share would hand the job a
4791
+ // second copy of an allowance its parent already holds.
4792
+ const held = [...reserved.values()].reduce((a, b) => a + b, 0);
4793
+ const flowLeft = run.budgetUsd && run.budgetUsd > 0 ? Math.max(0, run.budgetUsd - platform.runSpend(run) - held) : null;
4794
+ const ceiling = stepCeilingFor(flowLeft, agentBudgets.get(agent), run.steps
4795
+ .filter((s) => s.agent === agent)
4796
+ .reduce((sum, s) => sum + (s.costUsd ?? 0) + (s !== child && s.status === "running" ? (reserved.get(s) ?? 0) : 0), 0), agent, 1);
4797
+ if (ceiling.ceilingUsd === 0) {
4798
+ child.status = "failed";
4799
+ child.events.push({ t: new Date().toISOString(), type: "error", text: `over budget — nothing left to spend before this job (${ceiling.note}); it was not started` });
4800
+ entry.status = "failed";
4801
+ entry.ms = 0;
4802
+ save();
4803
+ return { status: "failed", result: `${agent} was not started: over budget (${ceiling.note})`, wrote: [] };
4804
+ }
4805
+ if (ceiling.ceilingUsd !== null)
4806
+ reserved.set(child, ceiling.ceilingUsd);
4807
+ save();
4808
+ // A running sandbox does not hear a stop flag: when the parent stops
4809
+ // waiting (its tool timed out, or its step ended), destroy the job's
4810
+ // sandbox, so nothing it does outlives the step that asked for it.
4811
+ const parentGone = () => run.stopRequested === true || cancelled() || (parent.status !== "running" && parent.status !== "pending");
4812
+ let killedIt = false;
4813
+ const watch = setInterval(() => {
4814
+ if (killedIt || !parentGone() || !child.sandbox?.ref)
4815
+ return;
4816
+ killedIt = true;
4817
+ child.events.push({ t: new Date().toISOString(), type: "error", text: "stopped — the step that asked for this job stopped waiting for it" });
4818
+ try {
4819
+ platform.killSandbox?.(child.sandbox);
4820
+ }
4821
+ catch {
4822
+ // its own deadline ends it
4823
+ }
4824
+ }, 2_000);
4825
+ watch.unref?.();
4826
+ try {
4827
+ await runStep(dir, tenant, child, null, save, modelOverride, tags, effortOverride, run.id, undefined, ceiling.ceilingUsd, ceiling.note, run.test === true, () => {
4828
+ save();
4829
+ // The parent's step ending or cancelling ends the job too —
4830
+ // here while it waits for room; the watch above once it runs.
4831
+ return parentGone();
4832
+ }, flowTimezone, at);
4833
+ }
4834
+ catch (err) {
4835
+ child.status = "failed";
4836
+ child.events.push({ t: new Date().toISOString(), type: "error", text: err instanceof Error ? err.message : String(err) });
4837
+ }
4838
+ finally {
4839
+ clearInterval(watch);
4840
+ reserved.delete(child);
4841
+ }
4842
+ // runStep mutates child.status; widen past TS's "running" narrowing.
4843
+ const ended = child.status;
4844
+ const status = ended === "completed" ? "completed" : ended === "skipped" ? "skipped" : "failed";
4845
+ entry.status = status;
4846
+ entry.costUsd = child.costUsd ?? null;
4847
+ entry.ms = Date.now() - started;
4848
+ save();
4849
+ const errors = child.events.filter((e) => e.type === "error").map((e) => e.text).join("\n");
4850
+ return {
4851
+ status,
4852
+ result: child.result ?? (errors || "(no reply)"),
4853
+ wrote: child.wrote ?? [],
4854
+ };
4855
+ };
4513
4856
  // An agent's own `budget:` — the most it may spend in one run — is
4514
4857
  // read at launch, so a cap raised mid-run applies to the next step.
4515
4858
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
@@ -4629,7 +4972,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4629
4972
  () => {
4630
4973
  save();
4631
4974
  return run.stopRequested === true;
4632
- }, flowTimezone, run.steps.indexOf(step), siblingsOf(step), peerBus).finally(() => reserved.delete(step));
4975
+ }, flowTimezone, run.steps.indexOf(step), siblingsOf(step), peerBus, delegateFor(step)).finally(() => reserved.delete(step));
4633
4976
  // runStep mutates step.status; read it through a widened local
4634
4977
  // so TS doesn't keep the "running" narrowing from above.
4635
4978
  const outcome = step.status;
@@ -4755,8 +5098,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4755
5098
  // answered an earlier failure — an evaluator loop's previous cycle
4756
5099
  // — not this one.
4757
5100
  const leftovers = [];
4758
- for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
5101
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isSplicedRecord(run.steps[j]); j++) {
4759
5102
  const r = run.steps[j];
5103
+ if (r.subagentOf)
5104
+ continue;
4760
5105
  if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
4761
5106
  leftovers.push(r);
4762
5107
  }
@@ -4864,7 +5209,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4864
5209
  // A rescue record's own failure is already on the step it took over
4865
5210
  // ("also failed"); counted here it would fail a group whose step a
4866
5211
  // later rescue completed.
4867
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
5212
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isSplicedRecord(s));
4868
5213
  if (requiredFailed) {
4869
5214
  run.status = "failed";
4870
5215
  break;
@@ -4943,7 +5288,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4943
5288
  // A rescue answered its own failure and stays as it ended: reset
4944
5289
  // to pending it read as an interrupted rescue, and the next
4945
5290
  // cycle's failure was handed the old one's instructions.
4946
- if (isRescueRecord(s))
5291
+ if (isSplicedRecord(s))
4947
5292
  continue;
4948
5293
  s.status = "pending";
4949
5294
  s.result = null;
@@ -5289,6 +5634,9 @@ export async function waitForRun(tenant, workspace, runId, timeoutMs = 5 * 60_00
5289
5634
  */
5290
5635
  export function runResult(run) {
5291
5636
  for (let i = run.steps.length - 1; i >= 0; i--) {
5637
+ // A sandbox: own job's reply went to the step that asked (runSummary).
5638
+ if (run.steps[i].subagentOf)
5639
+ continue;
5292
5640
  const r = run.steps[i].result;
5293
5641
  if (r && r.trim())
5294
5642
  return r;
package/dist/src/scan.js CHANGED
@@ -77,7 +77,7 @@ export const SCAN_RULES = [
77
77
  title: "Reads untrusted content, holds private data and can send it out",
78
78
  help: "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
79
79
  "Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
80
- "and an agent that reads the web stays open), split the reading and the sending into two agents, " +
80
+ "and an agent that reads the web stays open), split the reading and the sending into two agents (a step each, or a reader sub-agent with \`sandbox: own\`), " +
81
81
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
82
82
  owasp: ["ASI01", "ASI02", "ASI03"],
83
83
  },
@@ -90,6 +90,16 @@ export const SCAN_RULES = [
90
90
  "and if that rule ever changed, they would be a privilege escalation. Grant them to the parent or remove them.",
91
91
  owasp: ["ASI03"],
92
92
  },
93
+ {
94
+ id: "security/subagent-own-sandbox",
95
+ category: "security",
96
+ level: "info",
97
+ title: "A sub-agent works in its own sandbox, with tools its parent does not have",
98
+ help: "`sandbox: own` on a sub-agent runs each job it is handed in a sandbox of its own, with its own tools, secrets and network — " +
99
+ "not cut to the parent's. That is the point (a reader with the web and no secrets, under a writer with secrets and no web), " +
100
+ "so this only says what it holds. Only its reply reaches the parent.",
101
+ owasp: ["ASI03"],
102
+ },
93
103
  {
94
104
  id: "supply-chain/pipe-to-shell",
95
105
  category: "supply-chain",
@@ -650,6 +660,12 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
650
660
  if (!sub || s === a.name)
651
661
  continue;
652
662
  const extra = refNames(sub.data.tools).filter((t) => !tools.includes(t));
663
+ if (sub.data.sandbox === "own") {
664
+ if (extra.length) {
665
+ push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`, `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply comes back`);
666
+ }
667
+ continue;
668
+ }
653
669
  if (extra.length && tools.length) {
654
670
  push("security/subagent-wider", a.file, keyLine(a.lines, "subagents"), `subagent ${s} ${extra.join(",")}`, `subagents: [${s}] — ${s} declares ${extra.join(", ")}, which ${a.name} does not have; inside the delegation they are dropped`);
655
671
  }