@foldrun/core 1.0.0 → 1.1.1
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/completions.js +1 -0
- package/dist/src/deploy.js +5 -0
- package/dist/src/egress.d.ts +21 -0
- package/dist/src/egress.js +8 -0
- package/dist/src/network.d.ts +120 -0
- package/dist/src/network.js +360 -0
- package/dist/src/run-container.d.ts +8 -0
- package/dist/src/run-container.js +2 -0
- package/dist/src/runner.js +128 -16
- package/dist/src/secrets.d.ts +20 -0
- package/dist/src/secrets.js +65 -1
- package/dist/src/starter.js +7 -1
- package/dist/src/store.d.ts +12 -0
- package/dist/src/store.js +32 -1
- package/dist/src/tool-loop.d.ts +45 -0
- package/dist/src/tool-loop.js +133 -0
- package/package.json +2 -1
- package/src/completions.ts +1 -0
- package/src/deploy.ts +4 -0
- package/src/egress.ts +26 -0
- package/src/network.ts +390 -0
- package/src/run-container.ts +6 -0
- package/src/runner.ts +130 -14
- package/src/secrets.ts +69 -1
- package/src/starter.ts +7 -1
- package/src/store.ts +35 -1
- package/src/tool-loop.ts +176 -0
package/src/runner.ts
CHANGED
|
@@ -81,7 +81,8 @@ import {
|
|
|
81
81
|
runCost,
|
|
82
82
|
} from "./store.ts";
|
|
83
83
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
|
|
84
|
-
import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.ts";
|
|
84
|
+
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
|
|
85
|
+
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
85
86
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
|
|
86
87
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
|
|
87
88
|
import { attachOperations, prefetchOpenApi } from "./openapi.ts";
|
|
@@ -109,6 +110,30 @@ export { localDate } from "./clock.ts";
|
|
|
109
110
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
110
111
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
111
112
|
// process without ever entering the model's context.
|
|
113
|
+
/** Remote MCP servers with each bound secret's value put back as its
|
|
114
|
+
* `${NAME}`, granted to that server's host — the sandbox then holds the
|
|
115
|
+
* name and the proxy fills it, as for an http tool. */
|
|
116
|
+
function unbindMcpHeaders(
|
|
117
|
+
servers: Record<string, McpServerConfig>,
|
|
118
|
+
bound: string[],
|
|
119
|
+
values: Record<string, string>,
|
|
120
|
+
grant: EgressGrant,
|
|
121
|
+
): Record<string, McpServerConfig> {
|
|
122
|
+
const known = Object.fromEntries(bound.filter((n) => n in values).map((n) => [n, values[n]]));
|
|
123
|
+
const out: Record<string, McpServerConfig> = {};
|
|
124
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
125
|
+
if (server.type !== "http" || !server.headers) {
|
|
126
|
+
out[name] = server;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const host = hostOf(server.url);
|
|
130
|
+
out[name] = host
|
|
131
|
+
? { ...server, headers: Object.fromEntries(Object.entries(server.headers).map(([k, v]) => [k, unsubstitute(v, known, host, grant)])) }
|
|
132
|
+
: server;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
function mcpConfig(spec: McpSpec, tenant: string, workspace: string): McpServerConfig {
|
|
113
138
|
const fill = (values: Record<string, string>) => {
|
|
114
139
|
const out: Record<string, string> = {};
|
|
@@ -2420,7 +2445,45 @@ async function runStep(
|
|
|
2420
2445
|
}
|
|
2421
2446
|
}
|
|
2422
2447
|
}
|
|
2448
|
+
// `network:` — which sites the sandbox may reach (network.ts). Resolved
|
|
2449
|
+
// before the lease, because the proxy holds the list per request. A
|
|
2450
|
+
// step that may fetch or browse the open web defaults to open; every
|
|
2451
|
+
// other to the trusted list plus what it declares.
|
|
2452
|
+
const workspaceName = path.basename(workspaceRootOf(agentDir));
|
|
2453
|
+
const readsWeb = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.some((a) => a === "fetch" || a === "browse"));
|
|
2454
|
+
// Secrets bound to sites in the vault: filled by the proxy for those
|
|
2455
|
+
// sites alone, never put in the sandbox (egress.ts).
|
|
2456
|
+
const boundSites = secretSites(tenant, Object.keys(liveSecrets), workspaceName);
|
|
2457
|
+
const network = resolveNetwork({
|
|
2458
|
+
account: readAgentsMd(accountDir(tenant))?.data?.network,
|
|
2459
|
+
workspace: readAgentsMd(workspaceRootOf(agentDir))?.data?.network,
|
|
2460
|
+
agent: front.network,
|
|
2461
|
+
readsWeb,
|
|
2462
|
+
implied: [
|
|
2463
|
+
...apiSpecs.map((a) => hostOf(a.base)).filter((h): h is string => !!h),
|
|
2464
|
+
...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h): h is string => !!h),
|
|
2465
|
+
...Object.values(grant.secrets).flatMap((g) => g.hosts),
|
|
2466
|
+
...Object.values(boundSites).flat(),
|
|
2467
|
+
],
|
|
2468
|
+
});
|
|
2469
|
+
grant.network = { level: network.level, hosts: network.hosts };
|
|
2423
2470
|
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
|
|
2471
|
+
// The forward door: every program in the sandbox sends through the
|
|
2472
|
+
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2473
|
+
// without the door (an older platform) leaves programs going direct.
|
|
2474
|
+
// A step that may browse gets no proxy variables: the browser in it
|
|
2475
|
+
// reads HTTPS_PROXY too, and Chromium cannot send credentials written
|
|
2476
|
+
// into a proxy URL — every page failed ERR_INVALID_AUTH_CREDENTIALS
|
|
2477
|
+
// (14 Oct 2026, the reel posters). Such a step is open by default,
|
|
2478
|
+
// and its bound secrets materialise as they did before the door.
|
|
2479
|
+
const browses = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse"));
|
|
2480
|
+
const forward = lease?.proxy && !browses ? lease : null;
|
|
2481
|
+
push("info", networkLine(network, forward ? forward.enforce === true : null));
|
|
2482
|
+
const bound = forward ? Object.keys(boundSites).filter((name) => name in liveSecrets) : [];
|
|
2483
|
+
for (const name of bound) {
|
|
2484
|
+
for (const site of boundSites[name]) addGrant(grant, name, liveSecrets[name], site);
|
|
2485
|
+
(grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", workspaceName);
|
|
2486
|
+
}
|
|
2424
2487
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
2425
2488
|
// reads them from its environment, and unless it says `secrets:
|
|
2426
2489
|
// proxied` it needs the value, not the name. bash is a script by
|
|
@@ -2432,22 +2495,27 @@ async function runStep(
|
|
|
2432
2495
|
...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
|
|
2433
2496
|
...(allowed.includes("Bash") ? ["bash"] : []),
|
|
2434
2497
|
];
|
|
2435
|
-
const materialise = !lease || materialisers.length > 0;
|
|
2436
2498
|
const declared = Object.keys(liveSecrets);
|
|
2499
|
+
// A bound secret is never materialised, whatever the step grants.
|
|
2500
|
+
const unbound = declared.filter((name) => !bound.includes(name));
|
|
2501
|
+
const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
|
|
2437
2502
|
if (lease) {
|
|
2503
|
+
const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
|
|
2438
2504
|
push(
|
|
2439
2505
|
"info",
|
|
2440
2506
|
declared.length
|
|
2441
2507
|
? materialise
|
|
2442
|
-
? `secrets: ${
|
|
2443
|
-
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names`
|
|
2508
|
+
? `secrets: ${unbound.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}${boundNote ? `; ${boundNote}` : ""}; api tools and the model key go through the egress proxy`
|
|
2509
|
+
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names${boundNote ? ` (${boundNote})` : ""}`
|
|
2444
2510
|
: "secrets: none declared; the model key goes through the egress proxy",
|
|
2445
2511
|
);
|
|
2446
2512
|
}
|
|
2447
2513
|
// Sandbox env for the declared secrets: the value, or its own name as
|
|
2448
2514
|
// a placeholder that the proxy fills on the way out.
|
|
2449
2515
|
const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
|
|
2450
|
-
let sandboxSecrets: Record<string, string> = materialise
|
|
2516
|
+
let sandboxSecrets: Record<string, string> = materialise
|
|
2517
|
+
? { ...liveSecrets, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) }
|
|
2518
|
+
: placeholders;
|
|
2451
2519
|
// A test run hands the sandbox no send-capable value. The env var is
|
|
2452
2520
|
// still there, reading TEST_MODE_WITHHELD, so a script that never
|
|
2453
2521
|
// checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
|
|
@@ -2455,11 +2523,13 @@ async function runStep(
|
|
|
2455
2523
|
// has a declared sender (everything withheld) or a declared reader
|
|
2456
2524
|
// (nothing withheld); see test-mode.ts for the order.
|
|
2457
2525
|
if (testRun && materialise) {
|
|
2458
|
-
const held = withholdSecrets(liveSecrets, {
|
|
2526
|
+
const held = withholdSecrets(Object.fromEntries(unbound.map((name) => [name, liveSecrets[name]])), {
|
|
2459
2527
|
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2460
2528
|
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2461
2529
|
});
|
|
2462
|
-
|
|
2530
|
+
// Bound secrets stay placeholders: the proxy, which holds the test
|
|
2531
|
+
// policy, is the only party that ever fills them.
|
|
2532
|
+
sandboxSecrets = { ...held.env, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) };
|
|
2463
2533
|
for (const name of held.withheld) {
|
|
2464
2534
|
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2465
2535
|
}
|
|
@@ -2517,7 +2587,9 @@ async function runStep(
|
|
|
2517
2587
|
systemPrompt,
|
|
2518
2588
|
allowed,
|
|
2519
2589
|
mcpNames: consultNames,
|
|
2520
|
-
|
|
2590
|
+
// A remote MCP server's headers had their secrets filled host-side;
|
|
2591
|
+
// a bound one goes back to its ${NAME} for the proxy to fill.
|
|
2592
|
+
mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
|
|
2521
2593
|
apis: substitutedApis,
|
|
2522
2594
|
scripts: scriptSpecs,
|
|
2523
2595
|
// The merged spec, not `front.runtime` — see the plan's `runtime`
|
|
@@ -2561,6 +2633,14 @@ async function runStep(
|
|
|
2561
2633
|
// after and carries the agent's chosen endpoint + token.
|
|
2562
2634
|
...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
|
|
2563
2635
|
...(lease ? { [EGRESS_ENV]: lease.url } : {}),
|
|
2636
|
+
// Every program's HTTP(S) through the proxy's forward door, and
|
|
2637
|
+
// the CA it signs bound sites with, for the sandbox to trust.
|
|
2638
|
+
...(forward ? proxyEnv(forward.proxy!) : {}),
|
|
2639
|
+
// Only a step with a bound secret has a site the proxy
|
|
2640
|
+
// intercepts, so only that step's TLS clients are pointed at a
|
|
2641
|
+
// bundle with the egress CA in it; every other step's are as
|
|
2642
|
+
// they always were.
|
|
2643
|
+
...(forward?.ca && bound.length ? { [EGRESS_CA_ENV]: Buffer.from(forward.ca).toString("base64") } : {}),
|
|
2564
2644
|
...clockEnv,
|
|
2565
2645
|
// Who is running: the in-process path stamps scripts with these
|
|
2566
2646
|
// host-side, but a container rebuilds its tools from its own
|
|
@@ -2578,6 +2658,9 @@ async function runStep(
|
|
|
2578
2658
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
2579
2659
|
runId,
|
|
2580
2660
|
size,
|
|
2661
|
+
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2662
|
+
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
2663
|
+
network: { level: network.level, enforced: forward?.enforce === true },
|
|
2581
2664
|
// A test run's state/ and storage/ changes come out under the run.
|
|
2582
2665
|
divert: testRun && runId ? divertFor(runId) : undefined,
|
|
2583
2666
|
test: testRun,
|
|
@@ -4558,9 +4641,12 @@ function driveRunInner(
|
|
|
4558
4641
|
// Not after a stop: the failure a stop leaves behind is not one a
|
|
4559
4642
|
// rescuer should take over. The merge in save() surfaces the flag.
|
|
4560
4643
|
save();
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4644
|
+
// One rescue of one failed step. Also used when an evaluator loop
|
|
4645
|
+
// runs out of cycles below, which used to fail the run without ever
|
|
4646
|
+
// reaching the step's on-fail (medium-desk's claim-trimmer was
|
|
4647
|
+
// written for exactly that case and could never run).
|
|
4648
|
+
const rescueStep = async (step: StepRecord): Promise<void> => {
|
|
4649
|
+
if (!step.onFail) return;
|
|
4564
4650
|
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
4565
4651
|
if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
4566
4652
|
step.events.push({
|
|
@@ -4568,7 +4654,7 @@ function driveRunInner(
|
|
|
4568
4654
|
type: "error",
|
|
4569
4655
|
text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
|
|
4570
4656
|
});
|
|
4571
|
-
|
|
4657
|
+
return;
|
|
4572
4658
|
}
|
|
4573
4659
|
step.events.push({
|
|
4574
4660
|
t: new Date().toISOString(),
|
|
@@ -4576,11 +4662,22 @@ function driveRunInner(
|
|
|
4576
4662
|
text: `failed — handing to [[${step.onFail}]] (on-fail)`,
|
|
4577
4663
|
});
|
|
4578
4664
|
const failure = (step.result ?? step.events.filter((e) => e.type === "error").map((e) => e.text).join("\n")).slice(0, 4000);
|
|
4665
|
+
// A gated step's approval travels with it: the rescuer acts inside
|
|
4666
|
+
// the same answer the failed agent had, and must be told what it
|
|
4667
|
+
// was. Fixers used to dig it out of the run record, and stop when
|
|
4668
|
+
// they could not (15 Oct 2026).
|
|
4669
|
+
const approved = step.approvedAt
|
|
4670
|
+
? `\nA person approved this step before it ran${step.approvalNote ? " — their answer is below" : ", with no note: a plain yes"}. Act within that approval and nothing wider.`
|
|
4671
|
+
: "";
|
|
4672
|
+
const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
|
|
4579
4673
|
const rescue: StepRecord = {
|
|
4580
4674
|
agent: step.onFail,
|
|
4581
4675
|
instruction:
|
|
4582
4676
|
`${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
4583
|
-
`Its failure, for whatever it is worth:\n${failure}\n</rescue>`,
|
|
4677
|
+
`Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
|
|
4678
|
+
...(step.ask ? { ask: step.ask } : {}),
|
|
4679
|
+
...(step.approvedAt ? { approvedAt: step.approvedAt } : {}),
|
|
4680
|
+
...(step.approvalNote ? { approvalNote: step.approvalNote } : {}),
|
|
4584
4681
|
group: step.group,
|
|
4585
4682
|
optional: false,
|
|
4586
4683
|
model: step.model,
|
|
@@ -4626,6 +4723,11 @@ function driveRunInner(
|
|
|
4626
4723
|
});
|
|
4627
4724
|
}
|
|
4628
4725
|
save();
|
|
4726
|
+
};
|
|
4727
|
+
for (const step of freshGroup) {
|
|
4728
|
+
if (run.stopRequested) break;
|
|
4729
|
+
if (step.status !== "failed" || !step.onFail) continue;
|
|
4730
|
+
await rescueStep(step);
|
|
4629
4731
|
}
|
|
4630
4732
|
|
|
4631
4733
|
// A step carried into a re-run failed in the run it came from, not
|
|
@@ -4726,7 +4828,21 @@ function driveRunInner(
|
|
|
4726
4828
|
text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
|
|
4727
4829
|
});
|
|
4728
4830
|
save();
|
|
4729
|
-
if (
|
|
4831
|
+
if (looper.onFail && !run.stopRequested) {
|
|
4832
|
+
await rescueStep(looper);
|
|
4833
|
+
// The rescuer owes what the loop owed: its marker.
|
|
4834
|
+
// rescueStep mutates the status; widen past TS's "failed" narrowing.
|
|
4835
|
+
if ((looper.status as string) === "completed" && !saysUntilMarker(looper.result ?? null, looper.until)) {
|
|
4836
|
+
looper.status = "failed";
|
|
4837
|
+
looper.events.push({
|
|
4838
|
+
t: new Date().toISOString(),
|
|
4839
|
+
type: "error",
|
|
4840
|
+
text: `on-fail: [[${looper.onFail}]]'s result does not say "${looper.until}" either`,
|
|
4841
|
+
});
|
|
4842
|
+
save();
|
|
4843
|
+
}
|
|
4844
|
+
}
|
|
4845
|
+
if ((looper.status as string) === "failed" && !looper.optional) {
|
|
4730
4846
|
run.status = "failed";
|
|
4731
4847
|
break;
|
|
4732
4848
|
}
|
package/src/secrets.ts
CHANGED
|
@@ -42,6 +42,7 @@ const tenantKey = (tenant: string) => platform.tenantKey(tenant);
|
|
|
42
42
|
import crypto from "node:crypto";
|
|
43
43
|
import { assertSafeName } from "./store.ts";
|
|
44
44
|
import { trimSlashes } from "./paths.ts";
|
|
45
|
+
import { SITE } from "./network.ts";
|
|
45
46
|
|
|
46
47
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
47
48
|
|
|
@@ -99,6 +100,11 @@ interface StoredSecret {
|
|
|
99
100
|
* record written before per-account keys existed is encrypted with — so an
|
|
100
101
|
* old vault stays readable without a migration having to run first. */
|
|
101
102
|
k?: "tenant";
|
|
103
|
+
/** The sites this secret is bound to (network.ts SITE shape). A bound
|
|
104
|
+
* secret never enters a sandbox: the egress proxy fills its `${NAME}` for
|
|
105
|
+
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
106
|
+
* beside the ciphertext and survives the value being set again. */
|
|
107
|
+
sites?: string[];
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
type SecretsFile = Record<string, StoredSecret>;
|
|
@@ -195,15 +201,67 @@ export function setSecret(
|
|
|
195
201
|
if (value.length > 8192) throw new Error("secret too long");
|
|
196
202
|
const sealed = encryptValue(tenant, value);
|
|
197
203
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
204
|
+
// A new value keeps the sites the old one was bound to: binding is a
|
|
205
|
+
// property of the credential's use, not of its current value, and a
|
|
206
|
+
// rotation that silently unbound it would put the new value in pods.
|
|
207
|
+
const sites = all[name]?.sites;
|
|
198
208
|
all[name] = {
|
|
199
209
|
...sealed,
|
|
200
210
|
updatedAt: new Date().toISOString(),
|
|
201
211
|
...(kind ? { kind } : {}),
|
|
212
|
+
...(sites?.length ? { sites } : {}),
|
|
202
213
|
};
|
|
203
214
|
return all;
|
|
204
215
|
});
|
|
205
216
|
}
|
|
206
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
220
|
+
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
221
|
+
* scheme, path or port. Returns the list as stored.
|
|
222
|
+
*/
|
|
223
|
+
export function setSecretSites(tenant: string, name: string, sites: string[], workspace?: string): string[] {
|
|
224
|
+
const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
225
|
+
for (const site of clean) {
|
|
226
|
+
if (!SITE.test(site)) throw new Error(`"${site}" is not a site — a domain like api.example.com or *.example.com, no scheme, path or port`);
|
|
227
|
+
}
|
|
228
|
+
let found = false;
|
|
229
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
230
|
+
const rec = all[name];
|
|
231
|
+
if (!rec) return null;
|
|
232
|
+
found = true;
|
|
233
|
+
// A file, an ssh key or an @api header set is not a value that travels
|
|
234
|
+
// in a header — it is unpacked inside the sandbox, so there is nothing
|
|
235
|
+
// for the proxy to fill.
|
|
236
|
+
if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
|
|
237
|
+
throw new Error(`${name} is a ${rec.kind} secret, unpacked inside the sandbox — only a value sent in a request can be bound to sites`);
|
|
238
|
+
}
|
|
239
|
+
if (clean.length) rec.sites = clean;
|
|
240
|
+
else delete rec.sites;
|
|
241
|
+
return all;
|
|
242
|
+
});
|
|
243
|
+
if (!found) throw new Error(`no secret ${name}${workspace ? ` in workspace ${workspace}` : " in the account"}`);
|
|
244
|
+
return clean;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The sites each named secret is bound to, nearest scope first — the same
|
|
249
|
+
* record getSecret would read the value from. A name with no binding (or
|
|
250
|
+
* no secret) is absent from the answer.
|
|
251
|
+
*/
|
|
252
|
+
export function secretSites(tenant: string, names: string[], workspace?: string): Record<string, string[]> {
|
|
253
|
+
const out: Record<string, string[]> = {};
|
|
254
|
+
const own = workspace ? read(tenant, workspace) : {};
|
|
255
|
+
const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
|
|
256
|
+
const inherited = source ? read(tenant, source) : {};
|
|
257
|
+
const account = read(tenant);
|
|
258
|
+
for (const name of names) {
|
|
259
|
+
const rec = own[name] ?? inherited[name] ?? account[name];
|
|
260
|
+
if (rec?.sites?.length) out[name] = [...rec.sites];
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
207
265
|
/**
|
|
208
266
|
* Which key writes this account's records.
|
|
209
267
|
*
|
|
@@ -321,6 +379,9 @@ export interface SecretEntry {
|
|
|
321
379
|
/** How this credential works: auto-refreshing oauth2, a signed service
|
|
322
380
|
* account, a file/ssh/api materialised into runs — absent for static values. */
|
|
323
381
|
kind?: SecretKind;
|
|
382
|
+
/** The sites it is bound to — the proxy fills it for these and no other,
|
|
383
|
+
* and it never enters a sandbox. Absent when unbound. */
|
|
384
|
+
sites?: string[];
|
|
324
385
|
}
|
|
325
386
|
|
|
326
387
|
// Metadata only — never values. Without a workspace this lists the account
|
|
@@ -332,6 +393,7 @@ export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
|
|
|
332
393
|
updatedAt: rec.updatedAt,
|
|
333
394
|
scope: "account" as const,
|
|
334
395
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
396
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
335
397
|
}));
|
|
336
398
|
if (!workspace) return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
337
399
|
|
|
@@ -341,6 +403,7 @@ export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
|
|
|
341
403
|
scope: "workspace" as const,
|
|
342
404
|
shadowed: account.some((a) => a.name === name),
|
|
343
405
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
406
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
344
407
|
}));
|
|
345
408
|
const ownNames = new Set(own.map((s) => s.name));
|
|
346
409
|
return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) =>
|
|
@@ -913,7 +976,12 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
913
976
|
if (plain.size === 0 || unreadableHere.length) return null;
|
|
914
977
|
const next: SecretsFile = { ...all };
|
|
915
978
|
for (const [name, value] of plain) {
|
|
916
|
-
next[name] = {
|
|
979
|
+
next[name] = {
|
|
980
|
+
...encryptValue(tenant, value),
|
|
981
|
+
updatedAt: all[name].updatedAt,
|
|
982
|
+
...(all[name].kind ? { kind: all[name].kind } : {}),
|
|
983
|
+
...(all[name].sites?.length ? { sites: all[name].sites } : {}),
|
|
984
|
+
};
|
|
917
985
|
}
|
|
918
986
|
moved += plain.size;
|
|
919
987
|
return next;
|
package/src/starter.ts
CHANGED
|
@@ -311,7 +311,11 @@ memory an agent wrote that your push does not mention. Those belong to the platf
|
|
|
311
311
|
reverted what an agent learned would make every run a little dumber.
|
|
312
312
|
|
|
313
313
|
Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
|
|
314
|
-
agents reference them by name.
|
|
314
|
+
agents reference them by name. Bind one to the sites it is for
|
|
315
|
+
(\`foldrun secrets sites NAME api.example.com\`) and it never enters a sandbox:
|
|
316
|
+
the egress proxy fills \`\${NAME}\` in requests to those sites alone. Which sites
|
|
317
|
+
a sandbox may reach at all is \`network:\` — open for an agent that reads the web,
|
|
318
|
+
the trusted list plus what it declares for every other (\`foldrun agent network\`).
|
|
315
319
|
|
|
316
320
|
## Talking to a deployed workspace
|
|
317
321
|
|
|
@@ -357,6 +361,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
357
361
|
| \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
|
|
358
362
|
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
359
363
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
364
|
+
| \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
|
|
360
365
|
|
|
361
366
|
Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
|
|
362
367
|
\`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
@@ -464,6 +469,7 @@ agents: [writer] # colleagues this one may consult (an answer, no too
|
|
|
464
469
|
subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
|
|
465
470
|
secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
|
|
466
471
|
limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
|
|
472
|
+
network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
|
|
467
473
|
timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
|
|
468
474
|
provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
|
|
469
475
|
permissionMode: plan # plan first, act once approved
|
package/src/store.ts
CHANGED
|
@@ -49,7 +49,8 @@ import { refNames } from "./refs.ts";
|
|
|
49
49
|
import { parseBudget, budgetProblem } from "./budget.ts";
|
|
50
50
|
import { timezoneProblem } from "./clock.ts";
|
|
51
51
|
import { parseStepLimits, readLimits } from "./limits.ts";
|
|
52
|
-
import {
|
|
52
|
+
import { networkProblems, resolveNetwork, type NetworkPolicy } from "./network.ts";
|
|
53
|
+
import { webProblems, webWarnings, runnerEngines, webConfig } from "./providers.ts";
|
|
53
54
|
import { actionProblems } from "./web-actions.ts";
|
|
54
55
|
import { languageProblem } from "./language.ts";
|
|
55
56
|
import { localeProblems } from "./locale.ts";
|
|
@@ -995,6 +996,15 @@ export interface AgentInfo {
|
|
|
995
996
|
/** What is wrong with the `limits:` block's shape; key-vs-grant problems
|
|
996
997
|
* need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
|
|
997
998
|
limitProblems: string[];
|
|
999
|
+
/** What cannot be read in `network:` (network.ts) — a level that is not
|
|
1000
|
+
* one, a site with a scheme or a path. */
|
|
1001
|
+
networkProblems: string[];
|
|
1002
|
+
/** The sites this agent's steps may reach, resolved the way a run
|
|
1003
|
+
* resolves them — its `network:`, the workspace's, the account's, and
|
|
1004
|
+
* the default (open when it may fetch or browse the web, else trusted).
|
|
1005
|
+
* `implied` here is its `apis:` hosts; a run also adds its MCP servers',
|
|
1006
|
+
* its web providers' and its bound secrets' sites. */
|
|
1007
|
+
network: NetworkPolicy & { readsWeb: boolean };
|
|
998
1008
|
/** Tools this agent declares inline rather than in `tools:` — `apis:`,
|
|
999
1009
|
* `scripts:` and `mcpServers:` names — which a limit may also name. */
|
|
1000
1010
|
inlineTools: string[];
|
|
@@ -1536,6 +1546,16 @@ function inlineToolNames(data: Record<string, unknown>): string[] {
|
|
|
1536
1546
|
export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
1537
1547
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
1538
1548
|
if (!fs.existsSync(dir)) return [];
|
|
1549
|
+
// The two levels above every agent, for the network: cascade.
|
|
1550
|
+
const frontOf = (file: string): Record<string, unknown> => {
|
|
1551
|
+
try {
|
|
1552
|
+
return readFrontmatter(file);
|
|
1553
|
+
} catch {
|
|
1554
|
+
return {}; // no AGENTS.md at that level, or one that does not parse
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
const accountFront = frontOf(path.join(accountDir(tenant), "AGENTS.md"));
|
|
1558
|
+
const workspaceFront = frontOf(path.join(workspaceDir(tenant, workspace), "AGENTS.md"));
|
|
1539
1559
|
return fs
|
|
1540
1560
|
.readdirSync(dir)
|
|
1541
1561
|
.filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
|
|
@@ -1543,7 +1563,20 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
|
1543
1563
|
.map((name) => {
|
|
1544
1564
|
const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
|
|
1545
1565
|
const tools = refNames(data.tools);
|
|
1566
|
+
const actions = webConfig(data, { ...accountFront, ...workspaceFront }).actions;
|
|
1567
|
+
const readsWeb = tools.includes("web") && (!actions || actions.some((a) => a === "fetch" || a === "browse"));
|
|
1568
|
+
const network = {
|
|
1569
|
+
...resolveNetwork({
|
|
1570
|
+
account: accountFront.network,
|
|
1571
|
+
workspace: workspaceFront.network,
|
|
1572
|
+
agent: data.network,
|
|
1573
|
+
readsWeb,
|
|
1574
|
+
implied: parseApis(data.apis).map((a) => { try { return new URL(a.base).host; } catch { return ""; } }),
|
|
1575
|
+
}),
|
|
1576
|
+
readsWeb,
|
|
1577
|
+
};
|
|
1546
1578
|
return {
|
|
1579
|
+
network,
|
|
1547
1580
|
name,
|
|
1548
1581
|
description: data.description ?? "",
|
|
1549
1582
|
model: data.model ?? "default",
|
|
@@ -1567,6 +1600,7 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
|
1567
1600
|
webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
|
|
1568
1601
|
scheduleProblem: agentScheduleProblem(data),
|
|
1569
1602
|
...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
|
|
1603
|
+
networkProblems: networkProblems(data.network),
|
|
1570
1604
|
inlineTools: inlineToolNames(data),
|
|
1571
1605
|
};
|
|
1572
1606
|
});
|
package/src/tool-loop.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// A model with tools that live in this process, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// What the platform's assistant needs (and what the eval judge and the flow
|
|
4
|
+
// draft are a toolless special case of): one Agent SDK query whose ONLY
|
|
5
|
+
// tools are the ones the caller passes — no built-in tool, no file system,
|
|
6
|
+
// no shell, no sub-agent, no MCP server the machine happens to have — run in
|
|
7
|
+
// a throwaway directory, inside a turn, dollar and wall-clock ceiling.
|
|
8
|
+
//
|
|
9
|
+
// The tools run here, in the caller's process, so whatever they read they
|
|
10
|
+
// read with the caller's own scoping; the model only ever sees what a tool
|
|
11
|
+
// returns. That is why this needs no sandbox pod: nothing the model asks for
|
|
12
|
+
// is executed as code.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { query, createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
19
|
+
import { hostSafeEnv } from "./host-env.ts";
|
|
20
|
+
|
|
21
|
+
/** One string argument: what it means, whether it may be left out, how long it may be. */
|
|
22
|
+
export interface LoopArg {
|
|
23
|
+
description?: string;
|
|
24
|
+
optional?: boolean;
|
|
25
|
+
max?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LoopTool {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
args: Record<string, LoopArg>;
|
|
32
|
+
/** The answer the model reads. A throw becomes an error result, not a crash. */
|
|
33
|
+
run: (args: Record<string, string | undefined>) => Promise<string> | string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LoopUsage {
|
|
37
|
+
/** Everything sent, cache reads and writes included. */
|
|
38
|
+
inputTokens: number;
|
|
39
|
+
outputTokens: number;
|
|
40
|
+
costUsd: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LoopResult {
|
|
44
|
+
/** The last turn's text — the answer, not the thinking aloud between tool calls. */
|
|
45
|
+
text: string;
|
|
46
|
+
/** What the SDK reports the query cost; 0 when it never reported (killed). */
|
|
47
|
+
costUsd: number;
|
|
48
|
+
usage: LoopUsage | null;
|
|
49
|
+
/** Why it ended, when it did not end well: max turns, budget, wall clock. */
|
|
50
|
+
stopped: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LoopOptions {
|
|
54
|
+
system: string;
|
|
55
|
+
prompt: string;
|
|
56
|
+
tools: LoopTool[];
|
|
57
|
+
model: string;
|
|
58
|
+
/** The model credential and anything else the SDK process needs. */
|
|
59
|
+
env: Record<string, string | undefined>;
|
|
60
|
+
maxTurns: number;
|
|
61
|
+
maxBudgetUsd: number;
|
|
62
|
+
maxOutputTokens: number;
|
|
63
|
+
wallSeconds: number;
|
|
64
|
+
/** The reply as it is written, for a live view. */
|
|
65
|
+
onText?: (delta: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SERVER = "foldrun_tools";
|
|
69
|
+
|
|
70
|
+
/** The Agent SDK's name for one of our tools. */
|
|
71
|
+
export const loopToolName = (name: string) => `mcp__${SERVER}__${name}`;
|
|
72
|
+
|
|
73
|
+
function schemaOf(args: Record<string, LoopArg>): z.ZodRawShape {
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
Object.entries(args).map(([name, a]) => {
|
|
76
|
+
let s = z.string().min(a.optional ? 0 : 1);
|
|
77
|
+
if (a.max) s = s.max(a.max);
|
|
78
|
+
if (a.description) s = s.describe(a.description);
|
|
79
|
+
return [name, a.optional ? s.optional() : s];
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
85
|
+
const server = createSdkMcpServer({
|
|
86
|
+
name: SERVER,
|
|
87
|
+
version: "1.0.0",
|
|
88
|
+
tools: o.tools.map((t) =>
|
|
89
|
+
tool(t.name, t.description, schemaOf(t.args), async (args: Record<string, unknown>) => {
|
|
90
|
+
try {
|
|
91
|
+
const plain = Object.fromEntries(Object.entries(args).map(([k, v]) => [k, v === undefined ? undefined : String(v)]));
|
|
92
|
+
return { content: [{ type: "text" as const, text: await t.run(plain) }] };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return { content: [{ type: "text" as const, text: `error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
});
|
|
99
|
+
// Homeless on purpose: there are no file tools, and a throwaway cwd means
|
|
100
|
+
// even a confused call has no tree to touch.
|
|
101
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-loop-"));
|
|
102
|
+
const abort = new AbortController();
|
|
103
|
+
let killed = false;
|
|
104
|
+
const watchdog = setTimeout(() => {
|
|
105
|
+
killed = true;
|
|
106
|
+
abort.abort();
|
|
107
|
+
}, o.wallSeconds * 1000);
|
|
108
|
+
watchdog.unref?.();
|
|
109
|
+
|
|
110
|
+
let text = "";
|
|
111
|
+
let costUsd = 0;
|
|
112
|
+
let usage: LoopUsage | null = null;
|
|
113
|
+
let stopped: string | null = null;
|
|
114
|
+
try {
|
|
115
|
+
const q = query({
|
|
116
|
+
prompt: o.prompt,
|
|
117
|
+
options: {
|
|
118
|
+
model: o.model,
|
|
119
|
+
systemPrompt: o.system,
|
|
120
|
+
// No built-in tool at all (no Task, so no sub-agents either) — only
|
|
121
|
+
// ours, each pre-approved; anything else is refused unasked.
|
|
122
|
+
tools: [],
|
|
123
|
+
mcpServers: { [SERVER]: server },
|
|
124
|
+
strictMcpConfig: true,
|
|
125
|
+
allowedTools: o.tools.map((t) => loopToolName(t.name)),
|
|
126
|
+
permissionMode: "dontAsk",
|
|
127
|
+
settingSources: [],
|
|
128
|
+
persistSession: false,
|
|
129
|
+
maxTurns: o.maxTurns,
|
|
130
|
+
maxBudgetUsd: o.maxBudgetUsd,
|
|
131
|
+
includePartialMessages: true,
|
|
132
|
+
cwd: scratch,
|
|
133
|
+
abortController: abort,
|
|
134
|
+
env: { ...hostSafeEnv(), ...o.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens) },
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
let turn = "";
|
|
138
|
+
for await (const message of q) {
|
|
139
|
+
if (message.type === "stream_event") {
|
|
140
|
+
const e = message.event;
|
|
141
|
+
if (e.type === "message_start") turn = "";
|
|
142
|
+
if (e.type === "content_block_delta" && e.delta.type === "text_delta") {
|
|
143
|
+
turn += e.delta.text;
|
|
144
|
+
o.onText?.(e.delta.text);
|
|
145
|
+
}
|
|
146
|
+
} else if (message.type === "assistant") {
|
|
147
|
+
const said = message.message.content
|
|
148
|
+
.filter((b) => b.type === "text")
|
|
149
|
+
.map((b) => (b as { text: string }).text)
|
|
150
|
+
.join("");
|
|
151
|
+
if (said.trim()) text = said;
|
|
152
|
+
} else if (message.type === "result") {
|
|
153
|
+
costUsd = message.total_cost_usd ?? 0;
|
|
154
|
+
const models = Object.values(message.modelUsage ?? {});
|
|
155
|
+
usage = {
|
|
156
|
+
inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
|
|
157
|
+
outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
|
|
158
|
+
costUsd,
|
|
159
|
+
};
|
|
160
|
+
if (message.subtype === "success") {
|
|
161
|
+
if (message.result?.trim()) text = message.result;
|
|
162
|
+
} else {
|
|
163
|
+
stopped = message.subtype;
|
|
164
|
+
if (!text) text = turn;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (!killed) throw err;
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(watchdog);
|
|
172
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
if (killed) stopped = `ran past ${o.wallSeconds}s`;
|
|
175
|
+
return { text, costUsd, usage, stopped };
|
|
176
|
+
}
|