@foldrun/core 2.6.0 → 2.7.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/dist/src/capture.js +39 -1
- package/dist/src/egress.d.ts +16 -0
- package/dist/src/run-container.d.ts +1 -1
- package/dist/src/run-container.js +30 -0
- package/dist/src/runner.js +74 -3
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- package/package.json +1 -1
- package/src/capture.ts +33 -1
- package/src/egress.ts +15 -1
- package/src/run-container.ts +30 -0
- package/src/runner.ts +71 -3
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
package/dist/src/capture.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// secret on the cap's edge is never kept half. Recording never fails the step.
|
|
7
7
|
import { redactText } from "./scan.js";
|
|
8
8
|
import { writeCapturedCall, CAPTURE_CAP } from "./store.js";
|
|
9
|
-
import { isFileValue, fileContent } from "./secrets.js";
|
|
9
|
+
import { isFileValue, fileContent, isApiValue, apiConfigOf, isClientCertValue, clientCertOf } from "./secrets.js";
|
|
10
10
|
/** How much past the cap a step sends, so masking sees a secret whole
|
|
11
11
|
* before the cut (a value longer than this is a file secret, which is also
|
|
12
12
|
* masked line by line). */
|
|
@@ -29,6 +29,44 @@ export function redactionsFor(secrets) {
|
|
|
29
29
|
for (const [name, raw] of Object.entries(secrets)) {
|
|
30
30
|
if (typeof raw !== "string")
|
|
31
31
|
continue;
|
|
32
|
+
// An API connection's header values are each a secret in their own right
|
|
33
|
+
// (a token, Basic credentials) — and a Basic header's password too.
|
|
34
|
+
// An exported client certificate's passphrase and private key reach the
|
|
35
|
+
// sandbox as files: each is masked, whole and line by line.
|
|
36
|
+
if (isClientCertValue(raw)) {
|
|
37
|
+
const c = clientCertOf(raw);
|
|
38
|
+
for (const v of [c.passphrase, c.key]) {
|
|
39
|
+
if (!v)
|
|
40
|
+
continue;
|
|
41
|
+
add(v, name);
|
|
42
|
+
add(v.trim(), name);
|
|
43
|
+
const escaped = JSON.stringify(v).slice(1, -1);
|
|
44
|
+
if (escaped !== v)
|
|
45
|
+
add(escaped, name);
|
|
46
|
+
for (const line of v.split(/\r?\n/))
|
|
47
|
+
if (line.trim().length >= 16)
|
|
48
|
+
add(line.trim(), name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (isApiValue(raw)) {
|
|
52
|
+
// Each as written and as JSON would escape it, like every value here.
|
|
53
|
+
const both = (v) => {
|
|
54
|
+
add(v, name);
|
|
55
|
+
const escaped = JSON.stringify(v).slice(1, -1);
|
|
56
|
+
if (escaped !== v)
|
|
57
|
+
add(escaped, name);
|
|
58
|
+
};
|
|
59
|
+
for (const v of Object.values(apiConfigOf(raw).headers ?? {})) {
|
|
60
|
+
both(v);
|
|
61
|
+
const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(v.trim());
|
|
62
|
+
if (basic) {
|
|
63
|
+
both(basic[1]);
|
|
64
|
+
const decoded = Buffer.from(basic[1], "base64").toString("utf8");
|
|
65
|
+
if (decoded.includes(":"))
|
|
66
|
+
both(decoded.slice(decoded.indexOf(":") + 1));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
32
70
|
for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
|
|
33
71
|
add(v, name);
|
|
34
72
|
add(v.trim(), name);
|
package/dist/src/egress.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export declare function hostOf(url: string): string | null;
|
|
|
27
27
|
* from the step's declared secrets and the hosts its tools and provider
|
|
28
28
|
* name; the proxy enforces it per request.
|
|
29
29
|
*/
|
|
30
|
+
/** Which workload a lease is for — the runner's word, never the pod's: what
|
|
31
|
+
* an identity token's subject is built from (oidc-issuer.ts). */
|
|
32
|
+
export interface LeaseWho {
|
|
33
|
+
workspace: string;
|
|
34
|
+
agent: string;
|
|
35
|
+
flow?: string;
|
|
36
|
+
}
|
|
30
37
|
export interface EgressGrant {
|
|
31
38
|
/** Secret name → the hosts that secret may be substituted for. */
|
|
32
39
|
secrets: Record<string, {
|
|
@@ -53,6 +60,11 @@ export interface EgressGrant {
|
|
|
53
60
|
/** Refuse a site off the list (true), or allow it and name it in the
|
|
54
61
|
* trace (false: report only). Set by the platform when it leases. */
|
|
55
62
|
enforce?: boolean;
|
|
63
|
+
/** Identity name → the audience its tokens are for: the only identities
|
|
64
|
+
* this step may ask the proxy's /identity door to mint (oidc-issuer.ts). */
|
|
65
|
+
identities?: Record<string, {
|
|
66
|
+
audience: string;
|
|
67
|
+
}>;
|
|
56
68
|
}
|
|
57
69
|
export interface EgressLease {
|
|
58
70
|
/** The base the pod sends through: `http://…/e/<token>`. */
|
|
@@ -105,7 +117,11 @@ export interface EgressHooks {
|
|
|
105
117
|
step?: number;
|
|
106
118
|
agent?: string;
|
|
107
119
|
peers?: PeerInfo[];
|
|
120
|
+
who?: LeaseWho;
|
|
108
121
|
}): Promise<EgressLease | null>;
|
|
122
|
+
/** The run is still being driven: renew its leases' heartbeat. Optional —
|
|
123
|
+
* an install without leases has nothing to renew. */
|
|
124
|
+
renew?(runId: string): Promise<number>;
|
|
109
125
|
/** The step is over — every attempt, however it ended (tools: [peers]).
|
|
110
126
|
* A lease comes and goes per attempt, so it cannot say this: a sibling
|
|
111
127
|
* between retries, or waiting for a slot, holds none and is not done.
|
|
@@ -289,7 +289,7 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
|
|
|
289
289
|
target: string;
|
|
290
290
|
} | null;
|
|
291
291
|
/** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
|
|
292
|
-
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
292
|
+
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
293
293
|
/** The uv release copied into the image. Pinned: a floating uv is a floating
|
|
294
294
|
* resolver, and the runtime cache is keyed on the declaration, not on it. */
|
|
295
295
|
export declare const UV_VERSION = "0.11.25";
|
|
@@ -577,6 +577,36 @@ try {
|
|
|
577
577
|
const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
|
|
578
578
|
const inbox = channel ? inboxReader(channel, emit) : undefined;
|
|
579
579
|
|
|
580
|
+
// Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
|
|
581
|
+
// fresh through the proxy's /identity door, which mints only what this
|
|
582
|
+
// step's lease was granted. Fetched before the step starts, renewed five
|
|
583
|
+
// minutes before it expires (with jitter); a failure is said and retried.
|
|
584
|
+
// Plain JS: this text is the driver, not TypeScript.
|
|
585
|
+
const identityNames = (env.FOLDRUN_IDENTITIES || "").split(",").filter(Boolean);
|
|
586
|
+
if (identityNames.length) {
|
|
587
|
+
// No backslashes: this sits inside a template literal, which eats them.
|
|
588
|
+
const ep = new RegExp("^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$").exec(env.FOLDRUN_EGRESS || "");
|
|
589
|
+
const dir = env.FOLDRUN_IDENTITY_DIR || "/tmp/foldrun-identity";
|
|
590
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
591
|
+
const keepFresh = async (name) => {
|
|
592
|
+
let wait = 30000;
|
|
593
|
+
try {
|
|
594
|
+
if (!ep) throw new Error("no egress proxy to ask");
|
|
595
|
+
const res = await fetch(ep[1] + "/identity/" + ep[2] + "/" + name, { signal: AbortSignal.timeout(15000) });
|
|
596
|
+
const body = await res.json().catch(() => ({}));
|
|
597
|
+
if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || ("proxy answered " + res.status));
|
|
598
|
+
const file = dir + "/" + name.toLowerCase() + ".jwt";
|
|
599
|
+
fs.writeFileSync(file + ".tmp", body.token, { mode: 0o600 });
|
|
600
|
+
fs.renameSync(file + ".tmp", file);
|
|
601
|
+
wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
emit("error", "identity " + name + ": " + (err && err.message ? err.message : String(err)) + " — trying again in 30s");
|
|
604
|
+
}
|
|
605
|
+
setTimeout(() => { void keepFresh(name); }, wait).unref();
|
|
606
|
+
};
|
|
607
|
+
await Promise.all(identityNames.map(keepFresh));
|
|
608
|
+
}
|
|
609
|
+
|
|
580
610
|
mark("tools");
|
|
581
611
|
const outcome = await executeStep({
|
|
582
612
|
agentDir,
|
package/dist/src/runner.js
CHANGED
|
@@ -41,7 +41,7 @@ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowl
|
|
|
41
41
|
import { canReadBack } from "./tool-output.js";
|
|
42
42
|
import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
|
|
43
43
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
|
|
44
|
-
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
|
|
44
|
+
import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.js";
|
|
45
45
|
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
46
46
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
47
47
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
|
|
@@ -1348,7 +1348,8 @@ grantsOnly = false) {
|
|
|
1348
1348
|
// the agent, and the date the platform believes it is.
|
|
1349
1349
|
{
|
|
1350
1350
|
...runtime.env,
|
|
1351
|
-
|
|
1351
|
+
// An unexported client certificate is the proxy's, never a script's.
|
|
1352
|
+
...withoutHeldCerts(secretEnv),
|
|
1352
1353
|
...(identity.runId ? { FOLDRUN_RUN_ID: identity.runId } : {}),
|
|
1353
1354
|
...(identity.agent ? { FOLDRUN_AGENT: identity.agent } : {}),
|
|
1354
1355
|
// And the workspace: a commit or a record made by a shared library
|
|
@@ -1618,6 +1619,9 @@ grantsOnly = false) {
|
|
|
1618
1619
|
// asked for by name fails inside the container with "cannot find" while
|
|
1619
1620
|
// the host path, which uses this same merged spec, works fine.
|
|
1620
1621
|
runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
|
|
1622
|
+
// Whole: a bound client certificate goes from here into the egress
|
|
1623
|
+
// grant (runStep), which is where it is presented. Programs get theirs
|
|
1624
|
+
// filtered (withoutHeldCerts) where their environments are built.
|
|
1621
1625
|
secretEnv: { ...runtime.env, ...secretEnv },
|
|
1622
1626
|
formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
|
|
1623
1627
|
providerEnv,
|
|
@@ -2490,11 +2494,26 @@ peerBus) {
|
|
|
2490
2494
|
// to refuse; the proxy adds the install's own switch and has the last
|
|
2491
2495
|
// word (egress-proxy.ts), so false here never loosens anything.
|
|
2492
2496
|
grant.enforce = networkEnforceWanted(readAgentsMd(accountDir(tenant))?.data?.network_enforce, readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce);
|
|
2497
|
+
// Keyless cloud identities the step declared: the proxy may mint tokens
|
|
2498
|
+
// for these audiences only, for this workload (oidc-issuer.ts).
|
|
2499
|
+
// A test run withholds them as it withholds every credential
|
|
2500
|
+
// (test-mode.ts): a token is a credential, however it is delivered.
|
|
2501
|
+
const declaredIdentities = Object.entries(liveSecrets).filter(([, v]) => typeof v === "string" && isIdentityValue(v));
|
|
2502
|
+
const identitiesHeld = testRun
|
|
2503
|
+
? withholdSecrets(Object.fromEntries(declaredIdentities), {
|
|
2504
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2505
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2506
|
+
}).withheld
|
|
2507
|
+
: [];
|
|
2508
|
+
const identities = Object.fromEntries(declaredIdentities.filter(([name]) => !identitiesHeld.includes(name)).map(([name, v]) => [name, { audience: identityOf(v).audience }]));
|
|
2509
|
+
if (Object.keys(identities).length)
|
|
2510
|
+
grant.identities = identities;
|
|
2493
2511
|
const lease = await platform.egress.lease({
|
|
2494
2512
|
tenant,
|
|
2495
2513
|
runId: runId ?? "adhoc",
|
|
2496
2514
|
grant,
|
|
2497
2515
|
test: testRun,
|
|
2516
|
+
who: { workspace: workspaceName, agent: step.agent },
|
|
2498
2517
|
...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
|
|
2499
2518
|
// tools: [peers] — the roster the proxy serves, and the inboxes a
|
|
2500
2519
|
// colleague's message may be addressed to. Only when granted: a
|
|
@@ -2565,6 +2584,38 @@ peerBus) {
|
|
|
2565
2584
|
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2566
2585
|
}
|
|
2567
2586
|
}
|
|
2587
|
+
// A keyless identity holds nothing to hand over: the sandbox gets the
|
|
2588
|
+
// path of a token file the driver keeps fresh through the proxy's
|
|
2589
|
+
// /identity door. Without a lease there is no door, and so no token.
|
|
2590
|
+
const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]));
|
|
2591
|
+
for (const name of identityDeclared)
|
|
2592
|
+
delete sandboxSecrets[name];
|
|
2593
|
+
for (const name of identitiesHeld) {
|
|
2594
|
+
push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2595
|
+
}
|
|
2596
|
+
const identityNames = identityDeclared.filter((name) => !identitiesHeld.includes(name));
|
|
2597
|
+
if (identityNames.length) {
|
|
2598
|
+
if (lease) {
|
|
2599
|
+
const dir = "/tmp/foldrun-identity";
|
|
2600
|
+
sandboxSecrets.FOLDRUN_IDENTITIES = identityNames.join(",");
|
|
2601
|
+
sandboxSecrets.FOLDRUN_IDENTITY_DIR = dir;
|
|
2602
|
+
for (const name of identityNames)
|
|
2603
|
+
sandboxSecrets[`${name}_FILE`] = `${dir}/${name.toLowerCase()}.jwt`;
|
|
2604
|
+
push("info", `identity: ${identityNames.join(", ")} — a short-lived token in $NAME_FILE, renewed by the platform while the step runs`);
|
|
2605
|
+
}
|
|
2606
|
+
else {
|
|
2607
|
+
push("error", `identity: ${identityNames.join(", ")} needs a sandboxed step on the platform — no token here`);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
// A client certificate is the proxy's to present, to the exact sites it
|
|
2611
|
+
// is bound to; never the sandbox's, unless it was saved with `export`.
|
|
2612
|
+
for (const name of declared) {
|
|
2613
|
+
const v = liveSecrets[name];
|
|
2614
|
+
if (typeof v !== "string" || !isClientCertValue(v) || bound.includes(name) || clientCertOf(v).export)
|
|
2615
|
+
continue;
|
|
2616
|
+
delete sandboxSecrets[name];
|
|
2617
|
+
push("info", `secrets: ${name} is a client certificate — the egress proxy presents it to the sites it is bound to (\`foldrun secrets sites ${name} <host>\`); unbound, it stays out of the sandbox`);
|
|
2618
|
+
}
|
|
2568
2619
|
// On a test run the http tools always go through the proxy as
|
|
2569
2620
|
// placeholders, even when a script made the step materialise: the
|
|
2570
2621
|
// proxy is where the outward-write policy lives, and a header
|
|
@@ -2966,7 +3017,19 @@ peerBus) {
|
|
|
2966
3017
|
for (const name of held.withheld) {
|
|
2967
3018
|
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
2968
3019
|
}
|
|
2969
|
-
|
|
3020
|
+
// A client certificate not saved with `export` has no proxy here to
|
|
3021
|
+
// present it: it stays out of everything this step runs — tools,
|
|
3022
|
+
// consults, verify commands — not only out of the files.
|
|
3023
|
+
const stepSecrets = Object.fromEntries(Object.entries(held.env).filter(([name, v]) => {
|
|
3024
|
+
if (typeof v === "string" && isIdentityValue(v)) {
|
|
3025
|
+
push("error", `identity: ${name} needs a sandboxed step on the platform — no token here`);
|
|
3026
|
+
return false;
|
|
3027
|
+
}
|
|
3028
|
+
if (typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export)
|
|
3029
|
+
return true;
|
|
3030
|
+
push("info", `secrets: ${name} is a client certificate — no egress proxy here to present it; save it with export to use it as files`);
|
|
3031
|
+
return false;
|
|
3032
|
+
}));
|
|
2970
3033
|
// The model credential for this machine's Claude Code — the agent's
|
|
2971
3034
|
// own provider when it names one. The host allowlist drops it, so
|
|
2972
3035
|
// without this every in-process step ran on the machine's login.
|
|
@@ -3804,7 +3867,15 @@ async function harvestQuietly(tenant, workspace, run) {
|
|
|
3804
3867
|
export const drivingRuns = new Set();
|
|
3805
3868
|
export function driveRun(tenant, workspace, run, modelOverride, tags = [], opts = {}) {
|
|
3806
3869
|
drivingRuns.add(run.id);
|
|
3870
|
+
// The run's egress leases are renewed while it is driven, by whichever
|
|
3871
|
+
// door drove it — the queue, or a reconcile that picked it back up — so an
|
|
3872
|
+
// identity token is renewed for a live step however it was started.
|
|
3873
|
+
const renew = platform.egress.renew;
|
|
3874
|
+
const beat = renew ? setInterval(() => void renew(run.id).catch(() => undefined), 5 * 60_000) : null;
|
|
3875
|
+
beat?.unref?.();
|
|
3807
3876
|
return driveRunInner(tenant, workspace, run, modelOverride, tags, opts).finally(() => {
|
|
3877
|
+
if (beat)
|
|
3878
|
+
clearInterval(beat);
|
|
3808
3879
|
drivingRuns.delete(run.id);
|
|
3809
3880
|
});
|
|
3810
3881
|
}
|
package/dist/src/secret-files.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// deleted the keys the other was still using.
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import { isFileValue, fileContent, isSshValue, sshConfigOf, isApiValue, apiConfigOf, } from "./secrets.js";
|
|
20
|
+
import { isFileValue, fileContent, isSshValue, sshConfigOf, isApiValue, apiConfigOf, isClientCertValue, clientCertOf, } from "./secrets.js";
|
|
21
21
|
// POSIX single-quoting: the one escape sh actually honours inside ' is none,
|
|
22
22
|
// so close, backslash-quote, reopen. Wrapper scripts embed hosts, users and
|
|
23
23
|
// header values through this and nothing else.
|
|
@@ -34,7 +34,7 @@ function writeWrapper(dir, name, body) {
|
|
|
34
34
|
* Plain values pass through untouched.
|
|
35
35
|
*/
|
|
36
36
|
export function materializeFileSecrets(agentDir, env) {
|
|
37
|
-
const entries = Object.entries(env).filter(([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v)));
|
|
37
|
+
const entries = Object.entries(env).filter(([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v) || isClientCertValue(v)));
|
|
38
38
|
if (entries.length === 0)
|
|
39
39
|
return { env, dir: null };
|
|
40
40
|
const parent = path.join(agentDir, ".secret-files");
|
|
@@ -50,6 +50,27 @@ export function materializeFileSecrets(agentDir, env) {
|
|
|
50
50
|
fs.writeFileSync(file, fileContent(value), { mode: 0o600 });
|
|
51
51
|
out[name] = file;
|
|
52
52
|
}
|
|
53
|
+
else if (isClientCertValue(value)) {
|
|
54
|
+
// Only an exported certificate reaches here as itself (the runner
|
|
55
|
+
// withholds the rest): its parts as files, $NAME the certificate.
|
|
56
|
+
const c = clientCertOf(value);
|
|
57
|
+
if (!c.export) {
|
|
58
|
+
delete out[name];
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const cert = path.join(dir, `${name.toLowerCase()}.crt`);
|
|
62
|
+
const key = path.join(dir, `${name.toLowerCase()}.key`);
|
|
63
|
+
fs.writeFileSync(cert, c.cert, { mode: 0o600 });
|
|
64
|
+
fs.writeFileSync(key, c.key, { mode: 0o600 });
|
|
65
|
+
out[name] = cert;
|
|
66
|
+
out[`${name}_CERT`] = cert;
|
|
67
|
+
out[`${name}_KEY`] = key;
|
|
68
|
+
if (c.passphrase) {
|
|
69
|
+
const pass = path.join(dir, `${name.toLowerCase()}.pass`);
|
|
70
|
+
fs.writeFileSync(pass, c.passphrase, { mode: 0o600 });
|
|
71
|
+
out[`${name}_PASSPHRASE_FILE`] = pass;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
53
74
|
else if (isSshValue(value)) {
|
|
54
75
|
const ssh = sshConfigOf(value);
|
|
55
76
|
const dest = `${ssh.user}@${ssh.host}`;
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export declare function installKeyMaterial(): string;
|
|
|
15
15
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
16
16
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
17
17
|
* the run's scratch. Plain values have no kind. */
|
|
18
|
-
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
|
|
18
|
+
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
|
|
19
19
|
interface StoredSecret {
|
|
20
20
|
iv: string;
|
|
21
21
|
tag: string;
|
|
@@ -133,7 +133,9 @@ export declare function rotateMasterKey(tenant: string, from: string, to: string
|
|
|
133
133
|
export interface OAuth2Config {
|
|
134
134
|
token_url: string;
|
|
135
135
|
client_id: string;
|
|
136
|
-
|
|
136
|
+
/** Absent for a public client (an app with no secret, connected with
|
|
137
|
+
* PKCE): its refresh sends client_id and refresh_token only. */
|
|
138
|
+
client_secret?: string;
|
|
137
139
|
/** Required for the refresh grant; absent for client_credentials. */
|
|
138
140
|
refresh_token?: string;
|
|
139
141
|
/** "refresh_token" (default — user-consented) or "client_credentials"
|
|
@@ -150,6 +152,24 @@ export declare const OAUTH_REFRESH_TRIES = 3;
|
|
|
150
152
|
export declare function oauthRetryDelayMs(n: number): number;
|
|
151
153
|
export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
|
|
152
154
|
export declare function isOAuth2Value(value: string): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* The cache key for a live token: SHA-256 over the kind, the account and the
|
|
157
|
+
* WHOLE stored config — credential material included (client secret, refresh
|
|
158
|
+
* token, private key, subject, scopes, token URL). A caller can only hit an
|
|
159
|
+
* entry it could have minted itself. Both maps are process-wide, so a key of
|
|
160
|
+
* names alone (`sa:${name}:${issuer}`, `${name}:${client_id}:…`) once let a
|
|
161
|
+
* second account with the same names be handed the first one's token
|
|
162
|
+
* (Codex debate, 22 Oct 2026).
|
|
163
|
+
*/
|
|
164
|
+
export declare function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string, tenant?: string): string;
|
|
165
|
+
/**
|
|
166
|
+
* A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
|
|
167
|
+
* with a new one: write it back where the old one is stored — the workspace
|
|
168
|
+
* copy, else the account's — but only while that copy still holds the value
|
|
169
|
+
* this refresh used (compare-and-set), so two refreshes racing cannot put an
|
|
170
|
+
* older token over a newer one. Returns whether a copy was replaced.
|
|
171
|
+
*/
|
|
172
|
+
export declare function replaceOAuth2RefreshToken(tenant: string, name: string, workspace: string | undefined, usedValue: string, refreshToken: string): boolean;
|
|
153
173
|
/**
|
|
154
174
|
* Swap every oauth2 recipe in an env map for a live access token. The one
|
|
155
175
|
* async moment in secret resolution, kept out of the sync paths: callers
|
|
@@ -171,6 +191,10 @@ export interface ServiceAccountConfig {
|
|
|
171
191
|
scope: string;
|
|
172
192
|
/** JWT audience — usually the same as token_url. */
|
|
173
193
|
audience?: string;
|
|
194
|
+
/** The JWT bearer grant's subject (RFC 7523 §3): the user the account acts
|
|
195
|
+
* as, where the provider allows it — Google's domain-wide delegation, once
|
|
196
|
+
* a Workspace admin grants it. Absent, the account acts as itself. */
|
|
197
|
+
subject?: string;
|
|
174
198
|
}
|
|
175
199
|
export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string): void;
|
|
176
200
|
export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string): void;
|
|
@@ -187,10 +211,46 @@ export interface SshConfig {
|
|
|
187
211
|
export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string): void;
|
|
188
212
|
export declare function isSshValue(value: string): boolean;
|
|
189
213
|
export declare function sshConfigOf(value: string): SshConfig;
|
|
214
|
+
/** A client certificate for mutual TLS: presented by the egress proxy, on its
|
|
215
|
+
* own connection, to the exact hosts the secret is bound to — the sandbox
|
|
216
|
+
* never holds the key. `export` puts it in the sandbox as files instead,
|
|
217
|
+
* only when asked for (a step with no egress proxy). */
|
|
218
|
+
export interface ClientCertConfig {
|
|
219
|
+
/** PEM certificate, chain included. */
|
|
220
|
+
cert: string;
|
|
221
|
+
/** PEM private key. */
|
|
222
|
+
key: string;
|
|
223
|
+
passphrase?: string;
|
|
224
|
+
export?: boolean;
|
|
225
|
+
}
|
|
226
|
+
export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string): void;
|
|
227
|
+
/** A keyless cloud identity: no credential at all, only the audience the
|
|
228
|
+
* platform's signed tokens are for. A step that declares it gets a token
|
|
229
|
+
* file the sandbox driver keeps fresh through the egress proxy. */
|
|
230
|
+
export interface IdentityConfig {
|
|
231
|
+
audience: string;
|
|
232
|
+
}
|
|
233
|
+
export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string): void;
|
|
234
|
+
export declare function isIdentityValue(value: string): boolean;
|
|
235
|
+
export declare function identityOf(value: string): IdentityConfig;
|
|
236
|
+
export declare function isClientCertValue(value: string): boolean;
|
|
237
|
+
/** An environment without the client certificates that were not saved with
|
|
238
|
+
* `export`: those are the egress proxy's to present, never a program's. */
|
|
239
|
+
export declare function withoutHeldCerts(env: Record<string, string>): Record<string, string>;
|
|
240
|
+
export declare function clientCertOf(value: string): ClientCertConfig;
|
|
190
241
|
export interface ApiConfig {
|
|
191
242
|
base_url?: string;
|
|
192
243
|
headers: Record<string, string>;
|
|
244
|
+
/** Username and password for HTTP Basic (RFC 7617): stored as the
|
|
245
|
+
* `Authorization: Basic …` header they make, never kept apart. */
|
|
246
|
+
basic?: {
|
|
247
|
+
username: string;
|
|
248
|
+
password: string;
|
|
249
|
+
};
|
|
193
250
|
}
|
|
251
|
+
/** `Authorization: Basic …` from a username and password (RFC 7617 §2):
|
|
252
|
+
* UTF-8, no colon in the username, no control characters in either. */
|
|
253
|
+
export declare function basicAuthorization(username: string, password: string): string;
|
|
194
254
|
export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
|
|
195
255
|
export declare function isApiValue(value: string): boolean;
|
|
196
256
|
export declare function apiConfigOf(value: string): ApiConfig;
|