@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
package/src/network.ts ADDED
@@ -0,0 +1,390 @@
1
+ // Network: which sites a step's sandbox may reach.
2
+ //
3
+ // network: trusted # the trusted list, plus what the step declares
4
+ // network: open # any public site
5
+ // network: none # only what the step declares
6
+ // network: [api.example.com, "*.example.org"] # add sites; the level stays
7
+ // network: { level: trusted, allow: [api.example.com], defaults: false }
8
+ //
9
+ // Written on an agent, a workspace's AGENTS.md or the account's. The level
10
+ // and `defaults` are nearest-wins (agent, then workspace, then account);
11
+ // `allow` adds up across all three, so an account can name a site every
12
+ // agent may reach without each agent repeating it.
13
+ //
14
+ // Unset, an agent that reads the open web — `web` with fetch or browse — is
15
+ // `open`, because a search result can name any site and an allowlist would
16
+ // break it. Every other agent is `trusted`: package registries, source
17
+ // hosts and cloud SDKs, the list Anthropic's own cloud sandboxes start from
18
+ // (TRUSTED_SITES below), plus the hosts the step itself declares — its
19
+ // `apis:`, its remote MCP servers, its web providers, the sites its secrets
20
+ // are bound to. That is the default the cloud sandboxes this platform is
21
+ // measured against use: least access, widened on purpose.
22
+ //
23
+ // The rule is enforced at the egress proxy, which every request from a
24
+ // sandbox goes through (HTTPS_PROXY in the pod), and, on the Kubernetes
25
+ // tier, by the pod's network policy: a `trusted` or `none` pod reaches the
26
+ // proxy and DNS and nothing else, so a program that ignores the proxy
27
+ // variables fails instead of going round. This file is the vocabulary; the
28
+ // proxy (foldrun-platform egress-proxy.ts) holds the rule per request.
29
+
30
+ /** How wide a step's network is. */
31
+ export type NetworkLevel = "open" | "trusted" | "none";
32
+
33
+ export const NETWORK_LEVELS: readonly NetworkLevel[] = ["open", "trusted", "none"];
34
+
35
+ /** One level's `network:` line, read. Absent fields inherit. */
36
+ export interface NetworkSetting {
37
+ level?: NetworkLevel;
38
+ /** Sites added at this level. */
39
+ allow: string[];
40
+ /** false drops the trusted list, leaving only `allow` and what the step declares. */
41
+ defaults?: boolean;
42
+ }
43
+
44
+ /** What one step may reach, resolved. Plain JSON: it crosses into the egress
45
+ * grant and the run record. */
46
+ export interface NetworkPolicy {
47
+ level: NetworkLevel;
48
+ /** Every site allowed beyond what `open` would allow — the trusted list
49
+ * (unless dropped), the `allow` lists, and the hosts the step declares.
50
+ * Empty for `open`, which needs no list. */
51
+ hosts: string[];
52
+ /** Where the level came from, for the trace: "agent", "workspace",
53
+ * "account", or "default" with the reason. */
54
+ from: string;
55
+ /** Whether the trusted list is part of `hosts`. */
56
+ defaults: boolean;
57
+ /** The sites the files added (`allow`, every level), apart from the
58
+ * trusted list — what the trace and the dashboard show as "added". */
59
+ added: string[];
60
+ /** Hosts the step declares (apis, MCP servers, providers, bound secrets). */
61
+ implied: string[];
62
+ }
63
+
64
+ /**
65
+ * The trusted list: the domains Anthropic's Claude Code cloud environments
66
+ * allow at their default "Trusted" level, as published on
67
+ * code.claude.com/docs/en/cloud-environments#default-allowed-domains
68
+ * (read 14 Oct 2026), plus the Debian mirrors the runner image installs
69
+ * `runtime: system:` packages from. `*.` matches the subdomains and the
70
+ * bare domain.
71
+ */
72
+ export const TRUSTED_SITES: readonly string[] = [
73
+ // Anthropic services
74
+ "api.anthropic.com", "docs.claude.com", "platform.claude.com", "code.claude.com", "claude.ai", "claude.com",
75
+ "support.claude.com", "anthropic.com", "www.anthropic.com",
76
+ // Version control
77
+ "github.com", "www.github.com", "api.github.com", "npm.pkg.github.com", "raw.githubusercontent.com",
78
+ "pkg-npm.githubusercontent.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com",
79
+ "codeload.github.com", "avatars.githubusercontent.com", "camo.githubusercontent.com", "gist.github.com",
80
+ "gitlab.com", "www.gitlab.com", "registry.gitlab.com", "bitbucket.org", "www.bitbucket.org", "api.bitbucket.org",
81
+ // Container registries
82
+ "registry-1.docker.io", "auth.docker.io", "index.docker.io", "hub.docker.com", "www.docker.com",
83
+ "production.cloudflare.docker.com", "production.cloudfront.docker.com", "download.docker.com", "gcr.io",
84
+ "*.gcr.io", "ghcr.io", "mcr.microsoft.com", "*.data.mcr.microsoft.com", "public.ecr.aws",
85
+ // Cloud platforms
86
+ "cloud.google.com", "accounts.google.com", "gcloud.google.com", "*.googleapis.com", "storage.googleapis.com",
87
+ "compute.googleapis.com", "container.googleapis.com", "azure.com", "portal.azure.com", "microsoft.com",
88
+ "www.microsoft.com", "*.microsoftonline.com", "packages.microsoft.com", "dotnet.microsoft.com", "dot.net",
89
+ "visualstudio.com", "dev.azure.com", "*.amazonaws.com", "*.api.aws", "oracle.com", "www.oracle.com", "java.com",
90
+ "www.java.com", "java.net", "www.java.net", "download.oracle.com", "yum.oracle.com", "*.r2.cloudflarestorage.com",
91
+ // JavaScript and Node
92
+ "registry.npmjs.org", "www.npmjs.com", "www.npmjs.org", "npmjs.com", "npmjs.org", "yarnpkg.com",
93
+ "registry.yarnpkg.com", "jsr.io", "npm.jsr.io",
94
+ // Python
95
+ "pypi.org", "www.pypi.org", "files.pythonhosted.org", "pythonhosted.org", "test.pypi.org", "pypi.python.org",
96
+ "pypa.io", "www.pypa.io",
97
+ // Ruby
98
+ "rubygems.org", "www.rubygems.org", "api.rubygems.org", "index.rubygems.org", "ruby-lang.org",
99
+ "www.ruby-lang.org", "rubyforge.org", "www.rubyforge.org", "rubyonrails.org", "www.rubyonrails.org", "rvm.io",
100
+ "get.rvm.io",
101
+ // Rust
102
+ "crates.io", "www.crates.io", "index.crates.io", "static.crates.io", "rustup.rs", "static.rust-lang.org",
103
+ "www.rust-lang.org",
104
+ // Go
105
+ "proxy.golang.org", "sum.golang.org", "index.golang.org", "golang.org", "www.golang.org", "goproxy.io", "pkg.go.dev",
106
+ // JVM
107
+ "maven.org", "repo.maven.org", "central.maven.org", "repo1.maven.org", "repo.maven.apache.org",
108
+ "maven.google.com", "jcenter.bintray.com", "gradle.org", "www.gradle.org", "services.gradle.org",
109
+ "plugins.gradle.org", "plugins-artifacts.gradle.org", "kotlinlang.org", "www.kotlinlang.org", "spring.io",
110
+ "repo.spring.io",
111
+ // Other package managers
112
+ "packagist.org", "www.packagist.org", "repo.packagist.org", "nuget.org", "www.nuget.org", "api.nuget.org",
113
+ "pub.dev", "api.pub.dev", "hex.pm", "www.hex.pm", "cpan.org", "www.cpan.org", "metacpan.org", "www.metacpan.org",
114
+ "api.metacpan.org", "cocoapods.org", "www.cocoapods.org", "cdn.cocoapods.org", "haskell.org", "www.haskell.org",
115
+ "hackage.haskell.org", "swift.org", "www.swift.org",
116
+ // Linux distributions — Anthropic's list, then Debian's (the runner image is Debian)
117
+ "archive.ubuntu.com", "security.ubuntu.com", "ubuntu.com", "www.ubuntu.com", "*.ubuntu.com",
118
+ "ppa.launchpad.net", "launchpad.net", "www.launchpad.net", "*.nixos.org",
119
+ "deb.debian.org", "security.debian.org", "snapshot.debian.org",
120
+ // Development tools and platforms
121
+ "dl.k8s.io", "pkgs.k8s.io", "k8s.io", "www.k8s.io", "releases.hashicorp.com", "apt.releases.hashicorp.com",
122
+ "rpm.releases.hashicorp.com", "archive.releases.hashicorp.com", "hashicorp.com", "www.hashicorp.com",
123
+ "repo.anaconda.com", "conda.anaconda.org", "anaconda.org", "www.anaconda.com", "anaconda.com", "continuum.io",
124
+ "apache.org", "www.apache.org", "archive.apache.org", "downloads.apache.org", "eclipse.org", "www.eclipse.org",
125
+ "download.eclipse.org", "nodejs.org", "www.nodejs.org", "developer.apple.com", "developer.android.com",
126
+ "pkg.stainless.com", "binaries.prisma.sh",
127
+ // Cloud services and monitoring
128
+ "http-intake.logs.datadoghq.com", "*.datadoghq.com", "*.datadoghq.eu", "api.honeycomb.io",
129
+ // Content delivery and mirrors
130
+ "sourceforge.net", "*.sourceforge.net", "packagecloud.io", "*.packagecloud.io", "fonts.googleapis.com",
131
+ "fonts.gstatic.com",
132
+ // Schema and configuration
133
+ "json-schema.org", "www.json-schema.org", "json.schemastore.org", "www.schemastore.org",
134
+ // Model Context Protocol
135
+ "*.modelcontextprotocol.io",
136
+ ];
137
+
138
+ /** A site as `network:` takes it: a domain or `*.domain` — no scheme, no
139
+ * path, no port. The same shape `web.browse.allowed_domains` takes. */
140
+ export const SITE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
141
+
142
+ /** A host as it arrives — `API.Example.com:443.` — as the lists hold it. */
143
+ export function normalHost(host: string): string {
144
+ let h = host.trim().toLowerCase();
145
+ if (h.startsWith("[")) return h.replace(/^\[|\](:\d+)?$/g, ""); // an IPv6 literal
146
+ h = h.replace(/:\d+$/, "");
147
+ return h.replace(/\.$/, "");
148
+ }
149
+
150
+ /** Does `site` (a domain or `*.domain`) cover `host`? `*.example.com`
151
+ * covers `a.example.com`, `a.b.example.com` and `example.com` itself. */
152
+ export function siteCovers(site: string, host: string): boolean {
153
+ const h = normalHost(host);
154
+ const s = site.toLowerCase();
155
+ if (s.startsWith("*.")) {
156
+ const base = s.slice(2);
157
+ return h === base || h.endsWith(`.${base}`);
158
+ }
159
+ return h === s;
160
+ }
161
+
162
+ /** May a step under `policy` reach `host`? `open` reaches any public host —
163
+ * the caller still refuses private addresses, which no level opens. */
164
+ export function networkAllows(policy: Pick<NetworkPolicy, "level" | "hosts">, host: string): boolean {
165
+ if (policy.level === "open") return true;
166
+ return policy.hosts.some((s) => siteCovers(s, host));
167
+ }
168
+
169
+ /** A list of sites from YAML's list or one comma-separated line. */
170
+ function siteList(v: unknown): string[] | null {
171
+ if (typeof v === "string") return v.split(",").map((x) => x.trim()).filter(Boolean);
172
+ if (Array.isArray(v) && v.every((x) => typeof x === "string")) return v.map((x) => x.trim()).filter(Boolean);
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * One `network:` value, read. Never throws: what cannot be read is a
178
+ * problem `check` and deploy report, and the run treats that level as
179
+ * saying nothing — the next level up, or the default, decides.
180
+ */
181
+ export function readNetwork(raw: unknown, where = "network"): { setting: NetworkSetting | null; problems: string[] } {
182
+ if (raw === undefined || raw === null) return { setting: null, problems: [] };
183
+ const problems: string[] = [];
184
+ const sites = (v: unknown, key: string): string[] => {
185
+ const list = siteList(v);
186
+ if (!list) {
187
+ problems.push(`${key} is a list of sites, like [api.example.com, "*.example.org"].`);
188
+ return [];
189
+ }
190
+ const out: string[] = [];
191
+ for (const s of list.map((x) => x.toLowerCase())) {
192
+ if (/^https?:\/\//.test(s) || s.includes("/")) problems.push(`${key}: ${s} — a site is a domain, no scheme or path: ${s.replace(/^https?:\/\//, "").split("/")[0]}`);
193
+ else if (/:\d+$/.test(s)) problems.push(`${key}: ${s} — a site has no port; every port of the host is covered`);
194
+ else if (!SITE.test(s)) problems.push(`${key}: ${s} is not a domain or *.domain`);
195
+ else out.push(s);
196
+ }
197
+ return out;
198
+ };
199
+ const level = (v: unknown, key: string): NetworkLevel | undefined => {
200
+ if (typeof v === "string" && (NETWORK_LEVELS as readonly string[]).includes(v.trim().toLowerCase())) {
201
+ return v.trim().toLowerCase() as NetworkLevel;
202
+ }
203
+ problems.push(`${key} is ${NETWORK_LEVELS.join(", ")} — not ${JSON.stringify(v)}.`);
204
+ return undefined;
205
+ };
206
+ if (typeof raw === "string" && (NETWORK_LEVELS as readonly string[]).includes(raw.trim().toLowerCase())) {
207
+ return { setting: { level: raw.trim().toLowerCase() as NetworkLevel, allow: [] }, problems };
208
+ }
209
+ if (typeof raw === "string" || Array.isArray(raw)) {
210
+ // A bare word that is not a level reads as a site only when it looks
211
+ // like one — `network: closed` is a typo, not a domain called "closed".
212
+ if (typeof raw === "string" && !raw.includes(".") && !raw.includes(",")) {
213
+ return { setting: null, problems: [`${where} is ${NETWORK_LEVELS.join(", ")}, a list of sites, or {level, allow, defaults} — not "${raw}".`] };
214
+ }
215
+ const allow = sites(raw, where);
216
+ return { setting: problems.length && !allow.length ? null : { allow }, problems };
217
+ }
218
+ if (typeof raw === "object") {
219
+ const o = raw as Record<string, unknown>;
220
+ for (const k of Object.keys(o)) {
221
+ if (!["level", "allow", "defaults"].includes(k)) problems.push(`${where}.${k} is not a key — level, allow, defaults.`);
222
+ }
223
+ const setting: NetworkSetting = { allow: [] };
224
+ if (o.level !== undefined) setting.level = level(o.level, `${where}.level`);
225
+ if (o.allow !== undefined) setting.allow = sites(o.allow, `${where}.allow`);
226
+ if (o.defaults !== undefined) {
227
+ if (o.defaults === true || o.defaults === false) setting.defaults = o.defaults;
228
+ else problems.push(`${where}.defaults is true or false — false drops the trusted list.`);
229
+ }
230
+ return { setting, problems };
231
+ }
232
+ return { setting: null, problems: [`${where} is ${NETWORK_LEVELS.join(", ")}, a list of sites, or {level, allow, defaults}.`] };
233
+ }
234
+
235
+ /** `check`'s and deploy's view: the problems only. */
236
+ export function networkProblems(raw: unknown): string[] {
237
+ return readNetwork(raw).problems;
238
+ }
239
+
240
+ /**
241
+ * The step's network, from its three levels and what it declares.
242
+ *
243
+ * `readsWeb` — the step may fetch or browse the open web (the `web` tool
244
+ * with fetch or browse). It decides the default level only; a `network:`
245
+ * written anywhere beats it.
246
+ */
247
+ export function resolveNetwork(args: {
248
+ account?: unknown;
249
+ workspace?: unknown;
250
+ agent?: unknown;
251
+ readsWeb: boolean;
252
+ implied?: Iterable<string>;
253
+ }): NetworkPolicy {
254
+ const levels: [string, NetworkSetting | null][] = [
255
+ ["agent", readNetwork(args.agent).setting],
256
+ ["workspace", readNetwork(args.workspace).setting],
257
+ ["account", readNetwork(args.account).setting],
258
+ ];
259
+ const levelHit = levels.find(([, s]) => s?.level);
260
+ const level: NetworkLevel = levelHit ? levelHit[1]!.level! : args.readsWeb ? "open" : "trusted";
261
+ const from = levelHit ? levelHit[0] : args.readsWeb ? "default — the agent reads the web" : "default";
262
+ const defaultsHit = levels.find(([, s]) => s?.defaults !== undefined);
263
+ const defaults = level === "trusted" && (defaultsHit ? defaultsHit[1]!.defaults! : true);
264
+ const added = [...new Set(levels.flatMap(([, s]) => s?.allow ?? []))];
265
+ const implied = [...new Set([...(args.implied ?? [])].map(normalHost).filter((h) => h && SITE.test(h)))];
266
+ const hosts = level === "open" ? [] : [...new Set([...(defaults ? TRUSTED_SITES : []), ...added, ...implied])];
267
+ return { level, hosts, from, defaults, added, implied };
268
+ }
269
+
270
+ /** One line for the run's trace. */
271
+ export function networkLine(policy: NetworkPolicy, enforced: boolean | null): string {
272
+ const how =
273
+ enforced === null
274
+ ? " — not enforced here (no sandbox network)"
275
+ : enforced
276
+ ? ""
277
+ : " — report only on this install: a site off the list is allowed and named in the trace";
278
+ if (policy.level === "open") return `network: open (${policy.from}) — any public site${how}`;
279
+ const parts: string[] = [];
280
+ if (policy.defaults) parts.push(`the trusted list (${TRUSTED_SITES.length} sites)`);
281
+ if (policy.added.length) parts.push(`added: ${policy.added.join(", ")}`);
282
+ if (policy.implied.length) parts.push(`declared by the step: ${policy.implied.join(", ")}`);
283
+ const what = parts.length ? parts.join("; ") : "nothing but the platform's own services";
284
+ return `network: ${policy.level} (${policy.from}) — ${what}${how}`;
285
+ }
286
+
287
+ /** What the proxy says when it refuses a site. */
288
+ export function refusalText(policy: Pick<NetworkPolicy, "level">, host: string): string {
289
+ return `network: ${host} is not a site this step may reach (network: ${policy.level}). Add it to the agent's network: list — network: [${normalHost(host)}] — or set network: open.`;
290
+ }
291
+
292
+ /** The proxy variables a sandbox's programs read, for a forward-proxy URL
293
+ * (`http://foldrun:<token>@host:port`). Both spellings: curl reads only the
294
+ * lower-case http_proxy, much else only the upper-case. NO_PROXY keeps the
295
+ * cluster's own names — the egress address itself, the account's pods —
296
+ * off the proxy. NODE_USE_ENV_PROXY turns the variables on for Node's own
297
+ * fetch and http (Node 22.21 and later). */
298
+ export function proxyEnv(proxyUrl: string, extraNoProxy: string[] = []): Record<string, string> {
299
+ let egressHost = "";
300
+ try {
301
+ egressHost = new URL(proxyUrl).hostname;
302
+ } catch {
303
+ // a malformed URL is still exported as given; the client says why it fails
304
+ }
305
+ const noProxy = [...new Set(["localhost", "127.0.0.1", "::1", ".svc", ".cluster.local", egressHost, ...extraNoProxy].filter(Boolean))].join(",");
306
+ return {
307
+ HTTP_PROXY: proxyUrl,
308
+ HTTPS_PROXY: proxyUrl,
309
+ http_proxy: proxyUrl,
310
+ https_proxy: proxyUrl,
311
+ NO_PROXY: noProxy,
312
+ no_proxy: noProxy,
313
+ NODE_USE_ENV_PROXY: "1",
314
+ // Node 22 warns "EnvHttpProxyAgent is experimental" on the first request
315
+ // of every process; a script tool's stderr then reads as an error to the
316
+ // model. That one warning, and no other.
317
+ NODE_OPTIONS: "--disable-warning=UNDICI-EHPA",
318
+ };
319
+ }
320
+
321
+ /** The env var the egress CA travels in — its PEM, base64'd so it survives
322
+ * every env channel (docker's env file has no multi-line values). The
323
+ * pod's start writes it to a file and points every TLS client at a bundle
324
+ * that includes it. */
325
+ export const EGRESS_CA_ENV = "FOLDRUN_EGRESS_CA";
326
+
327
+ /**
328
+ * The shell the sandbox's start runs before anything else: when the step
329
+ * carries the egress CA, write it, build a bundle of the system's CAs plus
330
+ * it, and point every TLS client the image has at that bundle. The CA only
331
+ * ever signs for hosts a secret is bound to — every other connection is a
332
+ * tunnel the proxy cannot read — but those clients must trust it there.
333
+ * Sourced after the env file, before system packages install (apt goes
334
+ * through the proxy too). `dir` is a directory the start may write.
335
+ */
336
+ export function egressCaSh(dir: string, systemBundle = "/etc/ssl/certs/ca-certificates.crt"): string {
337
+ // Without the system's bundle to add to, a bundle of the egress CA alone
338
+ // would make every other site untrusted — so only Node, which ADDS the
339
+ // file to its own roots, is told; nothing else changes.
340
+ return `if [ -n "\${${EGRESS_CA_ENV}:-}" ] && printf '%s' "\$${EGRESS_CA_ENV}" | base64 -d > ${dir}/egress-ca.pem 2>/dev/null; then
341
+ if [ -s ${systemBundle} ] && cat ${systemBundle} ${dir}/egress-ca.pem > ${dir}/ca-bundle.pem; then
342
+ export SSL_CERT_FILE=${dir}/ca-bundle.pem REQUESTS_CA_BUNDLE=${dir}/ca-bundle.pem CURL_CA_BUNDLE=${dir}/ca-bundle.pem PIP_CERT=${dir}/ca-bundle.pem GIT_SSL_CAINFO=${dir}/ca-bundle.pem
343
+ fi
344
+ export NODE_EXTRA_CA_CERTS=${dir}/egress-ca.pem
345
+ fi`;
346
+ }
347
+
348
+ /** A site as a YAML flow item: `*.x` would read as an alias, so it is quoted. */
349
+ const siteItem = (site: string) => (/^[a-z0-9]/.test(site) ? site : JSON.stringify(site));
350
+
351
+ /** `network:` as one line of YAML, the shortest of the three shapes that
352
+ * says it; null when there is nothing to say. */
353
+ export function networkYaml(setting: { level?: NetworkLevel; allow?: string[]; defaults?: boolean } | null): string | null {
354
+ if (!setting) return null;
355
+ const allow = [...new Set((setting.allow ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean))];
356
+ const list = `[${allow.map(siteItem).join(", ")}]`;
357
+ if (setting.defaults === false) {
358
+ return `{ ${[setting.level ? `level: ${setting.level}` : "", allow.length ? `allow: ${list}` : "", "defaults: false"].filter(Boolean).join(", ")} }`;
359
+ }
360
+ if (setting.level && allow.length) return `{ level: ${setting.level}, allow: ${list} }`;
361
+ if (setting.level) return setting.level;
362
+ if (allow.length) return list;
363
+ return null;
364
+ }
365
+
366
+ /**
367
+ * The text of an agent.md (or AGENTS.md) with its `network:` replaced by
368
+ * `setting` — or removed, for null — and every other byte as it was. The
369
+ * old value's indented lines go with it, whatever shape it was written in.
370
+ * Pure string work, so the dashboard runs it in the browser and the CLI on
371
+ * a file, and both write exactly the same line.
372
+ */
373
+ export function setNetworkSetting(raw: string, setting: { level?: NetworkLevel; allow?: string[]; defaults?: boolean } | null): string {
374
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
375
+ const lines = raw.split(eol);
376
+ const value = networkYaml(setting);
377
+ const line = value ? `network: ${value}` : null;
378
+ if (lines[0]?.trim() !== "---") return line ? ["---", line, "---", ...lines].join(eol) : raw;
379
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
380
+ if (close < 0) return raw;
381
+ const at = lines.findIndex((l, i) => i > 0 && i < close && /^network\s*:/.test(l));
382
+ if (at < 0) {
383
+ if (line) lines.splice(close, 0, line);
384
+ return lines.join(eol);
385
+ }
386
+ let end = at + 1;
387
+ while (end < close && (/^\s+\S/.test(lines[end]) || /^\s*-\s/.test(lines[end]))) end++;
388
+ lines.splice(at, end - at, ...(line ? [line] : []));
389
+ return lines.join(eol);
390
+ }
package/src/notify.ts CHANGED
@@ -137,6 +137,15 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
137
137
  // line per parked run would bury the failures around it.
138
138
  let warnedNoPublicUrl = false;
139
139
 
140
+ /**
141
+ * The version of the webhook body's shape, sent on every delivery as
142
+ * `x-foldrun-webhook-version` (and in the user agent). Within a version the
143
+ * body only gains fields — a receiver must ignore the ones it does not know.
144
+ * A field removed, renamed or retyped is a new version, announced in What's
145
+ * new at least six months before the old shape stops being sent.
146
+ */
147
+ export const WEBHOOK_PAYLOAD_VERSION = 1;
148
+
140
149
  /**
141
150
  * The headers that prove a delivery came from this install.
142
151
  *
@@ -252,7 +261,8 @@ export async function attemptWebhook(
252
261
  method: "POST",
253
262
  headers: {
254
263
  "content-type": "application/json",
255
- "user-agent": "foldrun-webhooks/1",
264
+ "user-agent": `foldrun-webhooks/${WEBHOOK_PAYLOAD_VERSION}`,
265
+ "x-foldrun-webhook-version": String(WEBHOOK_PAYLOAD_VERSION),
256
266
  "x-foldrun-delivery": d.id,
257
267
  "x-foldrun-event": d.event,
258
268
  "x-foldrun-attempt": String(d.attempt),
package/src/providers.ts CHANGED
@@ -591,6 +591,11 @@ export interface BrowseSettings {
591
591
  ignore_https_errors?: boolean;
592
592
  /** The NAME of a vault secret whose value encrypts state= logins at rest. */
593
593
  state_key?: string;
594
+ /** A saved login every call uses by default: loaded from
595
+ * workspace/state/browser/<name>.json and saved back when the call ends.
596
+ * Beside `cookies:` (with `state_key:`), the secret seeds it once and the
597
+ * session the site keeps rotating is what later runs load. */
598
+ state?: string;
594
599
  /** true records every call, so the run page always has the recording to play. */
595
600
  video?: boolean;
596
601
  /** false stops the tool streaming its page to the run page while the step
@@ -685,7 +690,7 @@ export function browseEngineRefusal(engine: string, what: string): string | null
685
690
  }
686
691
  const BROWSE_SETTING_KEYS = [
687
692
  "engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "identities", "headless", "version", "live",
688
- "allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key",
693
+ "allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key", "state",
689
694
  "video", "live_view", "vendor_session",
690
695
  // Deprecated spelling of vendor_session (read, and warned about).
691
696
  "session",
@@ -964,6 +969,11 @@ export function readBrowseSettings(raw: unknown): { settings: BrowseSettings; re
964
969
  if (list.length) settings[k] = list;
965
970
  continue;
966
971
  }
972
+ if (k === "state") {
973
+ if (typeof v !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(v)) return { settings, rest: left(rest), error: "web.browse.state is a plain name (letters, digits, dot, dash, underscore), like youtube — the login is kept in workspace/state/browser/<name>.json." };
974
+ settings.state = v;
975
+ continue;
976
+ }
967
977
  if (k === "state_key") {
968
978
  if (typeof v !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(v)) return { settings, rest: left(rest), error: "web.browse.state_key must be the NAME of a vault secret (CAPITALS), never the key — store it with `foldrun secrets set NAME` and declare it under secrets:." };
969
979
  settings.state_key = v;
@@ -1033,6 +1043,22 @@ export function readBrowseSettings(raw: unknown): { settings: BrowseSettings; re
1033
1043
  };
1034
1044
  }
1035
1045
 
1046
+ // A secret's cookies seeding a saved login: the login then sits in state/,
1047
+ // a workspace file, so it must be encrypted with a key from the vault.
1048
+ if (settings.state && settings.cookies && !settings.state_key) {
1049
+ return {
1050
+ settings,
1051
+ rest: left(rest),
1052
+ error:
1053
+ `web.browse.state beside cookies needs web.browse.state_key — the secret's session would be saved ` +
1054
+ `to state/ where any step can read it. Store a key (\`foldrun secrets set BROWSER_STATE_KEY\`), ` +
1055
+ `write \`state_key: BROWSER_STATE_KEY\` and declare it under secrets:.`,
1056
+ };
1057
+ }
1058
+ if (settings.state && settings.storage) {
1059
+ return { settings, rest: left(rest), error: "web.browse.state cannot be combined with storage: a secret's tokens would be written to state/." };
1060
+ }
1061
+
1036
1062
  // Storage is per origin, and seeding it into the wrong one would hand a
1037
1063
  // site another site's signed-in state — the same mistake `cookie_domain`
1038
1064
  // exists to prevent, one storage area over.
@@ -18,6 +18,7 @@
18
18
  // The runner image bundles @foldrun/core itself, so the driver runs the same
19
19
  // executeStep the server would — one implementation, two homes.
20
20
 
21
+ import { isHookRecord, type HookRecord } from "./hooks.ts";
21
22
  import fs from "node:fs";
22
23
  import os from "node:os";
23
24
  import path from "node:path";
@@ -29,6 +30,7 @@ import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.
29
30
  import type { EventExtra } from "./step-exec.ts";
30
31
  import { isFileValue, fileContent } from "./secrets.ts";
31
32
  import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
33
+ import { egressCaSh, type NetworkLevel } from "./network.ts";
32
34
  import type { ScriptSpec } from "./script-tools.ts";
33
35
  import type { ConsultSpec } from "./agent-tools.ts";
34
36
  import type { SubagentSpec } from "./subagents.ts";
@@ -77,6 +79,9 @@ export interface ContainerStepInput {
77
79
  /** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
78
80
  * count reads (limits.ts) — enforced in the pod by the same hook. */
79
81
  limits?: Record<string, number>;
82
+ /** `hooks:` names, resolved inside the sandbox against its own roots. */
83
+ hookNames?: string[];
84
+ hookBase?: Record<string, unknown>;
80
85
  toolOwners?: Record<string, string>;
81
86
  /** tools: [search] — the directories to search, as paths INSIDE the
82
87
  * container (/workspace/…, /library/…). */
@@ -172,7 +177,9 @@ const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
172
177
  // there: everything platform-owned (store.ts#isPlatformPath — also never
173
178
  // copied in), plus knowledge/, which goes in for reading and is dropped on
174
179
  // the way out, and .git/.
175
- const DENY_BACK = ["knowledge/", ".git/"];
180
+ // hooks/: a step must not rewrite the code that guards it — for this run (the
181
+ // code is read into memory before the model starts, hooks.ts) or the next.
182
+ const DENY_BACK = ["knowledge/", "hooks/", ".git/"];
176
183
  // A .git ANYWHERE in the tree, not just at the root. A step that clones a
177
184
  // repository into a shared area (files/site, say) was shipping the entire
178
185
  // object store back to the workspace, where the harvest then filed thousands
@@ -659,6 +666,7 @@ try {
659
666
  schema: input.schema,
660
667
  maxTurns: input.maxTurns,
661
668
  ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
669
+ ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
662
670
  // Slim browsing: the platform put this step on the slim image to browse
663
671
  // through the account's pod, and named the file the web tool logs its
664
672
  // reconnects to. A lost pod stops the step; what it wrote decides what
@@ -944,6 +952,7 @@ ${CORE_INSTALL}`;
944
952
  // reads the status file and fails the step with apt's own words.
945
953
  const ENTRY = `#!/bin/sh
946
954
  set +e
955
+ ${egressCaSh("/opt/runner/job")}
947
956
  ${SYSTEM_FROM_INPUT_SH}
948
957
  ${systemInstallSh(RUNTIME_CACHE)}
949
958
  ${INSTALL_HELPER_SH}
@@ -1107,7 +1116,7 @@ export function buildFailureSummary(log: string, opts: { tail?: number; maxLines
1107
1116
 
1108
1117
  export function parseDriverLine(
1109
1118
  line: string,
1110
- ): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean } | { e: "done" } & ContainerStepOutcome | null {
1119
+ ): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord } | { e: "done" } & ContainerStepOutcome | null {
1111
1120
  const trimmed = line.trim();
1112
1121
  if (!trimmed.startsWith("{")) return null;
1113
1122
  try {
@@ -1123,6 +1132,7 @@ export function parseDriverLine(
1123
1132
  ...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
1124
1133
  ...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
1125
1134
  ...(parsed.check === true ? { check: true } : {}),
1135
+ ...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
1126
1136
  };
1127
1137
  }
1128
1138
  if (parsed.e === "done") {
@@ -1193,6 +1203,10 @@ export interface RunInContainerArgs {
1193
1203
  input: ContainerStepInput;
1194
1204
  /** Secret + provider env — crosses as an env file, never argv. */
1195
1205
  env: Record<string, string>;
1206
+ /** The step's network (network.ts), for an executor that can wall the
1207
+ * sandbox in itself: the k8s tier labels a filtered pod so its policy
1208
+ * admits the proxy and DNS alone. `enforced` false is report-only. */
1209
+ network?: { level: NetworkLevel; enforced: boolean };
1196
1210
  emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
1197
1211
  /** The run this step belongs to. Stamped on the sandbox as a label so a
1198
1212
  * person stopping the run can reach the thing actually burning money —