@foldrun/core 0.7.0 → 1.1.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.
Files changed (49) hide show
  1. package/README.md +14 -1
  2. package/dist/src/completions.js +1 -0
  3. package/dist/src/deploy.js +5 -0
  4. package/dist/src/egress.d.ts +21 -0
  5. package/dist/src/egress.js +8 -0
  6. package/dist/src/hooks.d.ts +114 -0
  7. package/dist/src/hooks.js +370 -0
  8. package/dist/src/kinds.d.ts +1 -1
  9. package/dist/src/kinds.js +26 -0
  10. package/dist/src/library.d.ts +1 -1
  11. package/dist/src/library.js +17 -1
  12. package/dist/src/mail.js +1 -1
  13. package/dist/src/network.d.ts +120 -0
  14. package/dist/src/network.js +360 -0
  15. package/dist/src/notify.d.ts +8 -0
  16. package/dist/src/notify.js +10 -1
  17. package/dist/src/providers.d.ts +5 -0
  18. package/dist/src/providers.js +21 -1
  19. package/dist/src/run-container.d.ts +13 -0
  20. package/dist/src/run-container.js +8 -1
  21. package/dist/src/runner.d.ts +1 -0
  22. package/dist/src/runner.js +144 -16
  23. package/dist/src/secrets.d.ts +20 -0
  24. package/dist/src/secrets.js +65 -1
  25. package/dist/src/starter.js +9 -1
  26. package/dist/src/step-exec.d.ts +10 -0
  27. package/dist/src/step-exec.js +113 -2
  28. package/dist/src/store.d.ts +23 -1
  29. package/dist/src/store.js +33 -1
  30. package/dist/src/tool-loop.d.ts +45 -0
  31. package/dist/src/tool-loop.js +133 -0
  32. package/package.json +19 -1
  33. package/src/completions.ts +1 -0
  34. package/src/deploy.ts +4 -0
  35. package/src/egress.ts +26 -0
  36. package/src/hooks.ts +406 -0
  37. package/src/kinds.ts +28 -0
  38. package/src/library.ts +18 -2
  39. package/src/mail.ts +1 -1
  40. package/src/network.ts +390 -0
  41. package/src/notify.ts +11 -1
  42. package/src/providers.ts +27 -1
  43. package/src/run-container.ts +16 -2
  44. package/src/runner.ts +145 -14
  45. package/src/secrets.ts +69 -1
  46. package/src/starter.ts +9 -1
  47. package/src/step-exec.ts +115 -3
  48. package/src/store.ts +39 -1
  49. package/src/tool-loop.ts +176 -0
@@ -7,6 +7,7 @@
7
7
  // assembles the system prompt and builds MCP servers, then hands this the
8
8
  // results. That is what lets the same function run somewhere the vault, the
9
9
  // library and the account do not exist.
10
+ import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks } from "./hooks.js";
10
11
  import fs from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
