@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.
- package/README.md +14 -1
- 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/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/network.d.ts +120 -0
- package/dist/src/network.js +360 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +13 -0
- package/dist/src/run-container.js +8 -1
- package/dist/src/runner.d.ts +1 -0
- package/dist/src/runner.js +144 -16
- package/dist/src/secrets.d.ts +20 -0
- package/dist/src/secrets.js +65 -1
- package/dist/src/starter.js +9 -1
- package/dist/src/step-exec.d.ts +10 -0
- package/dist/src/step-exec.js +113 -2
- package/dist/src/store.d.ts +23 -1
- package/dist/src/store.js +33 -1
- package/dist/src/tool-loop.d.ts +45 -0
- package/dist/src/tool-loop.js +133 -0
- package/package.json +19 -1
- package/src/completions.ts +1 -0
- package/src/deploy.ts +4 -0
- package/src/egress.ts +26 -0
- package/src/hooks.ts +406 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/network.ts +390 -0
- package/src/notify.ts +11 -1
- package/src/providers.ts +27 -1
- package/src/run-container.ts +16 -2
- package/src/runner.ts +145 -14
- package/src/secrets.ts +69 -1
- package/src/starter.ts +9 -1
- package/src/step-exec.ts +115 -3
- package/src/store.ts +39 -1
- package/src/tool-loop.ts +176 -0
package/dist/src/library.js
CHANGED
|
@@ -22,7 +22,7 @@ import { assertSafeName, assertCanonicalCase, buildMemoryIndex, parseToolDef, re
|
|
|
22
22
|
// Order matters and is asserted: it is the order every noun list in the
|
|
23
23
|
// product uses. Tools then scripts, because a script is material a tool
|
|
24
24
|
// points at — see the KINDS table, which is where the order is declared.
|
|
25
|
-
export const LIBRARY_KINDS = ["tools", "knowledge", "memory", "skills", "scripts"];
|
|
25
|
+
export const LIBRARY_KINDS = ["tools", "hooks", "knowledge", "memory", "skills", "scripts"];
|
|
26
26
|
export function libraryDir(tenant, kind) {
|
|
27
27
|
// Alongside the workspace in single-workspace mode: ./library, so a shared
|
|
28
28
|
// library is still possible on a laptop without inventing a home directory.
|
|
@@ -117,6 +117,22 @@ export function listLibrary(tenant, kind) {
|
|
|
117
117
|
}
|
|
118
118
|
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
119
119
|
}
|
|
120
|
+
// A hook is a folder: hook.md says when it runs, run.mjs is the code.
|
|
121
|
+
if (kind === "hooks") {
|
|
122
|
+
for (const entry of fs.readdirSync(dir).sort()) {
|
|
123
|
+
const full = path.join(dir, entry);
|
|
124
|
+
const hookMd = path.join(full, "hook.md");
|
|
125
|
+
if (!fs.statSync(full).isDirectory() || !fs.existsSync(hookMd))
|
|
126
|
+
continue;
|
|
127
|
+
out.push({
|
|
128
|
+
...describe(hookMd, entry),
|
|
129
|
+
path: `${entry}/hook.md`,
|
|
130
|
+
files: folderFiles(full, entry),
|
|
131
|
+
updatedAt: fs.statSync(hookMd).mtime.toISOString(),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
120
136
|
if (kind === "skills") {
|
|
121
137
|
for (const entry of fs.readdirSync(dir).sort()) {
|
|
122
138
|
const full = path.join(dir, entry);
|
package/dist/src/mail.js
CHANGED
|
@@ -56,7 +56,7 @@ export const MAIL_CATEGORIES = [
|
|
|
56
56
|
label: "Account notices",
|
|
57
57
|
required: true,
|
|
58
58
|
perWorkspace: false,
|
|
59
|
-
description: "Something about the account stopped working on its own — a notification webhook switched off after days of failures, a close request.",
|
|
59
|
+
description: "Something about the account stopped working on its own, or is about to — a notification webhook switched off after days of failures, a close request, an API route it still calls being switched off.",
|
|
60
60
|
why: "each one is the only message saying a thing you rely on has stopped",
|
|
61
61
|
},
|
|
62
62
|
{
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** How wide a step's network is. */
|
|
2
|
+
export type NetworkLevel = "open" | "trusted" | "none";
|
|
3
|
+
export declare const NETWORK_LEVELS: readonly NetworkLevel[];
|
|
4
|
+
/** One level's `network:` line, read. Absent fields inherit. */
|
|
5
|
+
export interface NetworkSetting {
|
|
6
|
+
level?: NetworkLevel;
|
|
7
|
+
/** Sites added at this level. */
|
|
8
|
+
allow: string[];
|
|
9
|
+
/** false drops the trusted list, leaving only `allow` and what the step declares. */
|
|
10
|
+
defaults?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** What one step may reach, resolved. Plain JSON: it crosses into the egress
|
|
13
|
+
* grant and the run record. */
|
|
14
|
+
export interface NetworkPolicy {
|
|
15
|
+
level: NetworkLevel;
|
|
16
|
+
/** Every site allowed beyond what `open` would allow — the trusted list
|
|
17
|
+
* (unless dropped), the `allow` lists, and the hosts the step declares.
|
|
18
|
+
* Empty for `open`, which needs no list. */
|
|
19
|
+
hosts: string[];
|
|
20
|
+
/** Where the level came from, for the trace: "agent", "workspace",
|
|
21
|
+
* "account", or "default" with the reason. */
|
|
22
|
+
from: string;
|
|
23
|
+
/** Whether the trusted list is part of `hosts`. */
|
|
24
|
+
defaults: boolean;
|
|
25
|
+
/** The sites the files added (`allow`, every level), apart from the
|
|
26
|
+
* trusted list — what the trace and the dashboard show as "added". */
|
|
27
|
+
added: string[];
|
|
28
|
+
/** Hosts the step declares (apis, MCP servers, providers, bound secrets). */
|
|
29
|
+
implied: string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The trusted list: the domains Anthropic's Claude Code cloud environments
|
|
33
|
+
* allow at their default "Trusted" level, as published on
|
|
34
|
+
* code.claude.com/docs/en/cloud-environments#default-allowed-domains
|
|
35
|
+
* (read 14 Oct 2026), plus the Debian mirrors the runner image installs
|
|
36
|
+
* `runtime: system:` packages from. `*.` matches the subdomains and the
|
|
37
|
+
* bare domain.
|
|
38
|
+
*/
|
|
39
|
+
export declare const TRUSTED_SITES: readonly string[];
|
|
40
|
+
/** A site as `network:` takes it: a domain or `*.domain` — no scheme, no
|
|
41
|
+
* path, no port. The same shape `web.browse.allowed_domains` takes. */
|
|
42
|
+
export declare const SITE: RegExp;
|
|
43
|
+
/** A host as it arrives — `API.Example.com:443.` — as the lists hold it. */
|
|
44
|
+
export declare function normalHost(host: string): string;
|
|
45
|
+
/** Does `site` (a domain or `*.domain`) cover `host`? `*.example.com`
|
|
46
|
+
* covers `a.example.com`, `a.b.example.com` and `example.com` itself. */
|
|
47
|
+
export declare function siteCovers(site: string, host: string): boolean;
|
|
48
|
+
/** May a step under `policy` reach `host`? `open` reaches any public host —
|
|
49
|
+
* the caller still refuses private addresses, which no level opens. */
|
|
50
|
+
export declare function networkAllows(policy: Pick<NetworkPolicy, "level" | "hosts">, host: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* One `network:` value, read. Never throws: what cannot be read is a
|
|
53
|
+
* problem `check` and deploy report, and the run treats that level as
|
|
54
|
+
* saying nothing — the next level up, or the default, decides.
|
|
55
|
+
*/
|
|
56
|
+
export declare function readNetwork(raw: unknown, where?: string): {
|
|
57
|
+
setting: NetworkSetting | null;
|
|
58
|
+
problems: string[];
|
|
59
|
+
};
|
|
60
|
+
/** `check`'s and deploy's view: the problems only. */
|
|
61
|
+
export declare function networkProblems(raw: unknown): string[];
|
|
62
|
+
/**
|
|
63
|
+
* The step's network, from its three levels and what it declares.
|
|
64
|
+
*
|
|
65
|
+
* `readsWeb` — the step may fetch or browse the open web (the `web` tool
|
|
66
|
+
* with fetch or browse). It decides the default level only; a `network:`
|
|
67
|
+
* written anywhere beats it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveNetwork(args: {
|
|
70
|
+
account?: unknown;
|
|
71
|
+
workspace?: unknown;
|
|
72
|
+
agent?: unknown;
|
|
73
|
+
readsWeb: boolean;
|
|
74
|
+
implied?: Iterable<string>;
|
|
75
|
+
}): NetworkPolicy;
|
|
76
|
+
/** One line for the run's trace. */
|
|
77
|
+
export declare function networkLine(policy: NetworkPolicy, enforced: boolean | null): string;
|
|
78
|
+
/** What the proxy says when it refuses a site. */
|
|
79
|
+
export declare function refusalText(policy: Pick<NetworkPolicy, "level">, host: string): string;
|
|
80
|
+
/** The proxy variables a sandbox's programs read, for a forward-proxy URL
|
|
81
|
+
* (`http://foldrun:<token>@host:port`). Both spellings: curl reads only the
|
|
82
|
+
* lower-case http_proxy, much else only the upper-case. NO_PROXY keeps the
|
|
83
|
+
* cluster's own names — the egress address itself, the account's pods —
|
|
84
|
+
* off the proxy. NODE_USE_ENV_PROXY turns the variables on for Node's own
|
|
85
|
+
* fetch and http (Node 22.21 and later). */
|
|
86
|
+
export declare function proxyEnv(proxyUrl: string, extraNoProxy?: string[]): Record<string, string>;
|
|
87
|
+
/** The env var the egress CA travels in — its PEM, base64'd so it survives
|
|
88
|
+
* every env channel (docker's env file has no multi-line values). The
|
|
89
|
+
* pod's start writes it to a file and points every TLS client at a bundle
|
|
90
|
+
* that includes it. */
|
|
91
|
+
export declare const EGRESS_CA_ENV = "FOLDRUN_EGRESS_CA";
|
|
92
|
+
/**
|
|
93
|
+
* The shell the sandbox's start runs before anything else: when the step
|
|
94
|
+
* carries the egress CA, write it, build a bundle of the system's CAs plus
|
|
95
|
+
* it, and point every TLS client the image has at that bundle. The CA only
|
|
96
|
+
* ever signs for hosts a secret is bound to — every other connection is a
|
|
97
|
+
* tunnel the proxy cannot read — but those clients must trust it there.
|
|
98
|
+
* Sourced after the env file, before system packages install (apt goes
|
|
99
|
+
* through the proxy too). `dir` is a directory the start may write.
|
|
100
|
+
*/
|
|
101
|
+
export declare function egressCaSh(dir: string, systemBundle?: string): string;
|
|
102
|
+
/** `network:` as one line of YAML, the shortest of the three shapes that
|
|
103
|
+
* says it; null when there is nothing to say. */
|
|
104
|
+
export declare function networkYaml(setting: {
|
|
105
|
+
level?: NetworkLevel;
|
|
106
|
+
allow?: string[];
|
|
107
|
+
defaults?: boolean;
|
|
108
|
+
} | null): string | null;
|
|
109
|
+
/**
|
|
110
|
+
* The text of an agent.md (or AGENTS.md) with its `network:` replaced by
|
|
111
|
+
* `setting` — or removed, for null — and every other byte as it was. The
|
|
112
|
+
* old value's indented lines go with it, whatever shape it was written in.
|
|
113
|
+
* Pure string work, so the dashboard runs it in the browser and the CLI on
|
|
114
|
+
* a file, and both write exactly the same line.
|
|
115
|
+
*/
|
|
116
|
+
export declare function setNetworkSetting(raw: string, setting: {
|
|
117
|
+
level?: NetworkLevel;
|
|
118
|
+
allow?: string[];
|
|
119
|
+
defaults?: boolean;
|
|
120
|
+
} | null): string;
|
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
export const NETWORK_LEVELS = ["open", "trusted", "none"];
|
|
30
|
+
/**
|
|
31
|
+
* The trusted list: the domains Anthropic's Claude Code cloud environments
|
|
32
|
+
* allow at their default "Trusted" level, as published on
|
|
33
|
+
* code.claude.com/docs/en/cloud-environments#default-allowed-domains
|
|
34
|
+
* (read 14 Oct 2026), plus the Debian mirrors the runner image installs
|
|
35
|
+
* `runtime: system:` packages from. `*.` matches the subdomains and the
|
|
36
|
+
* bare domain.
|
|
37
|
+
*/
|
|
38
|
+
export const TRUSTED_SITES = [
|
|
39
|
+
// Anthropic services
|
|
40
|
+
"api.anthropic.com", "docs.claude.com", "platform.claude.com", "code.claude.com", "claude.ai", "claude.com",
|
|
41
|
+
"support.claude.com", "anthropic.com", "www.anthropic.com",
|
|
42
|
+
// Version control
|
|
43
|
+
"github.com", "www.github.com", "api.github.com", "npm.pkg.github.com", "raw.githubusercontent.com",
|
|
44
|
+
"pkg-npm.githubusercontent.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com",
|
|
45
|
+
"codeload.github.com", "avatars.githubusercontent.com", "camo.githubusercontent.com", "gist.github.com",
|
|
46
|
+
"gitlab.com", "www.gitlab.com", "registry.gitlab.com", "bitbucket.org", "www.bitbucket.org", "api.bitbucket.org",
|
|
47
|
+
// Container registries
|
|
48
|
+
"registry-1.docker.io", "auth.docker.io", "index.docker.io", "hub.docker.com", "www.docker.com",
|
|
49
|
+
"production.cloudflare.docker.com", "production.cloudfront.docker.com", "download.docker.com", "gcr.io",
|
|
50
|
+
"*.gcr.io", "ghcr.io", "mcr.microsoft.com", "*.data.mcr.microsoft.com", "public.ecr.aws",
|
|
51
|
+
// Cloud platforms
|
|
52
|
+
"cloud.google.com", "accounts.google.com", "gcloud.google.com", "*.googleapis.com", "storage.googleapis.com",
|
|
53
|
+
"compute.googleapis.com", "container.googleapis.com", "azure.com", "portal.azure.com", "microsoft.com",
|
|
54
|
+
"www.microsoft.com", "*.microsoftonline.com", "packages.microsoft.com", "dotnet.microsoft.com", "dot.net",
|
|
55
|
+
"visualstudio.com", "dev.azure.com", "*.amazonaws.com", "*.api.aws", "oracle.com", "www.oracle.com", "java.com",
|
|
56
|
+
"www.java.com", "java.net", "www.java.net", "download.oracle.com", "yum.oracle.com", "*.r2.cloudflarestorage.com",
|
|
57
|
+
// JavaScript and Node
|
|
58
|
+
"registry.npmjs.org", "www.npmjs.com", "www.npmjs.org", "npmjs.com", "npmjs.org", "yarnpkg.com",
|
|
59
|
+
"registry.yarnpkg.com", "jsr.io", "npm.jsr.io",
|
|
60
|
+
// Python
|
|
61
|
+
"pypi.org", "www.pypi.org", "files.pythonhosted.org", "pythonhosted.org", "test.pypi.org", "pypi.python.org",
|
|
62
|
+
"pypa.io", "www.pypa.io",
|
|
63
|
+
// Ruby
|
|
64
|
+
"rubygems.org", "www.rubygems.org", "api.rubygems.org", "index.rubygems.org", "ruby-lang.org",
|
|
65
|
+
"www.ruby-lang.org", "rubyforge.org", "www.rubyforge.org", "rubyonrails.org", "www.rubyonrails.org", "rvm.io",
|
|
66
|
+
"get.rvm.io",
|
|
67
|
+
// Rust
|
|
68
|
+
"crates.io", "www.crates.io", "index.crates.io", "static.crates.io", "rustup.rs", "static.rust-lang.org",
|
|
69
|
+
"www.rust-lang.org",
|
|
70
|
+
// Go
|
|
71
|
+
"proxy.golang.org", "sum.golang.org", "index.golang.org", "golang.org", "www.golang.org", "goproxy.io", "pkg.go.dev",
|
|
72
|
+
// JVM
|
|
73
|
+
"maven.org", "repo.maven.org", "central.maven.org", "repo1.maven.org", "repo.maven.apache.org",
|
|
74
|
+
"maven.google.com", "jcenter.bintray.com", "gradle.org", "www.gradle.org", "services.gradle.org",
|
|
75
|
+
"plugins.gradle.org", "plugins-artifacts.gradle.org", "kotlinlang.org", "www.kotlinlang.org", "spring.io",
|
|
76
|
+
"repo.spring.io",
|
|
77
|
+
// Other package managers
|
|
78
|
+
"packagist.org", "www.packagist.org", "repo.packagist.org", "nuget.org", "www.nuget.org", "api.nuget.org",
|
|
79
|
+
"pub.dev", "api.pub.dev", "hex.pm", "www.hex.pm", "cpan.org", "www.cpan.org", "metacpan.org", "www.metacpan.org",
|
|
80
|
+
"api.metacpan.org", "cocoapods.org", "www.cocoapods.org", "cdn.cocoapods.org", "haskell.org", "www.haskell.org",
|
|
81
|
+
"hackage.haskell.org", "swift.org", "www.swift.org",
|
|
82
|
+
// Linux distributions — Anthropic's list, then Debian's (the runner image is Debian)
|
|
83
|
+
"archive.ubuntu.com", "security.ubuntu.com", "ubuntu.com", "www.ubuntu.com", "*.ubuntu.com",
|
|
84
|
+
"ppa.launchpad.net", "launchpad.net", "www.launchpad.net", "*.nixos.org",
|
|
85
|
+
"deb.debian.org", "security.debian.org", "snapshot.debian.org",
|
|
86
|
+
// Development tools and platforms
|
|
87
|
+
"dl.k8s.io", "pkgs.k8s.io", "k8s.io", "www.k8s.io", "releases.hashicorp.com", "apt.releases.hashicorp.com",
|
|
88
|
+
"rpm.releases.hashicorp.com", "archive.releases.hashicorp.com", "hashicorp.com", "www.hashicorp.com",
|
|
89
|
+
"repo.anaconda.com", "conda.anaconda.org", "anaconda.org", "www.anaconda.com", "anaconda.com", "continuum.io",
|
|
90
|
+
"apache.org", "www.apache.org", "archive.apache.org", "downloads.apache.org", "eclipse.org", "www.eclipse.org",
|
|
91
|
+
"download.eclipse.org", "nodejs.org", "www.nodejs.org", "developer.apple.com", "developer.android.com",
|
|
92
|
+
"pkg.stainless.com", "binaries.prisma.sh",
|
|
93
|
+
// Cloud services and monitoring
|
|
94
|
+
"http-intake.logs.datadoghq.com", "*.datadoghq.com", "*.datadoghq.eu", "api.honeycomb.io",
|
|
95
|
+
// Content delivery and mirrors
|
|
96
|
+
"sourceforge.net", "*.sourceforge.net", "packagecloud.io", "*.packagecloud.io", "fonts.googleapis.com",
|
|
97
|
+
"fonts.gstatic.com",
|
|
98
|
+
// Schema and configuration
|
|
99
|
+
"json-schema.org", "www.json-schema.org", "json.schemastore.org", "www.schemastore.org",
|
|
100
|
+
// Model Context Protocol
|
|
101
|
+
"*.modelcontextprotocol.io",
|
|
102
|
+
];
|
|
103
|
+
/** A site as `network:` takes it: a domain or `*.domain` — no scheme, no
|
|
104
|
+
* path, no port. The same shape `web.browse.allowed_domains` takes. */
|
|
105
|
+
export const SITE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
|
106
|
+
/** A host as it arrives — `API.Example.com:443.` — as the lists hold it. */
|
|
107
|
+
export function normalHost(host) {
|
|
108
|
+
let h = host.trim().toLowerCase();
|
|
109
|
+
if (h.startsWith("["))
|
|
110
|
+
return h.replace(/^\[|\](:\d+)?$/g, ""); // an IPv6 literal
|
|
111
|
+
h = h.replace(/:\d+$/, "");
|
|
112
|
+
return h.replace(/\.$/, "");
|
|
113
|
+
}
|
|
114
|
+
/** Does `site` (a domain or `*.domain`) cover `host`? `*.example.com`
|
|
115
|
+
* covers `a.example.com`, `a.b.example.com` and `example.com` itself. */
|
|
116
|
+
export function siteCovers(site, host) {
|
|
117
|
+
const h = normalHost(host);
|
|
118
|
+
const s = site.toLowerCase();
|
|
119
|
+
if (s.startsWith("*.")) {
|
|
120
|
+
const base = s.slice(2);
|
|
121
|
+
return h === base || h.endsWith(`.${base}`);
|
|
122
|
+
}
|
|
123
|
+
return h === s;
|
|
124
|
+
}
|
|
125
|
+
/** May a step under `policy` reach `host`? `open` reaches any public host —
|
|
126
|
+
* the caller still refuses private addresses, which no level opens. */
|
|
127
|
+
export function networkAllows(policy, host) {
|
|
128
|
+
if (policy.level === "open")
|
|
129
|
+
return true;
|
|
130
|
+
return policy.hosts.some((s) => siteCovers(s, host));
|
|
131
|
+
}
|
|
132
|
+
/** A list of sites from YAML's list or one comma-separated line. */
|
|
133
|
+
function siteList(v) {
|
|
134
|
+
if (typeof v === "string")
|
|
135
|
+
return v.split(",").map((x) => x.trim()).filter(Boolean);
|
|
136
|
+
if (Array.isArray(v) && v.every((x) => typeof x === "string"))
|
|
137
|
+
return v.map((x) => x.trim()).filter(Boolean);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* One `network:` value, read. Never throws: what cannot be read is a
|
|
142
|
+
* problem `check` and deploy report, and the run treats that level as
|
|
143
|
+
* saying nothing — the next level up, or the default, decides.
|
|
144
|
+
*/
|
|
145
|
+
export function readNetwork(raw, where = "network") {
|
|
146
|
+
if (raw === undefined || raw === null)
|
|
147
|
+
return { setting: null, problems: [] };
|
|
148
|
+
const problems = [];
|
|
149
|
+
const sites = (v, key) => {
|
|
150
|
+
const list = siteList(v);
|
|
151
|
+
if (!list) {
|
|
152
|
+
problems.push(`${key} is a list of sites, like [api.example.com, "*.example.org"].`);
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const s of list.map((x) => x.toLowerCase())) {
|
|
157
|
+
if (/^https?:\/\//.test(s) || s.includes("/"))
|
|
158
|
+
problems.push(`${key}: ${s} — a site is a domain, no scheme or path: ${s.replace(/^https?:\/\//, "").split("/")[0]}`);
|
|
159
|
+
else if (/:\d+$/.test(s))
|
|
160
|
+
problems.push(`${key}: ${s} — a site has no port; every port of the host is covered`);
|
|
161
|
+
else if (!SITE.test(s))
|
|
162
|
+
problems.push(`${key}: ${s} is not a domain or *.domain`);
|
|
163
|
+
else
|
|
164
|
+
out.push(s);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
const level = (v, key) => {
|
|
169
|
+
if (typeof v === "string" && NETWORK_LEVELS.includes(v.trim().toLowerCase())) {
|
|
170
|
+
return v.trim().toLowerCase();
|
|
171
|
+
}
|
|
172
|
+
problems.push(`${key} is ${NETWORK_LEVELS.join(", ")} — not ${JSON.stringify(v)}.`);
|
|
173
|
+
return undefined;
|
|
174
|
+
};
|
|
175
|
+
if (typeof raw === "string" && NETWORK_LEVELS.includes(raw.trim().toLowerCase())) {
|
|
176
|
+
return { setting: { level: raw.trim().toLowerCase(), allow: [] }, problems };
|
|
177
|
+
}
|
|
178
|
+
if (typeof raw === "string" || Array.isArray(raw)) {
|
|
179
|
+
// A bare word that is not a level reads as a site only when it looks
|
|
180
|
+
// like one — `network: closed` is a typo, not a domain called "closed".
|
|
181
|
+
if (typeof raw === "string" && !raw.includes(".") && !raw.includes(",")) {
|
|
182
|
+
return { setting: null, problems: [`${where} is ${NETWORK_LEVELS.join(", ")}, a list of sites, or {level, allow, defaults} — not "${raw}".`] };
|
|
183
|
+
}
|
|
184
|
+
const allow = sites(raw, where);
|
|
185
|
+
return { setting: problems.length && !allow.length ? null : { allow }, problems };
|
|
186
|
+
}
|
|
187
|
+
if (typeof raw === "object") {
|
|
188
|
+
const o = raw;
|
|
189
|
+
for (const k of Object.keys(o)) {
|
|
190
|
+
if (!["level", "allow", "defaults"].includes(k))
|
|
191
|
+
problems.push(`${where}.${k} is not a key — level, allow, defaults.`);
|
|
192
|
+
}
|
|
193
|
+
const setting = { allow: [] };
|
|
194
|
+
if (o.level !== undefined)
|
|
195
|
+
setting.level = level(o.level, `${where}.level`);
|
|
196
|
+
if (o.allow !== undefined)
|
|
197
|
+
setting.allow = sites(o.allow, `${where}.allow`);
|
|
198
|
+
if (o.defaults !== undefined) {
|
|
199
|
+
if (o.defaults === true || o.defaults === false)
|
|
200
|
+
setting.defaults = o.defaults;
|
|
201
|
+
else
|
|
202
|
+
problems.push(`${where}.defaults is true or false — false drops the trusted list.`);
|
|
203
|
+
}
|
|
204
|
+
return { setting, problems };
|
|
205
|
+
}
|
|
206
|
+
return { setting: null, problems: [`${where} is ${NETWORK_LEVELS.join(", ")}, a list of sites, or {level, allow, defaults}.`] };
|
|
207
|
+
}
|
|
208
|
+
/** `check`'s and deploy's view: the problems only. */
|
|
209
|
+
export function networkProblems(raw) {
|
|
210
|
+
return readNetwork(raw).problems;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The step's network, from its three levels and what it declares.
|
|
214
|
+
*
|
|
215
|
+
* `readsWeb` — the step may fetch or browse the open web (the `web` tool
|
|
216
|
+
* with fetch or browse). It decides the default level only; a `network:`
|
|
217
|
+
* written anywhere beats it.
|
|
218
|
+
*/
|
|
219
|
+
export function resolveNetwork(args) {
|
|
220
|
+
const levels = [
|
|
221
|
+
["agent", readNetwork(args.agent).setting],
|
|
222
|
+
["workspace", readNetwork(args.workspace).setting],
|
|
223
|
+
["account", readNetwork(args.account).setting],
|
|
224
|
+
];
|
|
225
|
+
const levelHit = levels.find(([, s]) => s?.level);
|
|
226
|
+
const level = levelHit ? levelHit[1].level : args.readsWeb ? "open" : "trusted";
|
|
227
|
+
const from = levelHit ? levelHit[0] : args.readsWeb ? "default — the agent reads the web" : "default";
|
|
228
|
+
const defaultsHit = levels.find(([, s]) => s?.defaults !== undefined);
|
|
229
|
+
const defaults = level === "trusted" && (defaultsHit ? defaultsHit[1].defaults : true);
|
|
230
|
+
const added = [...new Set(levels.flatMap(([, s]) => s?.allow ?? []))];
|
|
231
|
+
const implied = [...new Set([...(args.implied ?? [])].map(normalHost).filter((h) => h && SITE.test(h)))];
|
|
232
|
+
const hosts = level === "open" ? [] : [...new Set([...(defaults ? TRUSTED_SITES : []), ...added, ...implied])];
|
|
233
|
+
return { level, hosts, from, defaults, added, implied };
|
|
234
|
+
}
|
|
235
|
+
/** One line for the run's trace. */
|
|
236
|
+
export function networkLine(policy, enforced) {
|
|
237
|
+
const how = enforced === null
|
|
238
|
+
? " — not enforced here (no sandbox network)"
|
|
239
|
+
: enforced
|
|
240
|
+
? ""
|
|
241
|
+
: " — report only on this install: a site off the list is allowed and named in the trace";
|
|
242
|
+
if (policy.level === "open")
|
|
243
|
+
return `network: open (${policy.from}) — any public site${how}`;
|
|
244
|
+
const parts = [];
|
|
245
|
+
if (policy.defaults)
|
|
246
|
+
parts.push(`the trusted list (${TRUSTED_SITES.length} sites)`);
|
|
247
|
+
if (policy.added.length)
|
|
248
|
+
parts.push(`added: ${policy.added.join(", ")}`);
|
|
249
|
+
if (policy.implied.length)
|
|
250
|
+
parts.push(`declared by the step: ${policy.implied.join(", ")}`);
|
|
251
|
+
const what = parts.length ? parts.join("; ") : "nothing but the platform's own services";
|
|
252
|
+
return `network: ${policy.level} (${policy.from}) — ${what}${how}`;
|
|
253
|
+
}
|
|
254
|
+
/** What the proxy says when it refuses a site. */
|
|
255
|
+
export function refusalText(policy, host) {
|
|
256
|
+
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.`;
|
|
257
|
+
}
|
|
258
|
+
/** The proxy variables a sandbox's programs read, for a forward-proxy URL
|
|
259
|
+
* (`http://foldrun:<token>@host:port`). Both spellings: curl reads only the
|
|
260
|
+
* lower-case http_proxy, much else only the upper-case. NO_PROXY keeps the
|
|
261
|
+
* cluster's own names — the egress address itself, the account's pods —
|
|
262
|
+
* off the proxy. NODE_USE_ENV_PROXY turns the variables on for Node's own
|
|
263
|
+
* fetch and http (Node 22.21 and later). */
|
|
264
|
+
export function proxyEnv(proxyUrl, extraNoProxy = []) {
|
|
265
|
+
let egressHost = "";
|
|
266
|
+
try {
|
|
267
|
+
egressHost = new URL(proxyUrl).hostname;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// a malformed URL is still exported as given; the client says why it fails
|
|
271
|
+
}
|
|
272
|
+
const noProxy = [...new Set(["localhost", "127.0.0.1", "::1", ".svc", ".cluster.local", egressHost, ...extraNoProxy].filter(Boolean))].join(",");
|
|
273
|
+
return {
|
|
274
|
+
HTTP_PROXY: proxyUrl,
|
|
275
|
+
HTTPS_PROXY: proxyUrl,
|
|
276
|
+
http_proxy: proxyUrl,
|
|
277
|
+
https_proxy: proxyUrl,
|
|
278
|
+
NO_PROXY: noProxy,
|
|
279
|
+
no_proxy: noProxy,
|
|
280
|
+
NODE_USE_ENV_PROXY: "1",
|
|
281
|
+
// Node 22 warns "EnvHttpProxyAgent is experimental" on the first request
|
|
282
|
+
// of every process; a script tool's stderr then reads as an error to the
|
|
283
|
+
// model. That one warning, and no other.
|
|
284
|
+
NODE_OPTIONS: "--disable-warning=UNDICI-EHPA",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** The env var the egress CA travels in — its PEM, base64'd so it survives
|
|
288
|
+
* every env channel (docker's env file has no multi-line values). The
|
|
289
|
+
* pod's start writes it to a file and points every TLS client at a bundle
|
|
290
|
+
* that includes it. */
|
|
291
|
+
export const EGRESS_CA_ENV = "FOLDRUN_EGRESS_CA";
|
|
292
|
+
/**
|
|
293
|
+
* The shell the sandbox's start runs before anything else: when the step
|
|
294
|
+
* carries the egress CA, write it, build a bundle of the system's CAs plus
|
|
295
|
+
* it, and point every TLS client the image has at that bundle. The CA only
|
|
296
|
+
* ever signs for hosts a secret is bound to — every other connection is a
|
|
297
|
+
* tunnel the proxy cannot read — but those clients must trust it there.
|
|
298
|
+
* Sourced after the env file, before system packages install (apt goes
|
|
299
|
+
* through the proxy too). `dir` is a directory the start may write.
|
|
300
|
+
*/
|
|
301
|
+
export function egressCaSh(dir, systemBundle = "/etc/ssl/certs/ca-certificates.crt") {
|
|
302
|
+
// Without the system's bundle to add to, a bundle of the egress CA alone
|
|
303
|
+
// would make every other site untrusted — so only Node, which ADDS the
|
|
304
|
+
// file to its own roots, is told; nothing else changes.
|
|
305
|
+
return `if [ -n "\${${EGRESS_CA_ENV}:-}" ] && printf '%s' "\$${EGRESS_CA_ENV}" | base64 -d > ${dir}/egress-ca.pem 2>/dev/null; then
|
|
306
|
+
if [ -s ${systemBundle} ] && cat ${systemBundle} ${dir}/egress-ca.pem > ${dir}/ca-bundle.pem; then
|
|
307
|
+
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
|
|
308
|
+
fi
|
|
309
|
+
export NODE_EXTRA_CA_CERTS=${dir}/egress-ca.pem
|
|
310
|
+
fi`;
|
|
311
|
+
}
|
|
312
|
+
/** A site as a YAML flow item: `*.x` would read as an alias, so it is quoted. */
|
|
313
|
+
const siteItem = (site) => (/^[a-z0-9]/.test(site) ? site : JSON.stringify(site));
|
|
314
|
+
/** `network:` as one line of YAML, the shortest of the three shapes that
|
|
315
|
+
* says it; null when there is nothing to say. */
|
|
316
|
+
export function networkYaml(setting) {
|
|
317
|
+
if (!setting)
|
|
318
|
+
return null;
|
|
319
|
+
const allow = [...new Set((setting.allow ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean))];
|
|
320
|
+
const list = `[${allow.map(siteItem).join(", ")}]`;
|
|
321
|
+
if (setting.defaults === false) {
|
|
322
|
+
return `{ ${[setting.level ? `level: ${setting.level}` : "", allow.length ? `allow: ${list}` : "", "defaults: false"].filter(Boolean).join(", ")} }`;
|
|
323
|
+
}
|
|
324
|
+
if (setting.level && allow.length)
|
|
325
|
+
return `{ level: ${setting.level}, allow: ${list} }`;
|
|
326
|
+
if (setting.level)
|
|
327
|
+
return setting.level;
|
|
328
|
+
if (allow.length)
|
|
329
|
+
return list;
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The text of an agent.md (or AGENTS.md) with its `network:` replaced by
|
|
334
|
+
* `setting` — or removed, for null — and every other byte as it was. The
|
|
335
|
+
* old value's indented lines go with it, whatever shape it was written in.
|
|
336
|
+
* Pure string work, so the dashboard runs it in the browser and the CLI on
|
|
337
|
+
* a file, and both write exactly the same line.
|
|
338
|
+
*/
|
|
339
|
+
export function setNetworkSetting(raw, setting) {
|
|
340
|
+
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
341
|
+
const lines = raw.split(eol);
|
|
342
|
+
const value = networkYaml(setting);
|
|
343
|
+
const line = value ? `network: ${value}` : null;
|
|
344
|
+
if (lines[0]?.trim() !== "---")
|
|
345
|
+
return line ? ["---", line, "---", ...lines].join(eol) : raw;
|
|
346
|
+
const close = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
|
|
347
|
+
if (close < 0)
|
|
348
|
+
return raw;
|
|
349
|
+
const at = lines.findIndex((l, i) => i > 0 && i < close && /^network\s*:/.test(l));
|
|
350
|
+
if (at < 0) {
|
|
351
|
+
if (line)
|
|
352
|
+
lines.splice(close, 0, line);
|
|
353
|
+
return lines.join(eol);
|
|
354
|
+
}
|
|
355
|
+
let end = at + 1;
|
|
356
|
+
while (end < close && (/^\s+\S/.test(lines[end]) || /^\s*-\s/.test(lines[end])))
|
|
357
|
+
end++;
|
|
358
|
+
lines.splice(at, end - at, ...(line ? [line] : []));
|
|
359
|
+
return lines.join(eol);
|
|
360
|
+
}
|
package/dist/src/notify.d.ts
CHANGED
|
@@ -46,6 +46,14 @@ export interface NotifyConfig {
|
|
|
46
46
|
signingSecret?: string;
|
|
47
47
|
}
|
|
48
48
|
export declare function notifyConfig(tenant: string, workspace: string): NotifyConfig | null;
|
|
49
|
+
/**
|
|
50
|
+
* The version of the webhook body's shape, sent on every delivery as
|
|
51
|
+
* `x-foldrun-webhook-version` (and in the user agent). Within a version the
|
|
52
|
+
* body only gains fields — a receiver must ignore the ones it does not know.
|
|
53
|
+
* A field removed, renamed or retyped is a new version, announced in What's
|
|
54
|
+
* new at least six months before the old shape stops being sent.
|
|
55
|
+
*/
|
|
56
|
+
export declare const WEBHOOK_PAYLOAD_VERSION = 1;
|
|
49
57
|
/**
|
|
50
58
|
* The headers that prove a delivery came from this install.
|
|
51
59
|
*
|
package/dist/src/notify.js
CHANGED
|
@@ -120,6 +120,14 @@ export function notifyConfig(tenant, workspace) {
|
|
|
120
120
|
// Said once per process, not once per run: the fix is one env edit, and a
|
|
121
121
|
// line per parked run would bury the failures around it.
|
|
122
122
|
let warnedNoPublicUrl = false;
|
|
123
|
+
/**
|
|
124
|
+
* The version of the webhook body's shape, sent on every delivery as
|
|
125
|
+
* `x-foldrun-webhook-version` (and in the user agent). Within a version the
|
|
126
|
+
* body only gains fields — a receiver must ignore the ones it does not know.
|
|
127
|
+
* A field removed, renamed or retyped is a new version, announced in What's
|
|
128
|
+
* new at least six months before the old shape stops being sent.
|
|
129
|
+
*/
|
|
130
|
+
export const WEBHOOK_PAYLOAD_VERSION = 1;
|
|
123
131
|
/**
|
|
124
132
|
* The headers that prove a delivery came from this install.
|
|
125
133
|
*
|
|
@@ -205,7 +213,8 @@ export async function attemptWebhook(tenant, workspace, d) {
|
|
|
205
213
|
method: "POST",
|
|
206
214
|
headers: {
|
|
207
215
|
"content-type": "application/json",
|
|
208
|
-
"user-agent":
|
|
216
|
+
"user-agent": `foldrun-webhooks/${WEBHOOK_PAYLOAD_VERSION}`,
|
|
217
|
+
"x-foldrun-webhook-version": String(WEBHOOK_PAYLOAD_VERSION),
|
|
209
218
|
"x-foldrun-delivery": d.id,
|
|
210
219
|
"x-foldrun-event": d.event,
|
|
211
220
|
"x-foldrun-attempt": String(d.attempt),
|
package/dist/src/providers.d.ts
CHANGED
|
@@ -346,6 +346,11 @@ export interface BrowseSettings {
|
|
|
346
346
|
ignore_https_errors?: boolean;
|
|
347
347
|
/** The NAME of a vault secret whose value encrypts state= logins at rest. */
|
|
348
348
|
state_key?: string;
|
|
349
|
+
/** A saved login every call uses by default: loaded from
|
|
350
|
+
* workspace/state/browser/<name>.json and saved back when the call ends.
|
|
351
|
+
* Beside `cookies:` (with `state_key:`), the secret seeds it once and the
|
|
352
|
+
* session the site keeps rotating is what later runs load. */
|
|
353
|
+
state?: string;
|
|
349
354
|
/** true records every call, so the run page always has the recording to play. */
|
|
350
355
|
video?: boolean;
|
|
351
356
|
/** false stops the tool streaming its page to the run page while the step
|
package/dist/src/providers.js
CHANGED
|
@@ -352,7 +352,7 @@ export function browseEngineRefusal(engine, what) {
|
|
|
352
352
|
}
|
|
353
353
|
const BROWSE_SETTING_KEYS = [
|
|
354
354
|
"engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "identities", "headless", "version", "live",
|
|
355
|
-
"allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key",
|
|
355
|
+
"allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key", "state",
|
|
356
356
|
"video", "live_view", "vendor_session",
|
|
357
357
|
// Deprecated spelling of vendor_session (read, and warned about).
|
|
358
358
|
"session",
|
|
@@ -696,6 +696,12 @@ export function readBrowseSettings(raw) {
|
|
|
696
696
|
settings[k] = list;
|
|
697
697
|
continue;
|
|
698
698
|
}
|
|
699
|
+
if (k === "state") {
|
|
700
|
+
if (typeof v !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(v))
|
|
701
|
+
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." };
|
|
702
|
+
settings.state = v;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
699
705
|
if (k === "state_key") {
|
|
700
706
|
if (typeof v !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(v))
|
|
701
707
|
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:." };
|
|
@@ -762,6 +768,20 @@ export function readBrowseSettings(raw) {
|
|
|
762
768
|
`would be sent to whatever site the call opens. Write \`cookie_domain: .example.com\`.`,
|
|
763
769
|
};
|
|
764
770
|
}
|
|
771
|
+
// A secret's cookies seeding a saved login: the login then sits in state/,
|
|
772
|
+
// a workspace file, so it must be encrypted with a key from the vault.
|
|
773
|
+
if (settings.state && settings.cookies && !settings.state_key) {
|
|
774
|
+
return {
|
|
775
|
+
settings,
|
|
776
|
+
rest: left(rest),
|
|
777
|
+
error: `web.browse.state beside cookies needs web.browse.state_key — the secret's session would be saved ` +
|
|
778
|
+
`to state/ where any step can read it. Store a key (\`foldrun secrets set BROWSER_STATE_KEY\`), ` +
|
|
779
|
+
`write \`state_key: BROWSER_STATE_KEY\` and declare it under secrets:.`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (settings.state && settings.storage) {
|
|
783
|
+
return { settings, rest: left(rest), error: "web.browse.state cannot be combined with storage: a secret's tokens would be written to state/." };
|
|
784
|
+
}
|
|
765
785
|
// Storage is per origin, and seeding it into the wrong one would hand a
|
|
766
786
|
// site another site's signed-in state — the same mistake `cookie_domain`
|
|
767
787
|
// exists to prevent, one storage area over.
|