@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.
@@ -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);
@@ -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,
@@ -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
- ...secretEnv,
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
- const stepSecrets = held.env;
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
  }
@@ -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}`;
@@ -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
- client_secret: string;
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;