@@ -169,8 +170,49 @@ async function executeStepInner(opts, runQuery) {
169
170
  // `limits:` — this step's counts. A new step, a retry of one, or a
170
171
  // fan-out instance each start from zero.
171
172
  const counter = new CallCounter(opts.limits);
173
+ // Hooks (hooks.ts): the person's own code at this dispatch, same on every
174
+ // model. Each runs in the agent's directory with a bare environment.
175
+ let hooks = opts.hooks ?? [];
176
+ if (opts.hookNames?.length) {
177
+ const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
178
+ const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
179
+ if (bad.length) {
180
+ emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
181
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
182
+ }
183
+ hooks = [...hooks, ...r.hooks];
184
+ }
185
+ const hookBase = { ...(opts.hookBase ?? {}) };
186
+ const callHook = (hook, event) => runHook(hook, event, { cwd: agentDir, env: opts.env });
187
+ const record = (r, via) => emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
188
+ // step.start — a gate: context it returns goes in front of the task; a
189
+ // hook that fails refuses the step rather than run it unguarded.
190
+ let prompt = opts.prompt;
191
+ for (const hook of hooks.filter((h) => h.on === "step.start")) {
192
+ const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
193
+ if (!a.ok) {
194
+ record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
195
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
196
+ }
197
+ // Exit 2 is a deny on every event (runHook). On a gate that means the
198
+ // step does not run — it used to come back ok with no context, and the
199
+ // step went ahead as if the hook had agreed.
200
+ if (a.decision === "deny") {
201
+ record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
202
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
203
+ }
204
+ if (a.context) {
205
+ prompt = `${prompt}\n\n${a.context}`;
206
+ record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
207
+ }
208
+ else if (a.note) {
209
+ record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
210
+ }
211
+ }
212
+ const toolHooks = hooks.filter((h) => h.on === "tool.call");
213
+ const resultHooks = hooks.filter((h) => h.on === "tool.result");
172
214
  const q = runQuery({
173
- prompt: opts.prompt,
215
+ prompt,
174
216
  options: {
175
217
  cwd: agentDir,
176
218
  abortController: abort,
@@ -272,6 +314,46 @@ async function executeStepInner(opts, runQuery) {
272
314
  return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
273
315
  }
274
316
  }
317
+ // tool.call hooks — after foldrun's own checks, so a hook never
318
+ // sees a call that was going to be refused anyway, and handed the
319
+ // input as the tool would get it (the workspace/ prefix expanded).
320
+ if (toolHooks.length) {
321
+ const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {});
322
+ // A sub-agent's call says so: a hook that guards the step's own
323
+ // model differently from its delegates can tell them apart.
324
+ const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
325
+ for (const r of v.records)
326
+ record(r, via);
327
+ const refundCall = () => { if (counter.active)
328
+ counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners)); };
329
+ if (v.decision === "deny") {
330
+ refundCall();
331
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: v.reason } };
332
+ }
333
+ if (v.input) {
334
+ // A rewritten call is answered "allow", and an allow skips
335
+ // canUseTool — where the shell and path confinement live. So
336
+ // the rewrite goes through the same checks here: a hook can
337
+ // narrow what the agent does, never widen it.
338
+ const again = hookInput.tool_name === "Bash"
339
+ ? checkBash(String(v.input.command ?? ""))
340
+ : isFilesystemTool(hookInput.tool_name)
341
+ ? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
342
+ : { ok: true };
343
+ const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
344
+ // The sub-agent guard read the ORIGINAL input: a rewrite of a
345
+ // delegation could otherwise swap subagent_type for one this
346
+ // step never declared (or the SDK's general-purpose).
347
+ const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
348
+ if (!again.ok || !granted || delegation) {
349
+ refundCall();
350
+ const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
351
+ emit("error", why, via ? { subagent: via } : undefined);
352
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: why } };
353
+ }
354
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: again.updatedInput ?? v.input } };
355
+ }
356
+ }
275
357
  if (!verdict?.updatedInput)
276
358
  return {};
277
359
  return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: verdict.updatedInput } };
