@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/dist/src/runner.js
CHANGED
|
@@ -35,7 +35,8 @@ import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacy
|
|
|
35
35
|
import { refNames } from "./refs.js";
|
|
36
36
|
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
37
37
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
|
|
38
|
-
import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
|
|
38
|
+
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
|
|
39
|
+
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
39
40
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
40
41
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
|
|
41
42
|
import { attachOperations, prefetchOpenApi } from "./openapi.js";
|
|
@@ -60,6 +61,24 @@ export { localDate } from "./clock.js";
|
|
|
60
61
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
61
62
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
62
63
|
// process without ever entering the model's context.
|
|
64
|
+
/** Remote MCP servers with each bound secret's value put back as its
|
|
65
|
+
* `${NAME}`, granted to that server's host — the sandbox then holds the
|
|
66
|
+
* name and the proxy fills it, as for an http tool. */
|
|
67
|
+
function unbindMcpHeaders(servers, bound, values, grant) {
|
|
68
|
+
const known = Object.fromEntries(bound.filter((n) => n in values).map((n) => [n, values[n]]));
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
71
|
+
if (server.type !== "http" || !server.headers) {
|
|
72
|
+
out[name] = server;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const host = hostOf(server.url);
|
|
76
|
+
out[name] = host
|
|
77
|
+
? { ...server, headers: Object.fromEntries(Object.entries(server.headers).map(([k, v]) => [k, unsubstitute(v, known, host, grant)])) }
|
|
78
|
+
: server;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
63
82
|
function mcpConfig(spec, tenant, workspace) {
|
|
64
83
|
const fill = (values) => {
|
|
65
84
|
const out = {};
|
|
@@ -2231,7 +2250,46 @@ stepIndex) {
|
|
|
2231
2250
|
}
|
|
2232
2251
|
}
|
|
2233
2252
|
}
|
|
2253
|
+
// `network:` — which sites the sandbox may reach (network.ts). Resolved
|
|
2254
|
+
// before the lease, because the proxy holds the list per request. A
|
|
2255
|
+
// step that may fetch or browse the open web defaults to open; every
|
|
2256
|
+
// other to the trusted list plus what it declares.
|
|
2257
|
+
const workspaceName = path.basename(workspaceRootOf(agentDir));
|
|
2258
|
+
const readsWeb = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.some((a) => a === "fetch" || a === "browse"));
|
|
2259
|
+
// Secrets bound to sites in the vault: filled by the proxy for those
|
|
2260
|
+
// sites alone, never put in the sandbox (egress.ts).
|
|
2261
|
+
const boundSites = secretSites(tenant, Object.keys(liveSecrets), workspaceName);
|
|
2262
|
+
const network = resolveNetwork({
|
|
2263
|
+
account: readAgentsMd(accountDir(tenant))?.data?.network,
|
|
2264
|
+
workspace: readAgentsMd(workspaceRootOf(agentDir))?.data?.network,
|
|
2265
|
+
agent: front.network,
|
|
2266
|
+
readsWeb,
|
|
2267
|
+
implied: [
|
|
2268
|
+
...apiSpecs.map((a) => hostOf(a.base)).filter((h) => !!h),
|
|
2269
|
+
...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h) => !!h),
|
|
2270
|
+
...Object.values(grant.secrets).flatMap((g) => g.hosts),
|
|
2271
|
+
...Object.values(boundSites).flat(),
|
|
2272
|
+
],
|
|
2273
|
+
});
|
|
2274
|
+
grant.network = { level: network.level, hosts: network.hosts };
|
|
2234
2275
|
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
|
|
2276
|
+
// The forward door: every program in the sandbox sends through the
|
|
2277
|
+
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2278
|
+
// without the door (an older platform) leaves programs going direct.
|
|
2279
|
+
// A step that may browse gets no proxy variables: the browser in it
|
|
2280
|
+
// reads HTTPS_PROXY too, and Chromium cannot send credentials written
|
|
2281
|
+
// into a proxy URL — every page failed ERR_INVALID_AUTH_CREDENTIALS
|
|
2282
|
+
// (14 Oct 2026, the reel posters). Such a step is open by default,
|
|
2283
|
+
// and its bound secrets materialise as they did before the door.
|
|
2284
|
+
const browses = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse"));
|
|
2285
|
+
const forward = lease?.proxy && !browses ? lease : null;
|
|
2286
|
+
push("info", networkLine(network, forward ? forward.enforce === true : null));
|
|
2287
|
+
const bound = forward ? Object.keys(boundSites).filter((name) => name in liveSecrets) : [];
|
|
2288
|
+
for (const name of bound) {
|
|
2289
|
+
for (const site of boundSites[name])
|
|
2290
|
+
addGrant(grant, name, liveSecrets[name], site);
|
|
2291
|
+
(grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", workspaceName);
|
|
2292
|
+
}
|
|
2235
2293
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
2236
2294
|
// reads them from its environment, and unless it says `secrets:
|
|
2237
2295
|
// proxied` it needs the value, not the name. bash is a script by
|
|
@@ -2243,19 +2301,24 @@ stepIndex) {
|
|
|
2243
2301
|
...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
|
|
2244
2302
|
...(allowed.includes("Bash") ? ["bash"] : []),
|
|
2245
2303
|
];
|
|
2246
|
-
const materialise = !lease || materialisers.length > 0;
|
|
2247
2304
|
const declared = Object.keys(liveSecrets);
|
|
2305
|
+
// A bound secret is never materialised, whatever the step grants.
|
|
2306
|
+
const unbound = declared.filter((name) => !bound.includes(name));
|
|
2307
|
+
const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
|
|
2248
2308
|
if (lease) {
|
|
2309
|
+
const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
|
|
2249
2310
|
push("info", declared.length
|
|
2250
2311
|
? materialise
|
|
2251
|
-
? `secrets: ${
|
|
2252
|
-
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names`
|
|
2312
|
+
? `secrets: ${unbound.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}${boundNote ? `; ${boundNote}` : ""}; api tools and the model key go through the egress proxy`
|
|
2313
|
+
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names${boundNote ? ` (${boundNote})` : ""}`
|
|
2253
2314
|
: "secrets: none declared; the model key goes through the egress proxy");
|
|
2254
2315
|
}
|
|
2255
2316
|
// Sandbox env for the declared secrets: the value, or its own name as
|
|
2256
2317
|
// a placeholder that the proxy fills on the way out.
|
|
2257
2318
|
const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
|
|
2258
|
-
let sandboxSecrets = materialise
|
|
2319
|
+
let sandboxSecrets = materialise
|
|
2320
|
+
? { ...liveSecrets, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) }
|
|
2321
|
+
: placeholders;
|
|
2259
2322
|
// A test run hands the sandbox no send-capable value. The env var is
|
|
2260
2323
|
// still there, reading TEST_MODE_WITHHELD, so a script that never
|
|
2261
2324
|
// checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
|
|
@@ -2263,11 +2326,13 @@ stepIndex) {
|
|
|
2263
2326
|
// has a declared sender (everything withheld) or a declared reader
|
|
2264
2327
|
// (nothing withheld); see test-mode.ts for the order.
|
|
2265
2328
|
if (testRun && materialise) {
|
|
2266
|
-
const held = withholdSecrets(liveSecrets, {
|
|
2329
|
+
const held = withholdSecrets(Object.fromEntries(unbound.map((name) => [name, liveSecrets[name]])), {
|
|
2267
2330
|
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2268
2331
|
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2269
2332
|
});
|
|
2270
|
-
|
|
2333
|
+
// Bound secrets stay placeholders: the proxy, which holds the test
|
|
2334
|
+
// policy, is the only party that ever fills them.
|
|
2335
|
+
sandboxSecrets = { ...held.env, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) };
|
|
2271
2336
|
for (const name of held.withheld) {
|
|
2272
2337
|
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2273
2338
|
}
|
|
@@ -2322,7 +2387,9 @@ stepIndex) {
|
|
|
2322
2387
|
systemPrompt,
|
|
2323
2388
|
allowed,
|
|
2324
2389
|
mcpNames: consultNames,
|
|
2325
|
-
|
|
2390
|
+
// A remote MCP server's headers had their secrets filled host-side;
|
|
2391
|
+
// a bound one goes back to its ${NAME} for the proxy to fill.
|
|
2392
|
+
mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
|
|
2326
2393
|
apis: substitutedApis,
|
|
2327
2394
|
scripts: scriptSpecs,
|
|
2328
2395
|
// The merged spec, not `front.runtime` — see the plan's `runtime`
|
|
@@ -2365,6 +2432,14 @@ stepIndex) {
|
|
|
2365
2432
|
// after and carries the agent's chosen endpoint + token.
|
|
2366
2433
|
...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
|
|
2367
2434
|
...(lease ? { [EGRESS_ENV]: lease.url } : {}),
|
|
2435
|
+
// Every program's HTTP(S) through the proxy's forward door, and
|
|
2436
|
+
// the CA it signs bound sites with, for the sandbox to trust.
|
|
2437
|
+
...(forward ? proxyEnv(forward.proxy) : {}),
|
|
2438
|
+
// Only a step with a bound secret has a site the proxy
|
|
2439
|
+
// intercepts, so only that step's TLS clients are pointed at a
|
|
2440
|
+
// bundle with the egress CA in it; every other step's are as
|
|
2441
|
+
// they always were.
|
|
2442
|
+
...(forward?.ca && bound.length ? { [EGRESS_CA_ENV]: Buffer.from(forward.ca).toString("base64") } : {}),
|
|
2368
2443
|
...clockEnv,
|
|
2369
2444
|
// Who is running: the in-process path stamps scripts with these
|
|
2370
2445
|
// host-side, but a container rebuilds its tools from its own
|
|
@@ -2381,6 +2456,9 @@ stepIndex) {
|
|
|
2381
2456
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
2382
2457
|
runId,
|
|
2383
2458
|
size,
|
|
2459
|
+
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2460
|
+
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
2461
|
+
network: { level: network.level, enforced: forward?.enforce === true },
|
|
2384
2462
|
// A test run's state/ and storage/ changes come out under the run.
|
|
2385
2463
|
divert: testRun && runId ? divertFor(runId) : undefined,
|
|
2386
2464
|
test: testRun,
|
|
@@ -4297,11 +4375,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4297
4375
|
// Not after a stop: the failure a stop leaves behind is not one a
|
|
4298
4376
|
// rescuer should take over. The merge in save() surfaces the flag.
|
|
4299
4377
|
save();
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4378
|
+
// One rescue of one failed step. Also used when an evaluator loop
|
|
4379
|
+
// runs out of cycles below, which used to fail the run without ever
|
|
4380
|
+
// reaching the step's on-fail (medium-desk's claim-trimmer was
|
|
4381
|
+
// written for exactly that case and could never run).
|
|
4382
|
+
const rescueStep = async (step) => {
|
|
4383
|
+
if (!step.onFail)
|
|
4384
|
+
return;
|
|
4305
4385
|
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
4306
4386
|
if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
4307
4387
|
step.events.push({
|
|
@@ -4309,7 +4389,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4309
4389
|
type: "error",
|
|
4310
4390
|
text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
|
|
4311
4391
|
});
|
|
4312
|
-
|
|
4392
|
+
return;
|
|
4313
4393
|
}
|
|
4314
4394
|
step.events.push({
|
|
4315
4395
|
t: new Date().toISOString(),
|
|
@@ -4317,10 +4397,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4317
4397
|
text: `failed — handing to [[${step.onFail}]] (on-fail)`,
|
|
4318
4398
|
});
|
|
4319
4399
|
const failure = (step.result ?? step.events.filter((e) => e.type === "error").map((e) => e.text).join("\n")).slice(0, 4000);
|
|
4400
|
+
// A gated step's approval travels with it: the rescuer acts inside
|
|
4401
|
+
// the same answer the failed agent had, and must be told what it
|
|
4402
|
+
// was. Fixers used to dig it out of the run record, and stop when
|
|
4403
|
+
// they could not (15 Oct 2026).
|
|
4404
|
+
const approved = step.approvedAt
|
|
4405
|
+
? `\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.`
|
|
4406
|
+
: "";
|
|
4407
|
+
const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
|
|
4320
4408
|
const rescue = {
|
|
4321
4409
|
agent: step.onFail,
|
|
4322
4410
|
instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
4323
|
-
`Its failure, for whatever it is worth:\n${failure}\n</rescue>`,
|
|
4411
|
+
`Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
|
|
4412
|
+
...(step.ask ? { ask: step.ask } : {}),
|
|
4413
|
+
...(step.approvedAt ? { approvedAt: step.approvedAt } : {}),
|
|
4414
|
+
...(step.approvalNote ? { approvalNote: step.approvalNote } : {}),
|
|
4324
4415
|
group: step.group,
|
|
4325
4416
|
optional: false,
|
|
4326
4417
|
model: step.model,
|
|
@@ -4368,6 +4459,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4368
4459
|
});
|
|
4369
4460
|
}
|
|
4370
4461
|
save();
|
|
4462
|
+
};
|
|
4463
|
+
for (const step of freshGroup) {
|
|
4464
|
+
if (run.stopRequested)
|
|
4465
|
+
break;
|
|
4466
|
+
if (step.status !== "failed" || !step.onFail)
|
|
4467
|
+
continue;
|
|
4468
|
+
await rescueStep(step);
|
|
4371
4469
|
}
|
|
4372
4470
|
// A step carried into a re-run failed in the run it came from, not
|
|
4373
4471
|
// here: the person chose to start after it. Counting it stopped
|
|
@@ -4467,7 +4565,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4467
4565
|
text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
|
|
4468
4566
|
});
|
|
4469
4567
|
save();
|
|
4470
|
-
if (
|
|
4568
|
+
if (looper.onFail && !run.stopRequested) {
|
|
4569
|
+
await rescueStep(looper);
|
|
4570
|
+
// The rescuer owes what the loop owed: its marker.
|
|
4571
|
+
// rescueStep mutates the status; widen past TS's "failed" narrowing.
|
|
4572
|
+
if (looper.status === "completed" && !saysUntilMarker(looper.result ?? null, looper.until)) {
|
|
4573
|
+
looper.status = "failed";
|
|
4574
|
+
looper.events.push({
|
|
4575
|
+
t: new Date().toISOString(),
|
|
4576
|
+
type: "error",
|
|
4577
|
+
text: `on-fail: [[${looper.onFail}]]'s result does not say "${looper.until}" either`,
|
|
4578
|
+
});
|
|
4579
|
+
save();
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
if (looper.status === "failed" && !looper.optional) {
|
|
4471
4583
|
run.status = "failed";
|
|
4472
4584
|
break;
|
|
4473
4585
|
}
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ interface StoredSecret {
|
|
|
26
26
|
* record written before per-account keys existed is encrypted with — so an
|
|
27
27
|
* old vault stays readable without a migration having to run first. */
|
|
28
28
|
k?: "tenant";
|
|
29
|
+
/** The sites this secret is bound to (network.ts SITE shape). A bound
|
|
30
|
+
* secret never enters a sandbox: the egress proxy fills its `${NAME}` for
|
|
31
|
+
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
32
|
+
* beside the ciphertext and survives the value being set again. */
|
|
33
|
+
sites?: string[];
|
|
29
34
|
}
|
|
30
35
|
type SecretsFile = Record<string, StoredSecret>;
|
|
31
36
|
/**
|
|
@@ -36,6 +41,18 @@ type SecretsFile = Record<string, StoredSecret>;
|
|
|
36
41
|
export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
|
|
37
42
|
export declare const SECRET_NAME: RegExp;
|
|
38
43
|
export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
|
|
44
|
+
/**
|
|
45
|
+
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
46
|
+
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
47
|
+
* scheme, path or port. Returns the list as stored.
|
|
48
|
+
*/
|
|
49
|
+
export declare function setSecretSites(tenant: string, name: string, sites: string[], workspace?: string): string[];
|
|
50
|
+
/**
|
|
51
|
+
* The sites each named secret is bound to, nearest scope first — the same
|
|
52
|
+
* record getSecret would read the value from. A name with no binding (or
|
|
53
|
+
* no secret) is absent from the answer.
|
|
54
|
+
*/
|
|
55
|
+
export declare function secretSites(tenant: string, names: string[], workspace?: string): Record<string, string[]>;
|
|
39
56
|
/** Encrypt one value for an account — the same envelope secrets use, exported
|
|
40
57
|
* so sibling stores (OAuth clients) don't invent a second crypto path. */
|
|
41
58
|
export declare function encryptValue(tenant: string, value: string): {
|
|
@@ -65,6 +82,9 @@ export interface SecretEntry {
|
|
|
65
82
|
/** How this credential works: auto-refreshing oauth2, a signed service
|
|
66
83
|
* account, a file/ssh/api materialised into runs — absent for static values. */
|
|
67
84
|
kind?: SecretKind;
|
|
85
|
+
/** The sites it is bound to — the proxy fills it for these and no other,
|
|
86
|
+
* and it never enters a sandbox. Absent when unbound. */
|
|
87
|
+
sites?: string[];
|
|
68
88
|
}
|
|
69
89
|
export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
|
|
70
90
|
export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
|
package/dist/src/secrets.js
CHANGED
|
@@ -39,6 +39,7 @@ const tenantKey = (tenant) => platform.tenantKey(tenant);
|
|
|
39
39
|
import crypto from "node:crypto";
|
|
40
40
|
import { assertSafeName } from "./store.js";
|
|
41
41
|
import { trimSlashes } from "./paths.js";
|
|
42
|
+
import { SITE } from "./network.js";
|
|
42
43
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
43
44
|
/**
|
|
44
45
|
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
@@ -163,14 +164,70 @@ export function setSecret(tenant, name, value, workspace, kind) {
|
|
|
163
164
|
throw new Error("secret too long");
|
|
164
165
|
const sealed = encryptValue(tenant, value);
|
|
165
166
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
167
|
+
// A new value keeps the sites the old one was bound to: binding is a
|
|
168
|
+
// property of the credential's use, not of its current value, and a
|
|
169
|
+
// rotation that silently unbound it would put the new value in pods.
|
|
170
|
+
const sites = all[name]?.sites;
|
|
166
171
|
all[name] = {
|
|
167
172
|
...sealed,
|
|
168
173
|
updatedAt: new Date().toISOString(),
|
|
169
174
|
...(kind ? { kind } : {}),
|
|
175
|
+
...(sites?.length ? { sites } : {}),
|
|
170
176
|
};
|
|
171
177
|
return all;
|
|
172
178
|
});
|
|
173
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
182
|
+
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
183
|
+
* scheme, path or port. Returns the list as stored.
|
|
184
|
+
*/
|
|
185
|
+
export function setSecretSites(tenant, name, sites, workspace) {
|
|
186
|
+
const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
187
|
+
for (const site of clean) {
|
|
188
|
+
if (!SITE.test(site))
|
|
189
|
+
throw new Error(`"${site}" is not a site — a domain like api.example.com or *.example.com, no scheme, path or port`);
|
|
190
|
+
}
|
|
191
|
+
let found = false;
|
|
192
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
193
|
+
const rec = all[name];
|
|
194
|
+
if (!rec)
|
|
195
|
+
return null;
|
|
196
|
+
found = true;
|
|
197
|
+
// A file, an ssh key or an @api header set is not a value that travels
|
|
198
|
+
// in a header — it is unpacked inside the sandbox, so there is nothing
|
|
199
|
+
// for the proxy to fill.
|
|
200
|
+
if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
|
|
201
|
+
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`);
|
|
202
|
+
}
|
|
203
|
+
if (clean.length)
|
|
204
|
+
rec.sites = clean;
|
|
205
|
+
else
|
|
206
|
+
delete rec.sites;
|
|
207
|
+
return all;
|
|
208
|
+
});
|
|
209
|
+
if (!found)
|
|
210
|
+
throw new Error(`no secret ${name}${workspace ? ` in workspace ${workspace}` : " in the account"}`);
|
|
211
|
+
return clean;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The sites each named secret is bound to, nearest scope first — the same
|
|
215
|
+
* record getSecret would read the value from. A name with no binding (or
|
|
216
|
+
* no secret) is absent from the answer.
|
|
217
|
+
*/
|
|
218
|
+
export function secretSites(tenant, names, workspace) {
|
|
219
|
+
const out = {};
|
|
220
|
+
const own = workspace ? read(tenant, workspace) : {};
|
|
221
|
+
const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
|
|
222
|
+
const inherited = source ? read(tenant, source) : {};
|
|
223
|
+
const account = read(tenant);
|
|
224
|
+
for (const name of names) {
|
|
225
|
+
const rec = own[name] ?? inherited[name] ?? account[name];
|
|
226
|
+
if (rec?.sites?.length)
|
|
227
|
+
out[name] = [...rec.sites];
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
174
231
|
/**
|
|
175
232
|
* Which key writes this account's records.
|
|
176
233
|
*
|
|
@@ -271,6 +328,7 @@ export function listSecrets(tenant, workspace) {
|
|
|
271
328
|
updatedAt: rec.updatedAt,
|
|
272
329
|
scope: "account",
|
|
273
330
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
331
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
274
332
|
}));
|
|
275
333
|
if (!workspace)
|
|
276
334
|
return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -280,6 +338,7 @@ export function listSecrets(tenant, workspace) {
|
|
|
280
338
|
scope: "workspace",
|
|
281
339
|
shadowed: account.some((a) => a.name === name),
|
|
282
340
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
341
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
283
342
|
}));
|
|
284
343
|
const ownNames = new Set(own.map((s) => s.name));
|
|
285
344
|
return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -748,7 +807,12 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
748
807
|
return null;
|
|
749
808
|
const next = { ...all };
|
|
750
809
|
for (const [name, value] of plain) {
|
|
751
|
-
next[name] = {
|
|
810
|
+
next[name] = {
|
|
811
|
+
...encryptValue(tenant, value),
|
|
812
|
+
updatedAt: all[name].updatedAt,
|
|
813
|
+
...(all[name].kind ? { kind: all[name].kind } : {}),
|
|
814
|
+
...(all[name].sites?.length ? { sites: all[name].sites } : {}),
|
|
815
|
+
};
|
|
752
816
|
}
|
|
753
817
|
moved += plain.size;
|
|
754
818
|
return next;
|
package/dist/src/starter.js
CHANGED
|
@@ -295,7 +295,11 @@ memory an agent wrote that your push does not mention. Those belong to the platf
|
|
|
295
295
|
reverted what an agent learned would make every run a little dumber.
|
|
296
296
|
|
|
297
297
|
Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
|
|
298
|
-
agents reference them by name.
|
|
298
|
+
agents reference them by name. Bind one to the sites it is for
|
|
299
|
+
(\`foldrun secrets sites NAME api.example.com\`) and it never enters a sandbox:
|
|
300
|
+
the egress proxy fills \`\${NAME}\` in requests to those sites alone. Which sites
|
|
301
|
+
a sandbox may reach at all is \`network:\` — open for an agent that reads the web,
|
|
302
|
+
the trusted list plus what it declares for every other (\`foldrun agent network\`).
|
|
299
303
|
|
|
300
304
|
## Talking to a deployed workspace
|
|
301
305
|
|
|
@@ -341,6 +345,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
341
345
|
| \`/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) |
|
|
342
346
|
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
343
347
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
348
|
+
| \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
|
|
344
349
|
|
|
345
350
|
Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
|
|
346
351
|
\`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
@@ -448,6 +453,7 @@ agents: [writer] # colleagues this one may consult (an answer, no too
|
|
|
448
453
|
subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
|
|
449
454
|
secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
|
|
450
455
|
limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
|
|
456
|
+
network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
|
|
451
457
|
timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
|
|
452
458
|
provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
|
|
453
459
|
permissionMode: plan # plan first, act once approved
|
package/dist/src/store.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { OperatorEvent } from "./operator.ts";
|
|
|
12
12
|
* the runtime, and there is nothing to fail to import.
|
|
13
13
|
*/
|
|
14
14
|
export declare function readFrontmatter(file: string): Record<string, unknown>;
|
|
15
|
+
import { type NetworkPolicy } from "./network.ts";
|
|
15
16
|
import { type WireFormat, type AuthShape } from "./providers.ts";
|
|
16
17
|
/**
|
|
17
18
|
* Single-workspace mode. When set, the workspace IS this directory — no
|
|
@@ -331,6 +332,17 @@ export interface AgentInfo {
|
|
|
331
332
|
/** What is wrong with the `limits:` block's shape; key-vs-grant problems
|
|
332
333
|
* need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
|
|
333
334
|
limitProblems: string[];
|
|
335
|
+
/** What cannot be read in `network:` (network.ts) — a level that is not
|
|
336
|
+
* one, a site with a scheme or a path. */
|
|
337
|
+
networkProblems: string[];
|
|
338
|
+
/** The sites this agent's steps may reach, resolved the way a run
|
|
339
|
+
* resolves them — its `network:`, the workspace's, the account's, and
|
|
340
|
+
* the default (open when it may fetch or browse the web, else trusted).
|
|
341
|
+
* `implied` here is its `apis:` hosts; a run also adds its MCP servers',
|
|
342
|
+
* its web providers' and its bound secrets' sites. */
|
|
343
|
+
network: NetworkPolicy & {
|
|
344
|
+
readsWeb: boolean;
|
|
345
|
+
};
|
|
334
346
|
/** Tools this agent declares inline rather than in `tools:` — `apis:`,
|
|
335
347
|
* `scripts:` and `mcpServers:` names — which a limit may also name. */
|
|
336
348
|
inlineTools: string[];
|
package/dist/src/store.js
CHANGED
|
@@ -44,7 +44,8 @@ import { refNames } from "./refs.js";
|
|
|
44
44
|
import { parseBudget, budgetProblem } from "./budget.js";
|
|
45
45
|
import { timezoneProblem } from "./clock.js";
|
|
46
46
|
import { parseStepLimits, readLimits } from "./limits.js";
|
|
47
|
-
import {
|
|
47
|
+
import { networkProblems, resolveNetwork } from "./network.js";
|
|
48
|
+
import { webProblems, webWarnings, runnerEngines, webConfig } from "./providers.js";
|
|
48
49
|
import { actionProblems } from "./web-actions.js";
|
|
49
50
|
import { languageProblem } from "./language.js";
|
|
50
51
|
import { localeProblems } from "./locale.js";
|
|
@@ -1095,6 +1096,17 @@ export function listAgents(tenant, workspace) {
|
|
|
1095
1096
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
1096
1097
|
if (!fs.existsSync(dir))
|
|
1097
1098
|
return [];
|
|
1099
|
+
// The two levels above every agent, for the network: cascade.
|
|
1100
|
+
const frontOf = (file) => {
|
|
1101
|
+
try {
|
|
1102
|
+
return readFrontmatter(file);
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
return {}; // no AGENTS.md at that level, or one that does not parse
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
const accountFront = frontOf(path.join(accountDir(tenant), "AGENTS.md"));
|
|
1109
|
+
const workspaceFront = frontOf(path.join(workspaceDir(tenant, workspace), "AGENTS.md"));
|
|
1098
1110
|
return fs
|
|
1099
1111
|
.readdirSync(dir)
|
|
1100
1112
|
.filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
|
|
@@ -1102,7 +1114,25 @@ export function listAgents(tenant, workspace) {
|
|
|
1102
1114
|
.map((name) => {
|
|
1103
1115
|
const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
|
|
1104
1116
|
const tools = refNames(data.tools);
|
|
1117
|
+
const actions = webConfig(data, { ...accountFront, ...workspaceFront }).actions;
|
|
1118
|
+
const readsWeb = tools.includes("web") && (!actions || actions.some((a) => a === "fetch" || a === "browse"));
|
|
1119
|
+
const network = {
|
|
1120
|
+
...resolveNetwork({
|
|
1121
|
+
account: accountFront.network,
|
|
1122
|
+
workspace: workspaceFront.network,
|
|
1123
|
+
agent: data.network,
|
|
1124
|
+
readsWeb,
|
|
1125
|
+
implied: parseApis(data.apis).map((a) => { try {
|
|
1126
|
+
return new URL(a.base).host;
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
return "";
|
|
1130
|
+
} }),
|
|
1131
|
+
}),
|
|
1132
|
+
readsWeb,
|
|
1133
|
+
};
|
|
1105
1134
|
return {
|
|
1135
|
+
network,
|
|
1106
1136
|
name,
|
|
1107
1137
|
description: data.description ?? "",
|
|
1108
1138
|
model: data.model ?? "default",
|
|
@@ -1126,6 +1156,7 @@ export function listAgents(tenant, workspace) {
|
|
|
1126
1156
|
webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
|
|
1127
1157
|
scheduleProblem: agentScheduleProblem(data),
|
|
1128
1158
|
...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
|
|
1159
|
+
networkProblems: networkProblems(data.network),
|
|
1129
1160
|
inlineTools: inlineToolNames(data),
|
|
1130
1161
|
};
|
|
1131
1162
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** One string argument: what it means, whether it may be left out, how long it may be. */
|
|
2
|
+
export interface LoopArg {
|
|
3
|
+
description?: string;
|
|
4
|
+
optional?: boolean;
|
|
5
|
+
max?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface LoopTool {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
args: Record<string, LoopArg>;
|
|
11
|
+
/** The answer the model reads. A throw becomes an error result, not a crash. */
|
|
12
|
+
run: (args: Record<string, string | undefined>) => Promise<string> | string;
|
|
13
|
+
}
|
|
14
|
+
export interface LoopUsage {
|
|
15
|
+
/** Everything sent, cache reads and writes included. */
|
|
16
|
+
inputTokens: number;
|
|
17
|
+
outputTokens: number;
|
|
18
|
+
costUsd: number;
|
|
19
|
+
}
|
|
20
|
+
export interface LoopResult {
|
|
21
|
+
/** The last turn's text — the answer, not the thinking aloud between tool calls. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** What the SDK reports the query cost; 0 when it never reported (killed). */
|
|
24
|
+
costUsd: number;
|
|
25
|
+
usage: LoopUsage | null;
|
|
26
|
+
/** Why it ended, when it did not end well: max turns, budget, wall clock. */
|
|
27
|
+
stopped: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface LoopOptions {
|
|
30
|
+
system: string;
|
|
31
|
+
prompt: string;
|
|
32
|
+
tools: LoopTool[];
|
|
33
|
+
model: string;
|
|
34
|
+
/** The model credential and anything else the SDK process needs. */
|
|
35
|
+
env: Record<string, string | undefined>;
|
|
36
|
+
maxTurns: number;
|
|
37
|
+
maxBudgetUsd: number;
|
|
38
|
+
maxOutputTokens: number;
|
|
39
|
+
wallSeconds: number;
|
|
40
|
+
/** The reply as it is written, for a live view. */
|
|
41
|
+
onText?: (delta: string) => void;
|
|
42
|
+
}
|
|
43
|
+
/** The Agent SDK's name for one of our tools. */
|
|
44
|
+
export declare const loopToolName: (name: string) => string;
|
|
45
|
+
export declare function runToolLoop(o: LoopOptions): Promise<LoopResult>;
|