@foldrun/core 2.5.0 → 2.7.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.
- package/dist/src/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/capture.js +39 -1
- package/dist/src/completions.js +25 -3
- package/dist/src/deploy.js +72 -7
- package/dist/src/egress.d.ts +16 -0
- package/dist/src/evals.d.ts +22 -1
- package/dist/src/evals.js +43 -14
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +51 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +30 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/matter.d.ts +34 -0
- package/dist/src/matter.js +104 -0
- package/dist/src/network.d.ts +0 -1
- package/dist/src/network.js +10 -7
- package/dist/src/package.js +9 -2
- package/dist/src/provider-health.js +2 -2
- package/dist/src/run-container.d.ts +9 -0
- package/dist/src/run-container.js +34 -1
- package/dist/src/runner.js +193 -39
- package/dist/src/scan.d.ts +11 -0
- package/dist/src/scan.js +86 -8
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- package/dist/src/starter.js +23 -0
- package/dist/src/step-exec.d.ts +10 -2
- package/dist/src/step-exec.js +12 -9
- package/dist/src/store.d.ts +17 -3
- package/dist/src/store.js +14 -10
- package/package.json +1 -1
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/capture.ts +33 -1
- package/src/completions.ts +26 -3
- package/src/deploy.ts +59 -7
- package/src/egress.ts +15 -1
- package/src/evals.ts +45 -13
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +74 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +30 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/judge-verdict.ts +52 -0
- package/src/matter.ts +112 -0
- package/src/network.ts +10 -6
- package/src/package.ts +8 -2
- package/src/provider-health.ts +2 -2
- package/src/run-container.ts +41 -1
- package/src/runner.ts +189 -40
- package/src/scan.ts +86 -8
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
- package/src/starter.ts +23 -0
- package/src/step-exec.ts +21 -8
- package/src/store.ts +23 -10
package/src/package.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import matter from "./matter.ts";
|
|
29
|
+
import matter, { configMatter } from "./matter.ts";
|
|
30
30
|
import {
|
|
31
31
|
assertSafeName,
|
|
32
32
|
isEditablePath,
|
|
@@ -592,7 +592,13 @@ interface AgentFront {
|
|
|
592
592
|
}
|
|
593
593
|
|
|
594
594
|
function frontOf(content: string): AgentFront {
|
|
595
|
-
|
|
595
|
+
// An agent's secrets: and tools: may sit in its guardrail: block.
|
|
596
|
+
let d: Record<string, unknown>;
|
|
597
|
+
try {
|
|
598
|
+
d = configMatter(content).data as Record<string, unknown>;
|
|
599
|
+
} catch {
|
|
600
|
+
d = {};
|
|
601
|
+
}
|
|
596
602
|
const apis = Array.isArray(d.apis) ? d.apis.map((a) => (a && typeof a === "object" ? String((a as { name?: unknown }).name ?? "") : "")) : [];
|
|
597
603
|
const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
|
|
598
604
|
const scripts = list(d.scripts);
|
package/src/provider-health.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { accountModelBlock } from "./model-credential.ts";
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import path from "node:path";
|
|
24
|
-
import
|
|
24
|
+
import { configMatter } from "./matter.ts";
|
|
25
25
|
import { getSecret } from "./secrets.ts";
|
|
26
26
|
import { noteSecretUse, healthKey } from "./secret-health.ts";
|
|
27
27
|
import { accountDir, workspaceDir, listWorkspaces, listAgents, parseProvider, type ProviderSpec } from "./store.ts";
|
|
@@ -205,7 +205,7 @@ function cheapestModel(spec: ProviderSpec): string {
|
|
|
205
205
|
|
|
206
206
|
function frontmatterOf(file: string): Record<string, unknown> {
|
|
207
207
|
try {
|
|
208
|
-
return (
|
|
208
|
+
return (configMatter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
|
|
209
209
|
} catch {
|
|
210
210
|
return {};
|
|
211
211
|
}
|
package/src/run-container.ts
CHANGED
|
@@ -73,6 +73,9 @@ export interface ContainerStepInput {
|
|
|
73
73
|
budgetNote?: string;
|
|
74
74
|
price?: TokenPrice | null;
|
|
75
75
|
verify?: string;
|
|
76
|
+
/** The model `verify: judge:` grades with (the workspace's `judge:`). */
|
|
77
|
+
judgeModel?: string;
|
|
78
|
+
judgeEffort?: string;
|
|
76
79
|
/** `output: json` — checked inside, where the reply is. */
|
|
77
80
|
output?: "json";
|
|
78
81
|
/** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
|
|
@@ -556,7 +559,8 @@ export function runtimeCacheMount(
|
|
|
556
559
|
|
|
557
560
|
// ---------- the runner image ----------
|
|
558
561
|
|
|
559
|
-
|
|
562
|
+
/** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
|
|
563
|
+
export const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
|
|
560
564
|
// same core the server uses, speaks JSONL on stdout, exits.
|
|
561
565
|
import fs from "node:fs";
|
|
562
566
|
const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
|
|
@@ -722,6 +726,36 @@ try {
|
|
|
722
726
|
const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
|
|
723
727
|
const inbox = channel ? inboxReader(channel, emit) : undefined;
|
|
724
728
|
|
|
729
|
+
// Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
|
|
730
|
+
// fresh through the proxy's /identity door, which mints only what this
|
|
731
|
+
// step's lease was granted. Fetched before the step starts, renewed five
|
|
732
|
+
// minutes before it expires (with jitter); a failure is said and retried.
|
|
733
|
+
// Plain JS: this text is the driver, not TypeScript.
|
|
734
|
+
const identityNames = (env.FOLDRUN_IDENTITIES || "").split(",").filter(Boolean);
|
|
735
|
+
if (identityNames.length) {
|
|
736
|
+
// No backslashes: this sits inside a template literal, which eats them.
|
|
737
|
+
const ep = new RegExp("^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$").exec(env.FOLDRUN_EGRESS || "");
|
|
738
|
+
const dir = env.FOLDRUN_IDENTITY_DIR || "/tmp/foldrun-identity";
|
|
739
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
740
|
+
const keepFresh = async (name) => {
|
|
741
|
+
let wait = 30000;
|
|
742
|
+
try {
|
|
743
|
+
if (!ep) throw new Error("no egress proxy to ask");
|
|
744
|
+
const res = await fetch(ep[1] + "/identity/" + ep[2] + "/" + name, { signal: AbortSignal.timeout(15000) });
|
|
745
|
+
const body = await res.json().catch(() => ({}));
|
|
746
|
+
if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || ("proxy answered " + res.status));
|
|
747
|
+
const file = dir + "/" + name.toLowerCase() + ".jwt";
|
|
748
|
+
fs.writeFileSync(file + ".tmp", body.token, { mode: 0o600 });
|
|
749
|
+
fs.renameSync(file + ".tmp", file);
|
|
750
|
+
wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
emit("error", "identity " + name + ": " + (err && err.message ? err.message : String(err)) + " — trying again in 30s");
|
|
753
|
+
}
|
|
754
|
+
setTimeout(() => { void keepFresh(name); }, wait).unref();
|
|
755
|
+
};
|
|
756
|
+
await Promise.all(identityNames.map(keepFresh));
|
|
757
|
+
}
|
|
758
|
+
|
|
725
759
|
mark("tools");
|
|
726
760
|
const outcome = await executeStep({
|
|
727
761
|
agentDir,
|
|
@@ -754,6 +788,8 @@ try {
|
|
|
754
788
|
price: input.price,
|
|
755
789
|
verify: input.verify,
|
|
756
790
|
verifyEnv: toolEnv,
|
|
791
|
+
...(typeof input.judgeModel === "string" ? { judgeModel: input.judgeModel } : {}),
|
|
792
|
+
...(typeof input.judgeEffort === "string" ? { judgeEffort: input.judgeEffort } : {}),
|
|
757
793
|
output: input.output,
|
|
758
794
|
schema: input.schema,
|
|
759
795
|
maxTurns: input.maxTurns,
|
|
@@ -1358,6 +1394,10 @@ export interface RunInContainerArgs {
|
|
|
1358
1394
|
* without it, "stop" could only mean "stop after this step", and a
|
|
1359
1395
|
* browser step has fifteen minutes left to spend. */
|
|
1360
1396
|
runId?: string;
|
|
1397
|
+
/** Whether a person has stopped the run, read from the record. A step that
|
|
1398
|
+
* waits for room before its sandbox exists asks this, since there is no
|
|
1399
|
+
* sandbox yet for the stop to destroy. */
|
|
1400
|
+
stopRequested?: () => boolean;
|
|
1361
1401
|
/** The reservation class — which limits this step's sandbox holds. */
|
|
1362
1402
|
size?: "small" | "large" | "heavy";
|
|
1363
1403
|
/** Re-attach to a sandbox a previous driver started, instead of creating
|
package/src/runner.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { hookNames } from "./hooks.ts";
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import matter from "./matter.ts";
|
|
13
|
+
import matter, { parseLenient, configMatter } from "./matter.ts";
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import {
|
|
16
16
|
knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
|
|
@@ -21,6 +21,7 @@ import { eventUrl } from "./webhook.ts";
|
|
|
21
21
|
import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
|
|
22
22
|
import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
|
|
23
23
|
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.ts";
|
|
24
|
+
import { parseJudge } from "./judge-verdict.ts";
|
|
24
25
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
25
26
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
|
|
26
27
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
|
|
@@ -70,6 +71,7 @@ import {
|
|
|
70
71
|
checkFormatVersion,
|
|
71
72
|
readRun,
|
|
72
73
|
writeRun,
|
|
74
|
+
isRescueRecord,
|
|
73
75
|
runSummary,
|
|
74
76
|
verdictOf,
|
|
75
77
|
type FlowStep,
|
|
@@ -88,7 +90,7 @@ import {
|
|
|
88
90
|
import { canReadBack } from "./tool-output.ts";
|
|
89
91
|
import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
|
|
90
92
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
|
|
91
|
-
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
|
|
93
|
+
import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.ts";
|
|
92
94
|
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
93
95
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
|
|
94
96
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
|
|
@@ -335,28 +337,9 @@ const toTags = (v: unknown): string[] =>
|
|
|
335
337
|
// rather than dropping the skill, so a cross-client skill still loads here.
|
|
336
338
|
function safeMatter(raw: string): { data: Record<string, unknown> } {
|
|
337
339
|
// gray-matter caches a *failed* parse as a hollow success; ./matter.ts
|
|
338
|
-
// parses every call, so the
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
return matter(raw) as { data: Record<string, unknown> };
|
|
342
|
-
} catch {
|
|
343
|
-
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
344
|
-
if (fm) {
|
|
345
|
-
const fixed = fm[1].replace(
|
|
346
|
-
/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm,
|
|
347
|
-
(_m, k: string, v: string) => `${k}: ${JSON.stringify(v)}`,
|
|
348
|
-
);
|
|
349
|
-
try {
|
|
350
|
-
// A function replacer, not a string: a fixed block containing `$&` or
|
|
351
|
-
// `$$` (a value with those in it) would otherwise be treated as a
|
|
352
|
-
// replacement pattern and splice the whole frontmatter into itself.
|
|
353
|
-
return matter(raw.replace(fm[1], () => fixed)) as { data: Record<string, unknown> };
|
|
354
|
-
} catch {
|
|
355
|
-
/* fall through */
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return { data: {} };
|
|
359
|
-
}
|
|
340
|
+
// parses every call, so the strict read throws every time and the lenient
|
|
341
|
+
// one (shared with check, which warns about it) fires every time.
|
|
342
|
+
return { data: parseLenient(raw).data };
|
|
360
343
|
}
|
|
361
344
|
|
|
362
345
|
// Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
|
|
@@ -452,7 +435,7 @@ export function readAgentsMd(dir: string): { data: Record<string, unknown>; body
|
|
|
452
435
|
const file = path.join(dir, name);
|
|
453
436
|
if (!fs.existsSync(file)) continue;
|
|
454
437
|
try {
|
|
455
|
-
const parsed =
|
|
438
|
+
const parsed = configMatter(fs.readFileSync(file, "utf8"));
|
|
456
439
|
return { data: parsed.data as Record<string, unknown>, body: withoutAgentRules(parsed.content).trim() };
|
|
457
440
|
} catch {
|
|
458
441
|
return null;
|
|
@@ -855,7 +838,7 @@ export function agentContext(
|
|
|
855
838
|
// agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
|
|
856
839
|
// workspace a secret should resolve against is two levels up.
|
|
857
840
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
858
|
-
const { data: front, content: body } =
|
|
841
|
+
const { data: front, content: body } = configMatter(
|
|
859
842
|
fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"),
|
|
860
843
|
);
|
|
861
844
|
// The prompt's sections, by name and by how often they change. Added in
|
|
@@ -1502,7 +1485,8 @@ export function agentContext(
|
|
|
1502
1485
|
// the agent, and the date the platform believes it is.
|
|
1503
1486
|
{
|
|
1504
1487
|
...runtime.env,
|
|
1505
|
-
|
|
1488
|
+
// An unexported client certificate is the proxy's, never a script's.
|
|
1489
|
+
...withoutHeldCerts(secretEnv),
|
|
1506
1490
|
...(identity.runId ? { FOLDRUN_RUN_ID: identity.runId } : {}),
|
|
1507
1491
|
...(identity.agent ? { FOLDRUN_AGENT: identity.agent } : {}),
|
|
1508
1492
|
// And the workspace: a commit or a record made by a shared library
|
|
@@ -1783,6 +1767,9 @@ export function agentContext(
|
|
|
1783
1767
|
// asked for by name fails inside the container with "cannot find" while
|
|
1784
1768
|
// the host path, which uses this same merged spec, works fine.
|
|
1785
1769
|
runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
|
|
1770
|
+
// Whole: a bound client certificate goes from here into the egress
|
|
1771
|
+
// grant (runStep), which is where it is presented. Programs get theirs
|
|
1772
|
+
// filtered (withoutHeldCerts) where their environments are built.
|
|
1786
1773
|
secretEnv: { ...runtime.env, ...secretEnv },
|
|
1787
1774
|
formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
|
|
1788
1775
|
providerEnv,
|
|
@@ -2089,6 +2076,15 @@ async function runStep(
|
|
|
2089
2076
|
// AGENTS.md — every model call always; each tool call's input and output
|
|
2090
2077
|
// unless it says `metadata`.
|
|
2091
2078
|
const capture: "metadata" | "full" = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
|
|
2079
|
+
// `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
|
|
2080
|
+
// grades with — field by field, the workspace's else the account's.
|
|
2081
|
+
const { judgeModel, judgeEffort } = (() => {
|
|
2082
|
+
const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
|
|
2083
|
+
const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
|
|
2084
|
+
const model = ws.model ?? account.model;
|
|
2085
|
+
const effort = resolveEffort(ws.effort ?? account.effort ?? "");
|
|
2086
|
+
return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
|
|
2087
|
+
})();
|
|
2092
2088
|
// A step that pours out content stops being recorded past this — its
|
|
2093
2089
|
// calls keep their names, sizes and times (FOL-54 step 4).
|
|
2094
2090
|
let capturedChars = 0;
|
|
@@ -2694,11 +2690,27 @@ async function runStep(
|
|
|
2694
2690
|
readAgentsMd(accountDir(tenant))?.data?.network_enforce,
|
|
2695
2691
|
readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce,
|
|
2696
2692
|
);
|
|
2693
|
+
// Keyless cloud identities the step declared: the proxy may mint tokens
|
|
2694
|
+
// for these audiences only, for this workload (oidc-issuer.ts).
|
|
2695
|
+
// A test run withholds them as it withholds every credential
|
|
2696
|
+
// (test-mode.ts): a token is a credential, however it is delivered.
|
|
2697
|
+
const declaredIdentities = Object.entries(liveSecrets).filter(([, v]) => typeof v === "string" && isIdentityValue(v));
|
|
2698
|
+
const identitiesHeld = testRun
|
|
2699
|
+
? withholdSecrets(Object.fromEntries(declaredIdentities), {
|
|
2700
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2701
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2702
|
+
}).withheld
|
|
2703
|
+
: [];
|
|
2704
|
+
const identities = Object.fromEntries(
|
|
2705
|
+
declaredIdentities.filter(([name]) => !identitiesHeld.includes(name)).map(([name, v]) => [name, { audience: identityOf(v).audience }]),
|
|
2706
|
+
);
|
|
2707
|
+
if (Object.keys(identities).length) grant.identities = identities;
|
|
2697
2708
|
const lease = await platform.egress.lease({
|
|
2698
2709
|
tenant,
|
|
2699
2710
|
runId: runId ?? "adhoc",
|
|
2700
2711
|
grant,
|
|
2701
2712
|
test: testRun,
|
|
2713
|
+
who: { workspace: workspaceName, agent: step.agent },
|
|
2702
2714
|
...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
|
|
2703
2715
|
// tools: [peers] — the roster the proxy serves, and the inboxes a
|
|
2704
2716
|
// colleague's message may be addressed to. Only when granted: a
|
|
@@ -2771,6 +2783,34 @@ async function runStep(
|
|
|
2771
2783
|
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2772
2784
|
}
|
|
2773
2785
|
}
|
|
2786
|
+
// A keyless identity holds nothing to hand over: the sandbox gets the
|
|
2787
|
+
// path of a token file the driver keeps fresh through the proxy's
|
|
2788
|
+
// /identity door. Without a lease there is no door, and so no token.
|
|
2789
|
+
const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]!));
|
|
2790
|
+
for (const name of identityDeclared) delete sandboxSecrets[name];
|
|
2791
|
+
for (const name of identitiesHeld) {
|
|
2792
|
+
push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2793
|
+
}
|
|
2794
|
+
const identityNames = identityDeclared.filter((name) => !identitiesHeld.includes(name));
|
|
2795
|
+
if (identityNames.length) {
|
|
2796
|
+
if (lease) {
|
|
2797
|
+
const dir = "/tmp/foldrun-identity";
|
|
2798
|
+
sandboxSecrets.FOLDRUN_IDENTITIES = identityNames.join(",");
|
|
2799
|
+
sandboxSecrets.FOLDRUN_IDENTITY_DIR = dir;
|
|
2800
|
+
for (const name of identityNames) sandboxSecrets[`${name}_FILE`] = `${dir}/${name.toLowerCase()}.jwt`;
|
|
2801
|
+
push("info", `identity: ${identityNames.join(", ")} — a short-lived token in $NAME_FILE, renewed by the platform while the step runs`);
|
|
2802
|
+
} else {
|
|
2803
|
+
push("error", `identity: ${identityNames.join(", ")} needs a sandboxed step on the platform — no token here`);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
// A client certificate is the proxy's to present, to the exact sites it
|
|
2807
|
+
// is bound to; never the sandbox's, unless it was saved with `export`.
|
|
2808
|
+
for (const name of declared) {
|
|
2809
|
+
const v = liveSecrets[name];
|
|
2810
|
+
if (typeof v !== "string" || !isClientCertValue(v) || bound.includes(name) || clientCertOf(v).export) continue;
|
|
2811
|
+
delete sandboxSecrets[name];
|
|
2812
|
+
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`);
|
|
2813
|
+
}
|
|
2774
2814
|
// On a test run the http tools always go through the proxy as
|
|
2775
2815
|
// placeholders, even when a script made the step materialise: the
|
|
2776
2816
|
// proxy is where the outward-write policy lives, and a header
|
|
@@ -2857,6 +2897,8 @@ async function runStep(
|
|
|
2857
2897
|
budgetNote: stepBudgetNote,
|
|
2858
2898
|
price: tokenPrice,
|
|
2859
2899
|
verify: step.verify,
|
|
2900
|
+
...(judgeModel ? { judgeModel } : {}),
|
|
2901
|
+
...(judgeEffort ? { judgeEffort } : {}),
|
|
2860
2902
|
output: step.output,
|
|
2861
2903
|
schema: resolvedSchema ?? undefined,
|
|
2862
2904
|
maxTurns: step.maxTurns,
|
|
@@ -2913,6 +2955,9 @@ async function runStep(
|
|
|
2913
2955
|
emit: pushWatching,
|
|
2914
2956
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
2915
2957
|
runId,
|
|
2958
|
+
// A step still waiting for room has no sandbox to destroy; the
|
|
2959
|
+
// executor asks this before it creates one (pod-room.ts).
|
|
2960
|
+
...(stopRequested ? { stopRequested } : {}),
|
|
2916
2961
|
size,
|
|
2917
2962
|
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2918
2963
|
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
@@ -3177,7 +3222,20 @@ async function runStep(
|
|
|
3177
3222
|
for (const name of held.withheld) {
|
|
3178
3223
|
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
3179
3224
|
}
|
|
3180
|
-
|
|
3225
|
+
// A client certificate not saved with `export` has no proxy here to
|
|
3226
|
+
// present it: it stays out of everything this step runs — tools,
|
|
3227
|
+
// consults, verify commands — not only out of the files.
|
|
3228
|
+
const stepSecrets = Object.fromEntries(
|
|
3229
|
+
Object.entries(held.env).filter(([name, v]) => {
|
|
3230
|
+
if (typeof v === "string" && isIdentityValue(v)) {
|
|
3231
|
+
push("error", `identity: ${name} needs a sandboxed step on the platform — no token here`);
|
|
3232
|
+
return false;
|
|
3233
|
+
}
|
|
3234
|
+
if (typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export) return true;
|
|
3235
|
+
push("info", `secrets: ${name} is a client certificate — no egress proxy here to present it; save it with export to use it as files`);
|
|
3236
|
+
return false;
|
|
3237
|
+
}),
|
|
3238
|
+
);
|
|
3181
3239
|
// The model credential for this machine's Claude Code — the agent's
|
|
3182
3240
|
// own provider when it names one. The host allowlist drops it, so
|
|
3183
3241
|
// without this every in-process step ran on the machine's login.
|
|
@@ -3252,6 +3310,8 @@ async function runStep(
|
|
|
3252
3310
|
budgetNote: stepBudgetNote,
|
|
3253
3311
|
price: tokenPrice,
|
|
3254
3312
|
verify: step.verify,
|
|
3313
|
+
...(judgeModel ? { judgeModel } : {}),
|
|
3314
|
+
...(judgeEffort ? { judgeEffort } : {}),
|
|
3255
3315
|
output: step.output,
|
|
3256
3316
|
schema: resolvedSchema ?? undefined,
|
|
3257
3317
|
maxTurns: step.maxTurns,
|
|
@@ -3455,7 +3515,7 @@ export function copyTreeBytes(from: string, to: string) {
|
|
|
3455
3515
|
function openApiSources(agentDir: string, tenant: string): string[] {
|
|
3456
3516
|
const out = new Set<string>();
|
|
3457
3517
|
try {
|
|
3458
|
-
const { data } =
|
|
3518
|
+
const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
|
|
3459
3519
|
const front = data as { apis?: unknown; tools?: unknown };
|
|
3460
3520
|
for (const api of parseApis(front.apis)) if (api.openapi?.startsWith("https://")) out.add(api.openapi);
|
|
3461
3521
|
const workspace = path.basename(path.resolve(agentDir, "..", ".."));
|
|
@@ -4039,7 +4099,14 @@ export function driveRun(
|
|
|
4039
4099
|
opts: { parkOnApproval?: boolean; effortOverride?: string | null } = {},
|
|
4040
4100
|
): Promise<void> {
|
|
4041
4101
|
drivingRuns.add(run.id);
|
|
4102
|
+
// The run's egress leases are renewed while it is driven, by whichever
|
|
4103
|
+
// door drove it — the queue, or a reconcile that picked it back up — so an
|
|
4104
|
+
// identity token is renewed for a live step however it was started.
|
|
4105
|
+
const renew = platform.egress.renew;
|
|
4106
|
+
const beat = renew ? setInterval(() => void renew(run.id).catch(() => undefined), 5 * 60_000) : null;
|
|
4107
|
+
beat?.unref?.();
|
|
4042
4108
|
return driveRunInner(tenant, workspace, run, modelOverride, tags, opts).finally(() => {
|
|
4109
|
+
if (beat) clearInterval(beat);
|
|
4043
4110
|
drivingRuns.delete(run.id);
|
|
4044
4111
|
});
|
|
4045
4112
|
}
|
|
@@ -4080,10 +4147,30 @@ function driveRunInner(
|
|
|
4080
4147
|
*
|
|
4081
4148
|
* The flag is one-way: nothing here clears it, so the read-modify-write can
|
|
4082
4149
|
* only ever turn a stop on, never off.
|
|
4150
|
+
*
|
|
4151
|
+
* And once it is on, this copy may not undo what stopRun wrote: the run
|
|
4152
|
+
* stays failed, and steps that had not started stay skipped. Before
|
|
4153
|
+
* 21 Oct 2026 only the flag came across, so the next save put the record
|
|
4154
|
+
* back to `running` with its steps pending; a second stop then answered
|
|
4155
|
+
* "failed" instead of "already failed", and the group kept starting pods
|
|
4156
|
+
* (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
|
|
4157
|
+
* as it is: its sandbox is gone, it ends on its own, and records how.
|
|
4083
4158
|
*/
|
|
4084
4159
|
const save = () => {
|
|
4085
|
-
|
|
4086
|
-
|
|
4160
|
+
const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
|
|
4161
|
+
if (disk?.stopRequested) run.stopRequested = true;
|
|
4162
|
+
if (run.stopRequested) {
|
|
4163
|
+
if (run.status !== "completed" && run.status !== "failed") {
|
|
4164
|
+
run.status = "failed";
|
|
4165
|
+
run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
|
|
4166
|
+
}
|
|
4167
|
+
run.parkedAt = null;
|
|
4168
|
+
for (const s of run.steps) {
|
|
4169
|
+
if (s.status === "pending" || s.status === "awaiting-approval") {
|
|
4170
|
+
s.status = "skipped";
|
|
4171
|
+
s.skipReason ??= "run stopped";
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4087
4174
|
}
|
|
4088
4175
|
writeRun(tenant, workspace, run);
|
|
4089
4176
|
};
|
|
@@ -4301,13 +4388,20 @@ function driveRunInner(
|
|
|
4301
4388
|
for (let i = gi - 1; i >= 0; i--) if (groupData[i] !== undefined) return groupData[i];
|
|
4302
4389
|
return undefined;
|
|
4303
4390
|
};
|
|
4304
|
-
|
|
4391
|
+
// A rescue's result already stands on the step it took over, so the
|
|
4392
|
+
// three handoffs below read the flow's steps only: counting the rescue
|
|
4393
|
+
// too handed the next group the same output twice, or a stale one
|
|
4394
|
+
// from an evaluator loop's earlier cycle.
|
|
4395
|
+
const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isRescueRecord(s));
|
|
4396
|
+
const dataOf = (all: StepRecord[]): unknown => {
|
|
4397
|
+
const group = flowStepsOf(all);
|
|
4305
4398
|
const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
|
|
4306
4399
|
if (carrying.length === 0) return undefined;
|
|
4307
4400
|
if (carrying.length === 1 && !carrying[0].item) return carrying[0].data;
|
|
4308
4401
|
return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
|
|
4309
4402
|
};
|
|
4310
|
-
const resultsOf = (
|
|
4403
|
+
const resultsOf = (all: StepRecord[]): string | null => {
|
|
4404
|
+
const group = flowStepsOf(all);
|
|
4311
4405
|
const results = group
|
|
4312
4406
|
.filter((s) => s.status === "completed" && s.result)
|
|
4313
4407
|
.map((s) =>
|
|
@@ -4321,8 +4415,8 @@ function driveRunInner(
|
|
|
4321
4415
|
};
|
|
4322
4416
|
// What each step concluded — its final turn — for the handoffs that
|
|
4323
4417
|
// should read a conclusion rather than a transcript (`each: lines`).
|
|
4324
|
-
const conclusionsOf = (
|
|
4325
|
-
const out =
|
|
4418
|
+
const conclusionsOf = (all: StepRecord[]): string | null => {
|
|
4419
|
+
const out = flowStepsOf(all)
|
|
4326
4420
|
.filter((s) => s.status === "completed" && (s.conclusion || s.result))
|
|
4327
4421
|
.map((s) => s.conclusion || s.result!);
|
|
4328
4422
|
return out.length ? out.join("\n") : null;
|
|
@@ -4709,7 +4803,7 @@ function driveRunInner(
|
|
|
4709
4803
|
save();
|
|
4710
4804
|
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
4711
4805
|
// everything in this group was rejected
|
|
4712
|
-
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
|
|
4806
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
|
|
4713
4807
|
run.status = "failed";
|
|
4714
4808
|
break;
|
|
4715
4809
|
}
|
|
@@ -4723,7 +4817,11 @@ function driveRunInner(
|
|
|
4723
4817
|
// itself mid-turn at its share, so a fan-out of twenty cannot end
|
|
4724
4818
|
// twenty steps over — the shares sum to what was left. Between
|
|
4725
4819
|
// groups the check above still refuses to start the next one.
|
|
4726
|
-
|
|
4820
|
+
// A rescue record is the on-fail of a step in this group, not a step
|
|
4821
|
+
// of the flow: one left pending by a drive that died is picked up by
|
|
4822
|
+
// rescueStep below, never launched alongside the group (21 Oct 2026:
|
|
4823
|
+
// a resumed run launched 77 leftover fixers at once).
|
|
4824
|
+
const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
|
|
4727
4825
|
// tools: [peers] — what each step of the group is told about the
|
|
4728
4826
|
// others, and the bus they share when they run in this process.
|
|
4729
4827
|
const peerBus = localPeerBus();
|
|
@@ -4788,7 +4886,7 @@ function driveRunInner(
|
|
|
4788
4886
|
// lead's reply or for a slot — must not start it: the
|
|
4789
4887
|
// between-groups check runs only once this group is over,
|
|
4790
4888
|
// and save() is what carries the flag in from the record.
|
|
4791
|
-
|
|
4889
|
+
{
|
|
4792
4890
|
save();
|
|
4793
4891
|
if (run.stopRequested) {
|
|
4794
4892
|
step.status = "skipped";
|
|
@@ -4968,6 +5066,13 @@ function driveRunInner(
|
|
|
4968
5066
|
// runs out of cycles below, which used to fail the run without ever
|
|
4969
5067
|
// reaching the step's on-fail (medium-desk's claim-trimmer was
|
|
4970
5068
|
// written for exactly that case and could never run).
|
|
5069
|
+
// A rescue hears a stop the way a group step does: its model loop and
|
|
5070
|
+
// its executor's wait for room both poll this (no sandbox exists yet
|
|
5071
|
+
// for a stop to destroy while it waits).
|
|
5072
|
+
const stopPoll = () => {
|
|
5073
|
+
save();
|
|
5074
|
+
return run.stopRequested === true;
|
|
5075
|
+
};
|
|
4971
5076
|
const rescueStep = async (step: StepRecord): Promise<void> => {
|
|
4972
5077
|
if (!step.onFail) return;
|
|
4973
5078
|
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
@@ -4979,6 +5084,38 @@ function driveRunInner(
|
|
|
4979
5084
|
});
|
|
4980
5085
|
return;
|
|
4981
5086
|
}
|
|
5087
|
+
// One rescue per failure, across drives. A worker that died
|
|
5088
|
+
// mid-rescue left an unfinished record behind it; the drive that
|
|
5089
|
+
// resumes picks that one up instead of splicing another — a crash
|
|
5090
|
+
// loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
|
|
5091
|
+
// ~80). The leftover carries the same instruction, built from the
|
|
5092
|
+
// same failure, and a running one may still hold a sandbox to
|
|
5093
|
+
// re-attach to, so it is THE rescue; any further unfinished copies
|
|
5094
|
+
// are superseded. A FINISHED rescue is history, never reused: it
|
|
5095
|
+
// answered an earlier failure — an evaluator loop's previous cycle
|
|
5096
|
+
// — not this one.
|
|
5097
|
+
const leftovers: StepRecord[] = [];
|
|
5098
|
+
for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
|
|
5099
|
+
const r = run.steps[j];
|
|
5100
|
+
if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
|
|
5101
|
+
leftovers.push(r);
|
|
5102
|
+
}
|
|
5103
|
+
}
|
|
5104
|
+
const resumed = leftovers[0];
|
|
5105
|
+
for (const r of leftovers.slice(1)) {
|
|
5106
|
+
r.status = "skipped";
|
|
5107
|
+
r.skipReason = "superseded on resume — one rescue per failure";
|
|
5108
|
+
}
|
|
5109
|
+
if (resumed) {
|
|
5110
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
|
|
5111
|
+
resumed.rescueOf ??= step.agent;
|
|
5112
|
+
resumed.attempts = Math.max(1, resumed.attempts ?? 0);
|
|
5113
|
+
resumed.status = "running";
|
|
5114
|
+
save();
|
|
5115
|
+
await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
|
|
5116
|
+
settleRescue(step, resumed);
|
|
5117
|
+
return;
|
|
5118
|
+
}
|
|
4982
5119
|
step.events.push({
|
|
4983
5120
|
t: new Date().toISOString(),
|
|
4984
5121
|
type: "info",
|
|
@@ -4995,6 +5132,7 @@ function driveRunInner(
|
|
|
4995
5132
|
const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
|
|
4996
5133
|
const rescue: StepRecord = {
|
|
4997
5134
|
agent: step.onFail,
|
|
5135
|
+
rescueOf: step.agent,
|
|
4998
5136
|
instruction:
|
|
4999
5137
|
`${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
5000
5138
|
`Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
|
|
@@ -5025,7 +5163,11 @@ function driveRunInner(
|
|
|
5025
5163
|
rescue.attempts = 1;
|
|
5026
5164
|
rescue.status = "running";
|
|
5027
5165
|
save();
|
|
5028
|
-
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true,
|
|
5166
|
+
await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
|
|
5167
|
+
settleRescue(step, rescue);
|
|
5168
|
+
};
|
|
5169
|
+
// What a rescue's end means for the step it took over.
|
|
5170
|
+
const settleRescue = (step: StepRecord, rescue: StepRecord): void => {
|
|
5029
5171
|
// runStep mutates rescue.status; widen past TS's "running" narrowing.
|
|
5030
5172
|
const rescueOutcome: string = rescue.status;
|
|
5031
5173
|
if (rescueOutcome === "completed") {
|
|
@@ -5057,7 +5199,10 @@ function driveRunInner(
|
|
|
5057
5199
|
// here: the person chose to start after it. Counting it stopped
|
|
5058
5200
|
// fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
|
|
5059
5201
|
// a second, before the one step it was asked to run.
|
|
5060
|
-
|
|
5202
|
+
// A rescue record's own failure is already on the step it took over
|
|
5203
|
+
// ("also failed"); counted here it would fail a group whose step a
|
|
5204
|
+
// later rescue completed.
|
|
5205
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
|
|
5061
5206
|
if (requiredFailed) {
|
|
5062
5207
|
run.status = "failed";
|
|
5063
5208
|
break;
|
|
@@ -5133,6 +5278,10 @@ function driveRunInner(
|
|
|
5133
5278
|
});
|
|
5134
5279
|
for (const s of [...ordered[gi - 1], ...freshGroup]) {
|
|
5135
5280
|
if (s.status === "skipped" && s.each) continue; // an expanded template stays expanded
|
|
5281
|
+
// A rescue answered its own failure and stays as it ended: reset
|
|
5282
|
+
// to pending it read as an interrupted rescue, and the next
|
|
5283
|
+
// cycle's failure was handed the old one's instructions.
|
|
5284
|
+
if (isRescueRecord(s)) continue;
|
|
5136
5285
|
s.status = "pending";
|
|
5137
5286
|
s.result = null;
|
|
5138
5287
|
}
|