@@ -281,12 +363,26 @@ async function executeStepInner(opts, runQuery) {
281
363
  // after its next tool call, as context — never as a change to what it
282
364
  // may do. A model that is only writing, calling no tools, hears it at
283
365
  // the next call it makes.
284
- ...(opts.inbox
366
+ ...(opts.inbox || resultHooks.length
285
367
  ? {
286
368
  PostToolUse: [{
287
369
  hooks: [async (hookInput) => {
288
370
  if (hookInput.hook_event_name !== "PostToolUse")
289
371
  return {};
372
+ // tool.result hooks watch: the call has happened, so one
373
+ // that fails is recorded and the step goes on.
374
+ for (const hook of resultHooks) {
375
+ if (!matchesTool(hook.tool, hookInput.tool_name))
376
+ continue;
377
+ const response = hookInput.tool_response;
378
+ const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
379
+ if (!a.ok)
380
+ record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
381
+ else if (a.note)
382
+ record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
383
+ }
384
+ if (!opts.inbox)
385
+ return {};
290
386
  // A sub-agent's call: the message is for the step's own
291
387
  // model, and draining it here would hand it to a context
292
388
  // that ends with the delegation — and lose it.
@@ -614,6 +710,21 @@ async function executeStepInner(opts, runQuery) {
614
710
  status = "failed";
615
711
  emit("error", "the model stream ended without a result — the model process likely died (out of memory?)");
616
712
  }
713
+ // step.end hooks watch the reply, and may rewrite it. One that fails is
714
+ // recorded and the reply stands: the work it describes is done.
715
+ if (status === "completed") {
716
+ for (const hook of hooks.filter((h) => h.on === "step.end")) {
717
+ const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
718
+ if (!a.ok)
719
+ record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
720
+ else if (a.reply !== undefined) {
721
+ texts.splice(0, texts.length, a.reply);
722
+ record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
723
+ }
724
+ else if (a.note)
725
+ record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
726
+ }
727
+ }
617
728
  const result = texts.join("\n").trim() || null;
618
729
  // A step's reply is not one message: the model narrates ("Now let me read
619
730
  // the outputs…"), calls tools, and answers last. Joining those and reading
@@ -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
@@ -20,7 +21,7 @@ import { type WireFormat, type AuthShape } from "./providers.ts";
20
21
  * core needs to know about which one it's in.
21
22
  */
22
23
  export { singleWorkspace };
23
- export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "state"];
24
+ export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "hooks", "state"];
24
25
  export declare const STORAGE_DIR = "storage";
25
26
  /** What this directory was called before. Read-only: nothing writes here any
26
27
  * more, but installs created before the rename have bytes under it, and a
@@ -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[];
@@ -893,6 +905,16 @@ export interface RunEvent {
893
905
  * detail), `output: json`, a schema. What the runner reads to decline a
894
906
  * retry of an outward step whose tools already ran. */
895
907
  check?: boolean;
908
+ /** Set on the line a hook wrote — a deny, a rewrite, context, a failure
909
+ * (hooks.ts). The run page and the Hooks page read these. */
910
+ hook?: {
911
+ hook: string;
912
+ event: string;
913
+ did: string;
914
+ tool?: string;
915
+ reason?: string;
916
+ ms: number;
917
+ };
896
918
  }
897
919
  /** A question an agent asked a person while its step ran (tools: [ask]). */
898
920
  export interface StepQuestion {
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 { webProblems, webWarnings, runnerEngines } from "./providers.js";
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";
@@ -74,6 +75,7 @@ export const WORKSPACE_DIRS = [
74
75
  "knowledge",
75
76
  "tools",
76
77
  "scripts",
78
+ "hooks",
77
79
  "state",
78
80
  ];
79
81
  const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
@@ -1094,6 +1096,17 @@ export function listAgents(tenant, workspace) {
1094
1096
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
1095
1097
  if (!fs.existsSync(dir))
1096
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"));
1097
1110
  return fs
1098
1111
  .readdirSync(dir)
1099
1112
  .filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
@@ -1101,7 +1114,25 @@ export function listAgents(tenant, workspace) {
1101
1114
  .map((name) => {
1102
1115
  const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
1103
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
+ };
1104
1134
  return {
1135
+ network,
1105
1136
  name,
1106
1137
  description: data.description ?? "",
1107
1138
  model: data.model ?? "default",
@@ -1125,6 +1156,7 @@ export function listAgents(tenant, workspace) {
1125
1156
  webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
1126
1157
  scheduleProblem: agentScheduleProblem(data),
1127
1158
  ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1159
+ networkProblems: networkProblems(data.network),
1128
1160
  inlineTools: inlineToolNames(data),
1129
1161
  };
1130
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>;
@@ -0,0 +1,133 @@
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
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { z } from "zod";
17
+ import { query, createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
18
+ import { hostSafeEnv } from "./host-env.js";
19
+ const SERVER = "foldrun_tools";
20
+ /** The Agent SDK's name for one of our tools. */
21
+ export const loopToolName = (name) => `mcp__${SERVER}__${name}`;
22
+ function schemaOf(args) {
23
+ return Object.fromEntries(Object.entries(args).map(([name, a]) => {
24
+ let s = z.string().min(a.optional ? 0 : 1);
25
+ if (a.max)
26
+ s = s.max(a.max);
27
+ if (a.description)
28
+ s = s.describe(a.description);
29
+ return [name, a.optional ? s.optional() : s];
30
+ }));
31
+ }
32
+ export async function runToolLoop(o) {
33
+ const server = createSdkMcpServer({
34
+ name: SERVER,
35
+ version: "1.0.0",
36
+ tools: o.tools.map((t) => tool(t.name, t.description, schemaOf(t.args), async (args) => {
37
+ try {
38
+ const plain = Object.fromEntries(Object.entries(args).map(([k, v]) => [k, v === undefined ? undefined : String(v)]));
39
+ return { content: [{ type: "text", text: await t.run(plain) }] };
40
+ }
41
+ catch (err) {
42
+ return { content: [{ type: "text", text: `error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
43
+ }
44
+ })),
45
+ });
46
+ // Homeless on purpose: there are no file tools, and a throwaway cwd means
47
+ // even a confused call has no tree to touch.
48
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-loop-"));
49
+ const abort = new AbortController();
50
+ let killed = false;
51
+ const watchdog = setTimeout(() => {
52
+ killed = true;
53
+ abort.abort();
54
+ }, o.wallSeconds * 1000);
55
+ watchdog.unref?.();
56
+ let text = "";
57
+ let costUsd = 0;
58
+ let usage = null;
59
+ let stopped = null;
60
+ try {
61
+ const q = query({
62
+ prompt: o.prompt,
63
+ options: {
64
+ model: o.model,
65
+ systemPrompt: o.system,
66
+ // No built-in tool at all (no Task, so no sub-agents either) — only
67
+ // ours, each pre-approved; anything else is refused unasked.
68
+ tools: [],
69
+ mcpServers: { [SERVER]: server },
70
+ strictMcpConfig: true,
71
+ allowedTools: o.tools.map((t) => loopToolName(t.name)),
72
+ permissionMode: "dontAsk",
73
+ settingSources: [],
74
+ persistSession: false,
75
+ maxTurns: o.maxTurns,
76
+ maxBudgetUsd: o.maxBudgetUsd,
77
+ includePartialMessages: true,
78
+ cwd: scratch,
79
+ abortController: abort,
80
+ env: { ...hostSafeEnv(), ...o.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens) },
81
+ },
82
+ });
83
+ let turn = "";
84
+ for await (const message of q) {
85
+ if (message.type === "stream_event") {
86
+ const e = message.event;
87
+ if (e.type === "message_start")
88
+ turn = "";
89
+ if (e.type === "content_block_delta" && e.delta.type === "text_delta") {
90
+ turn += e.delta.text;
91
+ o.onText?.(e.delta.text);
92
+ }
93
+ }
94
+ else if (message.type === "assistant") {
95
+ const said = message.message.content
96
+ .filter((b) => b.type === "text")
97
+ .map((b) => b.text)
98
+ .join("");
99
+ if (said.trim())
100
+ text = said;
101
+ }
102
+ else if (message.type === "result") {
103
+ costUsd = message.total_cost_usd ?? 0;
104
+ const models = Object.values(message.modelUsage ?? {});
105
+ usage = {
106
+ inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
107
+ outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
108
+ costUsd,
109
+ };
110
+ if (message.subtype === "success") {
111
+ if (message.result?.trim())
112
+ text = message.result;
113
+ }
114
+ else {
115
+ stopped = message.subtype;
116
+ if (!text)
117
+ text = turn;
118
+ }
119
+ }
120
+ }
121
+ }
122
+ catch (err) {
123
+ if (!killed)
124
+ throw err;
125
+ }
126
+ finally {
127
+ clearTimeout(watchdog);
128
+ fs.rmSync(scratch, { recursive: true, force: true });
129
+ }
130
+ if (killed)
131
+ stopped = `ran past ${o.wallSeconds}s`;
132
+ return { text, costUsd, usage, stopped };
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "0.7.0",
3
+ "version": "1.1.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -15,6 +15,24 @@
15
15
  "default": "./dist/src/*.js"
16
16
  }
17
17
  },
18
+ "foldrun": {
19
+ "publicApi": [
20
+ ".",
21
+ "./git-hook",
22
+ "./agents-md",
23
+ "./browser-pod",
24
+ "./flow-patterns",
25
+ "./history",
26
+ "./hooks",
27
+ "./kinds",
28
+ "./layout",
29
+ "./limits",
30
+ "./network",
31
+ "./providers",
32
+ "./subagents",
33
+ "./tool-names"
34
+ ]
35
+ },
18
36
  "main": "./dist/index.js",
19
37
  "types": "./dist/index.d.ts",
20
38
  "files": [
@@ -147,6 +147,7 @@ const FIELDS: Record<string, Completion[]> = {
147
147
  { label: "disallowedTools", insert: "disallowedTools:\n - " },
148
148
  { label: "subagents", insert: "subagents:\n - ", hint: "colleagues it may delegate a job to — own context, own tools, no wider than its own" },
149
149
  { label: "limits", insert: "limits:\n web.search: 40\n calls: 300", hint: "per step: a call past a limit is refused, never run" },
150
+ { label: "network", insert: "network:\n - api.example.com", hint: "sites the sandbox may reach: open | trusted | none, or a list added to the trusted list" },
150
151
  {
151
152
  label: "provider",
152
153
  insert: "provider:\n name: \n token: ${PROVIDER_TOKEN}",
package/src/deploy.ts CHANGED
@@ -40,6 +40,7 @@ import { conformanceIssues } from "./okf.ts";
40
40
  import { timezoneProblem } from "./clock.ts";
41
41
  import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
42
42
  import { readLimits } from "./limits.ts";
43
+ import { networkProblems } from "./network.ts";
43
44
  import { actionProblems } from "./web-actions.ts";
44
45
  import { languageProblem } from "./language.ts";
45
46
  import { localeProblems } from "./locale.ts";
@@ -326,6 +327,9 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
326
327
  // `limits:` that cannot be read — a count of 0, a word, web.<not an
327
328
  // action> — would run uncapped where the author meant a cap.
328
329
  for (const w of readLimits(front.limits).problems) at(f.path, w);
330
+ // `network:` that cannot be read would leave the step on the level
331
+ // above it — wider or narrower than the author wrote.
332
+ for (const w of networkProblems(front.network)) at(f.path, w);
329
333
  const lang = languageProblem(front.language);
330
334
  if (lang) at(f.path, lang);
331
335
  for (const w of localeProblems(front)) at(f.path, w);
package/src/egress.ts CHANGED
@@ -31,12 +31,21 @@
31
31
  // `FOLDRUN_EGRESS` instead, and sends through the proxy like everything
32
32
  // else.
33
33
  //
34
+ // A secret BOUND to sites in the vault (`foldrun secrets set NAME --site
35
+ // api.example.com`) is never materialised, whatever the step grants: the
36
+ // pod's programs all send through the proxy's forward door (HTTPS_PROXY),
37
+ // and for a bound site the door terminates TLS with the platform's egress CA
38
+ // — which the sandbox trusts — and fills `${NAME}` in the URL and headers on
39
+ // the way out, the way Anthropic's cloud sandboxes attach a network secret.
40
+ // Every other site is a tunnel the proxy does not read.
41
+ //
34
42
  // This file is the vocabulary both sides share. The server is the
35
43
  // platform's (it needs a long-lived process and a Service); the pod side is
36
44
  // here in core, because it is what the runner image runs.
37
45
 
38
46
  import { trimSlashes } from "./paths.ts";
39
47
  import type { TestEffect } from "./test-mode.ts";
48
+ import type { NetworkLevel } from "./network.ts";
40
49
 
41
50
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
42
51
  export const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g;
@@ -99,11 +108,28 @@ export interface EgressGrant {
99
108
  * that has to put a rate-limit reset into words — and a reset time is
100
109
  * only useful in the calendar the step works to. See refusal.ts. */
101
110
  timezone?: string;
111
+ /** Which sites the step may reach through the proxy's forward door
112
+ * (network.ts). Absent on a grant from a runner that predates it: the
113
+ * forward door then allows only the hosts the secrets were granted to. */
114
+ network?: { level: NetworkLevel; hosts: string[] };
115
+ /** Refuse a site off the list (true), or allow it and name it in the
116
+ * trace (false: report only). Set by the platform when it leases. */
117
+ enforce?: boolean;
102
118
  }
103
119
 
104
120
  export interface EgressLease {
105
121
  /** The base the pod sends through: `http://…/e/<token>`. */
106
122
  url: string;
123
+ /** The forward-proxy address for HTTP_PROXY / HTTPS_PROXY —
124
+ * `http://foldrun:<token>@host:port` — when the platform's proxy has the
125
+ * forward door. Absent on an older proxy: programs then go direct. */
126
+ proxy?: string;
127
+ /** The PEM of the CA the forward door signs with for a host a secret is
128
+ * bound to, so the sandbox can trust exactly those interceptions. */
129
+ ca?: string;
130
+ /** Whether a site off the step's list is refused (true) or only named
131
+ * in the trace (false). */
132
+ enforce?: boolean;
107
133
  /** The grant is finished being built — publish it. The runner keeps
108
134
  * adding grants after the lease exists (the model key's host needs the
109
135
  * lease URL first), so a store that is not this process's memory has