@foldrun/core 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/script-tools.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { z } from "zod";
|
|
|
21
21
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
22
22
|
import { runInContainer } from "./container.ts";
|
|
23
23
|
import { hostSafeEnv } from "./host-env.ts";
|
|
24
|
+
import { workspaceLinkEnv } from "./confine.ts";
|
|
25
|
+
import { platform } from "./platform.ts";
|
|
24
26
|
|
|
25
27
|
// A script's only clock is the `timeout:` its own tool.md declares. There
|
|
26
28
|
// was a two-minute default and a ten-minute cap here; both were the
|
|
@@ -39,8 +41,9 @@ export interface ScriptSpec {
|
|
|
39
41
|
args: Record<string, string>; // arg name → description
|
|
40
42
|
interpreter?: string; // optional override, e.g. "python3", "bash"
|
|
41
43
|
/** Seconds this script may run — for the crawl that legitimately takes
|
|
42
|
-
* five minutes.
|
|
43
|
-
*
|
|
44
|
+
* five minutes. Unset means no platform clock; there was a two-minute
|
|
45
|
+
* default and a ten-minute cap here and both were removed (see the note
|
|
46
|
+
* above `timeoutFor`). */
|
|
44
47
|
timeout?: number;
|
|
45
48
|
/** The script itself, when the tool is a single markdown file with its
|
|
46
49
|
* code in a fenced block. Materialised to a file at call time — the
|
|
@@ -99,6 +102,15 @@ export function parseScripts(raw: unknown): ScriptSpec[] {
|
|
|
99
102
|
return out;
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
/** Interpreter names that mean a language rather than a binary, and the
|
|
106
|
+
* extension whose prepared interpreter serves them. */
|
|
107
|
+
const GENERIC_INTERPRETER: Record<string, string> = {
|
|
108
|
+
python: ".py",
|
|
109
|
+
python3: ".py",
|
|
110
|
+
"/usr/bin/python3": ".py",
|
|
111
|
+
"/usr/bin/env python3": ".py",
|
|
112
|
+
};
|
|
113
|
+
|
|
102
114
|
// Pick how to execute a file when no interpreter is declared.
|
|
103
115
|
//
|
|
104
116
|
// Exported because the tool tester has to make the same choice, and used to
|
|
@@ -110,7 +122,18 @@ export function commandFor(
|
|
|
110
122
|
abs: string,
|
|
111
123
|
overrides: Record<string, string> = {},
|
|
112
124
|
): { cmd: string; args: string[] } {
|
|
113
|
-
if (spec.interpreter)
|
|
125
|
+
if (spec.interpreter) {
|
|
126
|
+
// `interpreter: python3` names the language, not a particular binary, and
|
|
127
|
+
// it is how most tool files are written. Taken literally it ran the bare
|
|
128
|
+
// system python beside a venv built for this very tool, and the tool
|
|
129
|
+
// died on its first import ("No module named openpyxl") while the log
|
|
130
|
+
// above it said the runtime was cached. A generic python name is served
|
|
131
|
+
// by the prepared venv when there is one; a path or anything else is
|
|
132
|
+
// still obeyed as written.
|
|
133
|
+
const lang = GENERIC_INTERPRETER[spec.interpreter.trim()];
|
|
134
|
+
if (lang && overrides[lang]) return { cmd: overrides[lang], args: [abs] };
|
|
135
|
+
return { cmd: spec.interpreter, args: [abs] };
|
|
136
|
+
}
|
|
114
137
|
const ext = path.extname(abs).toLowerCase();
|
|
115
138
|
// A prepared runtime (venv, npm prefix) wins over the host interpreter.
|
|
116
139
|
if (overrides[ext]) return { cmd: overrides[ext], args: [abs] };
|
|
@@ -211,12 +234,16 @@ export function runScript(
|
|
|
211
234
|
// Container path: same command, executed inside an isolated container
|
|
212
235
|
// with only the agent's directory (plus declared shared dirs) mounted.
|
|
213
236
|
if (exec && exec.executor === "docker") {
|
|
237
|
+
// `workspace/…` is the workspace root on the host (the step's link)
|
|
238
|
+
// and in a run container; here there is no link, so each such
|
|
239
|
+
// argument is staged: copied in, pointed at, and copied back after.
|
|
240
|
+
const workspaceRoot = path.resolve(cwd, "..", "..");
|
|
241
|
+
const staged: { host: string; container: string }[] = [];
|
|
214
242
|
const inContainer = (p: string) => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return p;
|
|
243
|
+
const w = stageWorkspaceArg(p, workspaceRoot);
|
|
244
|
+
if (!w) return containerPath(p, cwd, exec.mounts);
|
|
245
|
+
if (!staged.some((s) => s.host === w.host)) staged.push(w);
|
|
246
|
+
return w.container;
|
|
220
247
|
};
|
|
221
248
|
const argv = [
|
|
222
249
|
// The interpreter inside the image, not the host's venv path.
|
|
@@ -228,6 +255,7 @@ export function runScript(
|
|
|
228
255
|
runInContainer({
|
|
229
256
|
agentDir: path.resolve(cwd),
|
|
230
257
|
readOnly: exec.mounts,
|
|
258
|
+
staged,
|
|
231
259
|
image: exec.image,
|
|
232
260
|
argv,
|
|
233
261
|
env,
|
|
@@ -247,7 +275,7 @@ export function runScript(
|
|
|
247
275
|
// environment, which is the boundary, so nothing is lost there.
|
|
248
276
|
const child = spawn(cmd, args, {
|
|
249
277
|
cwd,
|
|
250
|
-
env: { ...hostSafeEnv(), ...env },
|
|
278
|
+
env: workspaceLinkEnv({ ...hostSafeEnv(), ...env }, cwd),
|
|
251
279
|
timeout: timeoutFor(spec),
|
|
252
280
|
});
|
|
253
281
|
let out = "";
|
|
@@ -269,6 +297,39 @@ export function runScript(
|
|
|
269
297
|
});
|
|
270
298
|
}
|
|
271
299
|
|
|
300
|
+
/**
|
|
301
|
+
* A host path as the script's container sees it: under the agent's own
|
|
302
|
+
* directory it is /workspace, under a mounted folder it is that mount, and
|
|
303
|
+
* anything else (an argument that only looks like a path) is left alone.
|
|
304
|
+
* Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
|
|
305
|
+
* `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
|
|
306
|
+
*/
|
|
307
|
+
export function containerPath(p: string, agentDir: string, mounts: Record<string, string>): string {
|
|
308
|
+
if (!path.isAbsolute(p)) return p;
|
|
309
|
+
const under = (root: string) => {
|
|
310
|
+
const rel = path.relative(root, p);
|
|
311
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) ? rel : null;
|
|
312
|
+
};
|
|
313
|
+
const own = under(path.resolve(agentDir));
|
|
314
|
+
if (own !== null) return path.posix.join("/workspace", own.split(path.sep).join("/"));
|
|
315
|
+
const hit = Object.entries(mounts)
|
|
316
|
+
.map(([host, mount]) => ({ mount, rel: under(path.resolve(host)), len: host.length }))
|
|
317
|
+
.filter((m) => m.rel !== null)
|
|
318
|
+
.sort((a, b) => b.len - a.len)[0];
|
|
319
|
+
return hit ? path.posix.join(hit.mount, hit.rel!.split(path.sep).join("/")) : p;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Where a `workspace/…` script argument is staged in a script container,
|
|
323
|
+
* or null when it is not one (or climbs out of the workspace). */
|
|
324
|
+
export function stageWorkspaceArg(arg: string, workspaceRoot: string): { host: string; container: string } | null {
|
|
325
|
+
if (arg !== "workspace" && !arg.startsWith("workspace/")) return null;
|
|
326
|
+
const rel = arg.slice("workspace".length).replace(/^\/+/, "");
|
|
327
|
+
const host = path.resolve(workspaceRoot, rel);
|
|
328
|
+
const within = path.relative(workspaceRoot, host);
|
|
329
|
+
if (within.startsWith("..") || path.isAbsolute(within)) return null;
|
|
330
|
+
return { host, container: path.posix.join("/workspace-root", within.split(path.sep).join("/")) };
|
|
331
|
+
}
|
|
332
|
+
|
|
272
333
|
export interface ExecutionContext {
|
|
273
334
|
/** "docker" nests each script in its own container; "sandbox" spawns it
|
|
274
335
|
* directly inside the isolation the step already has. */
|
|
@@ -307,7 +368,17 @@ export function resolveRunPath(agentDir: string, run: string, libraryScripts: st
|
|
|
307
368
|
return path.resolve(workspaceRoot, "tools", run.slice("workspace/tools/".length));
|
|
308
369
|
}
|
|
309
370
|
if (run.startsWith("account/tools/")) {
|
|
310
|
-
|
|
371
|
+
const rel = run.slice("account/tools/".length);
|
|
372
|
+
const own = path.resolve(libraryRoot, "tools", rel);
|
|
373
|
+
// A gallery tool nobody installed has no copy in the library. In a
|
|
374
|
+
// sandbox the gallery is laid down under the library, so the path above
|
|
375
|
+
// is it; a run on the host has no such merge, so it looks on the shelf.
|
|
376
|
+
const gallery = platform.galleryDir();
|
|
377
|
+
if (!fs.existsSync(own) && gallery) {
|
|
378
|
+
const shelf = path.resolve(gallery, "tools", rel);
|
|
379
|
+
if (fs.existsSync(shelf)) return shelf;
|
|
380
|
+
}
|
|
381
|
+
return own;
|
|
311
382
|
}
|
|
312
383
|
// Canonical prefixes, then the legacy spellings they replaced.
|
|
313
384
|
for (const p of ["workspace/scripts/", "shared/"]) {
|
package/src/secret-files.ts
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
// `"$PROD_VM" 'uptime'` or `"$STRIPE" /v1/charges` and never learns whether
|
|
10
10
|
// it was key or password, one header or five.
|
|
11
11
|
//
|
|
12
|
-
// Everything lands in a per-
|
|
12
|
+
// Everything lands in a per-step scratch directory inside the agent's own
|
|
13
13
|
// tree (so it crosses into a container with the workspace) but dot-prefixed
|
|
14
|
-
// and never archived or copied back — it holds live credentials.
|
|
14
|
+
// and never archived or copied back — it holds live credentials. Per step,
|
|
15
|
+
// not per agent: two steps of one agent run in parallel on the in-process
|
|
16
|
+
// path (a fan-out), and with one shared directory the first to finish
|
|
17
|
+
// deleted the keys the other was still using.
|
|
15
18
|
|
|
16
19
|
import fs from "node:fs";
|
|
17
20
|
import path from "node:path";
|
|
@@ -33,10 +36,10 @@ function writeWrapper(dir: string, name: string, body: string): string {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
|
-
* Turn any @file/@ssh/@api values in `env` into files under
|
|
37
|
-
* `<agentDir>/.secret-files`, rewriting each to its path.
|
|
38
|
-
* directory written (for cleanup) and the transformed env.
|
|
39
|
-
* pass through untouched.
|
|
39
|
+
* Turn any @file/@ssh/@api values in `env` into files under a fresh
|
|
40
|
+
* `<agentDir>/.secret-files/step-XXXXXX`, rewriting each to its path.
|
|
41
|
+
* Returns the directory written (for cleanup) and the transformed env.
|
|
42
|
+
* Plain values pass through untouched.
|
|
40
43
|
*/
|
|
41
44
|
export function materializeFileSecrets(
|
|
42
45
|
agentDir: string,
|
|
@@ -47,8 +50,10 @@ export function materializeFileSecrets(
|
|
|
47
50
|
);
|
|
48
51
|
if (entries.length === 0) return { env, dir: null };
|
|
49
52
|
|
|
50
|
-
const
|
|
51
|
-
fs.mkdirSync(
|
|
53
|
+
const parent = path.join(agentDir, ".secret-files");
|
|
54
|
+
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
55
|
+
const dir = fs.mkdtempSync(path.join(parent, "step-"));
|
|
56
|
+
fs.chmodSync(dir, 0o700);
|
|
52
57
|
const out = { ...env };
|
|
53
58
|
for (const [name, value] of entries) {
|
|
54
59
|
// Lowercased name as the filename keeps it predictable without leaking
|
|
@@ -95,7 +100,14 @@ export function materializeFileSecrets(
|
|
|
95
100
|
return { env: out, dir };
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
/** Best-effort removal of a materialised secret-files directory
|
|
103
|
+
/** Best-effort removal of a materialised secret-files directory — this
|
|
104
|
+
* step's own, and the shared parent once no other step is using it. */
|
|
99
105
|
export function cleanupFileSecrets(dir: string | null) {
|
|
100
|
-
if (dir)
|
|
106
|
+
if (!dir) return;
|
|
107
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
108
|
+
try {
|
|
109
|
+
fs.rmdirSync(path.dirname(dir));
|
|
110
|
+
} catch {
|
|
111
|
+
// another step's directory is still in there
|
|
112
|
+
}
|
|
101
113
|
}
|
package/src/secret-health.ts
CHANGED
|
@@ -55,7 +55,26 @@ export interface SecretRecord {
|
|
|
55
55
|
lastAccepted: string | null;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
type HealthFile = Record<string, SecretRecord>;
|
|
58
|
+
export type HealthFile = Record<string, SecretRecord>;
|
|
59
|
+
|
|
60
|
+
/** One use to fold into a record. */
|
|
61
|
+
export interface HealthUpdate {
|
|
62
|
+
key: string;
|
|
63
|
+
use: { host: string; status: number | null; at: string };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Where health is kept. A file per account by default — what the CLI and a
|
|
68
|
+
* single-box install use. The platform plugs in Postgres
|
|
69
|
+
* (setSecretHealthStore), where each use is one atomic upsert: two pods
|
|
70
|
+
* recording at once on a shared file was last-writer-wins, and a refusal
|
|
71
|
+
* could vanish.
|
|
72
|
+
*/
|
|
73
|
+
export interface SecretHealthStore {
|
|
74
|
+
apply(tenant: string, updates: HealthUpdate[]): Promise<void>;
|
|
75
|
+
read(tenant: string): Promise<HealthFile>;
|
|
76
|
+
forget(tenant: string, key: string): Promise<void>;
|
|
77
|
+
}
|
|
59
78
|
|
|
60
79
|
const healthFile = (tenant: string) => path.join(accountDir(tenant), "secret-health.json");
|
|
61
80
|
|
|
@@ -76,27 +95,51 @@ function write(tenant: string, data: HealthFile): void {
|
|
|
76
95
|
fs.renameSync(tmp, file);
|
|
77
96
|
}
|
|
78
97
|
|
|
98
|
+
const fileStore: SecretHealthStore = {
|
|
99
|
+
async apply(tenant, updates) {
|
|
100
|
+
const data = read(tenant);
|
|
101
|
+
for (const { key, use } of updates) apply(data, key, use);
|
|
102
|
+
write(tenant, data);
|
|
103
|
+
},
|
|
104
|
+
async read(tenant) {
|
|
105
|
+
return read(tenant);
|
|
106
|
+
},
|
|
107
|
+
async forget(tenant, key) {
|
|
108
|
+
const data = read(tenant);
|
|
109
|
+
if (!(key in data)) return;
|
|
110
|
+
delete data[key];
|
|
111
|
+
write(tenant, data);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let store: SecretHealthStore = fileStore;
|
|
116
|
+
|
|
117
|
+
/** The platform's store (Postgres), or back to files with null. */
|
|
118
|
+
export function setSecretHealthStore(next: SecretHealthStore | null): void {
|
|
119
|
+
store = next ?? fileStore;
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
// Writes are queued and coalesced off the caller's path. The egress proxy
|
|
80
123
|
// notes a use per secret per request, before it streams the response body,
|
|
81
124
|
// and a synchronous read-modify-write of a JSON file there is latency on
|
|
82
125
|
// every proxied call — "never blocks" has to be true, not aspirational.
|
|
83
126
|
// One writer per process; the queue drains in order.
|
|
84
|
-
const pending = new Map<string,
|
|
127
|
+
const pending = new Map<string, HealthUpdate[]>();
|
|
85
128
|
let draining = false;
|
|
86
129
|
|
|
87
130
|
function drain(): void {
|
|
88
131
|
if (draining) return;
|
|
89
132
|
draining = true;
|
|
90
|
-
setImmediate(() => {
|
|
133
|
+
setImmediate(async () => {
|
|
91
134
|
try {
|
|
92
|
-
for (const [tenant,
|
|
135
|
+
for (const [tenant, updates] of pending) {
|
|
93
136
|
pending.delete(tenant);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
137
|
+
try {
|
|
138
|
+
await store.apply(tenant, updates);
|
|
139
|
+
} catch {
|
|
140
|
+
// best effort, always
|
|
141
|
+
}
|
|
97
142
|
}
|
|
98
|
-
} catch {
|
|
99
|
-
// best effort, always
|
|
100
143
|
} finally {
|
|
101
144
|
draining = false;
|
|
102
145
|
if (pending.size) drain();
|
|
@@ -140,7 +183,7 @@ export function noteSecretUse(
|
|
|
140
183
|
): void {
|
|
141
184
|
if (!key) return;
|
|
142
185
|
const list = pending.get(tenant) ?? [];
|
|
143
|
-
list.push({
|
|
186
|
+
list.push({ key, use: { ...use, at: use.at ?? new Date().toISOString() } });
|
|
144
187
|
pending.set(tenant, list);
|
|
145
188
|
drain();
|
|
146
189
|
}
|
|
@@ -157,15 +200,15 @@ export function flushSecretHealth(): Promise<void> {
|
|
|
157
200
|
/** Everything known about this account's credentials, by name. Names the
|
|
158
201
|
* vault holds but nothing has used are simply absent, which is the honest
|
|
159
202
|
* answer: unused is not the same as broken. */
|
|
160
|
-
export function secretHealth(tenant: string): HealthFile {
|
|
161
|
-
return read(tenant);
|
|
203
|
+
export async function secretHealth(tenant: string): Promise<HealthFile> {
|
|
204
|
+
return store.read(tenant);
|
|
162
205
|
}
|
|
163
206
|
|
|
164
207
|
/** The credentials worth saying something about: refused the last time they
|
|
165
208
|
* were used. Sorted worst first — most consecutive refusals, then oldest
|
|
166
209
|
* last-success, which is the order someone would work through them. */
|
|
167
|
-
export function failingSecrets(tenant: string): { name: string; record: SecretRecord }[] {
|
|
168
|
-
return Object.entries(read(tenant))
|
|
210
|
+
export async function failingSecrets(tenant: string): Promise<{ name: string; record: SecretRecord }[]> {
|
|
211
|
+
return Object.entries(await store.read(tenant))
|
|
169
212
|
.filter(([, r]) => r.last.outcome === "refused")
|
|
170
213
|
.map(([name, record]) => ({ name, record }))
|
|
171
214
|
.sort((a, b) => b.record.refusals - a.record.refusals || (a.record.lastAccepted ?? "").localeCompare(b.record.lastAccepted ?? ""));
|
|
@@ -173,13 +216,15 @@ export function failingSecrets(tenant: string): { name: string; record: SecretRe
|
|
|
173
216
|
|
|
174
217
|
/** Drop what is known about a secret — for a delete, and for a rotation,
|
|
175
218
|
* where the old key's refusals say nothing about the new one. */
|
|
176
|
-
export function forgetSecretHealth(tenant: string, name: string): void {
|
|
219
|
+
export async function forgetSecretHealth(tenant: string, name: string): Promise<void> {
|
|
177
220
|
try {
|
|
178
|
-
|
|
179
|
-
if (!(name in data)) return;
|
|
180
|
-
delete data[name];
|
|
181
|
-
write(tenant, data);
|
|
221
|
+
await store.forget(tenant, name);
|
|
182
222
|
} catch {
|
|
183
223
|
// best effort
|
|
184
224
|
}
|
|
185
225
|
}
|
|
226
|
+
|
|
227
|
+
/** The account's file, for the platform's one-time import into its store. */
|
|
228
|
+
export function readSecretHealthFile(tenant: string): HealthFile {
|
|
229
|
+
return read(tenant);
|
|
230
|
+
}
|
package/src/secrets.ts
CHANGED
|
@@ -23,6 +23,7 @@ import fs from "node:fs";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { dataRoot } from "./paths.ts";
|
|
25
25
|
import { platform } from "./platform.ts";
|
|
26
|
+
import { fetchUntrusted } from "./untrusted-fetch.ts";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
@@ -103,19 +104,80 @@ interface StoredSecret {
|
|
|
103
104
|
type SecretsFile = Record<string, StoredSecret>;
|
|
104
105
|
|
|
105
106
|
function read(tenant: string, workspace?: string): SecretsFile {
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
return readFile(secretsFile(tenant, workspace));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ------------------------------------------------------------ safe writes
|
|
111
|
+
//
|
|
112
|
+
// A vault file is read, changed and written back. It was written in place,
|
|
113
|
+
// with no lock: two saves at once kept one; a crash mid-write left half a
|
|
114
|
+
// file, which is an account that cannot authenticate anything; and the boot
|
|
115
|
+
// re-wrap ran in every pod at once through ONE shared temp name, so two pods
|
|
116
|
+
// could interleave into it and rename a corrupt file into place.
|
|
117
|
+
//
|
|
118
|
+
// Now every change goes through mutateVaultFile: an exclusive lock file
|
|
119
|
+
// (O_EXCL, which NFS 4.1 and EFS honour, so it holds across pods), the read
|
|
120
|
+
// and the change under it, a temp file unique to this writer, and a rename —
|
|
121
|
+
// atomic on the same filesystem, so a reader sees the old file or the new
|
|
122
|
+
// one, never half. A lock older than LOCK_STALE_MS is a crashed holder.
|
|
123
|
+
|
|
124
|
+
const LOCK_STALE_MS = 30_000;
|
|
125
|
+
const LOCK_WAIT_MS = 10_000;
|
|
126
|
+
const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
127
|
+
|
|
128
|
+
function readFile(file: string): SecretsFile {
|
|
129
|
+
if (!fs.existsSync(file)) return {};
|
|
108
130
|
try {
|
|
109
|
-
return JSON.parse(fs.readFileSync(
|
|
131
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
110
132
|
} catch {
|
|
111
133
|
return {};
|
|
112
134
|
}
|
|
113
135
|
}
|
|
114
136
|
|
|
115
|
-
function
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
137
|
+
function lockVault(file: string): () => void {
|
|
138
|
+
const lock = `${file}.lock`;
|
|
139
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
140
|
+
for (;;) {
|
|
141
|
+
try {
|
|
142
|
+
const fd = fs.openSync(lock, "wx", 0o600);
|
|
143
|
+
fs.writeSync(fd, `${process.pid}@${new Date().toISOString()}`);
|
|
144
|
+
fs.closeSync(fd);
|
|
145
|
+
return () => fs.rmSync(lock, { force: true });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
148
|
+
try {
|
|
149
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
150
|
+
fs.rmSync(lock, { force: true }); // a holder that died; take it over
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
continue; // released between the open and the stat
|
|
155
|
+
}
|
|
156
|
+
if (Date.now() > deadline) throw new Error("the vault is busy — another change is being saved; try again");
|
|
157
|
+
sleep(25);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Change one vault file safely: `change` gets what is stored now and returns
|
|
164
|
+
* the new contents, or null to leave the file untouched. Returns what is
|
|
165
|
+
* stored after.
|
|
166
|
+
*/
|
|
167
|
+
export function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile {
|
|
168
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
169
|
+
const release = lockVault(file);
|
|
170
|
+
try {
|
|
171
|
+
const current = readFile(file);
|
|
172
|
+
const next = change(structuredClone(current));
|
|
173
|
+
if (next === null) return current;
|
|
174
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
175
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
176
|
+
fs.renameSync(tmp, file);
|
|
177
|
+
return next;
|
|
178
|
+
} finally {
|
|
179
|
+
release();
|
|
180
|
+
}
|
|
119
181
|
}
|
|
120
182
|
|
|
121
183
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
@@ -132,13 +194,14 @@ export function setSecret(
|
|
|
132
194
|
}
|
|
133
195
|
if (value.length > 8192) throw new Error("secret too long");
|
|
134
196
|
const sealed = encryptValue(tenant, value);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
197
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
198
|
+
all[name] = {
|
|
199
|
+
...sealed,
|
|
200
|
+
updatedAt: new Date().toISOString(),
|
|
201
|
+
...(kind ? { kind } : {}),
|
|
202
|
+
};
|
|
203
|
+
return all;
|
|
204
|
+
});
|
|
142
205
|
}
|
|
143
206
|
|
|
144
207
|
/**
|
|
@@ -242,9 +305,11 @@ export function getSecret(
|
|
|
242
305
|
}
|
|
243
306
|
|
|
244
307
|
export function deleteSecret(tenant: string, name: string, workspace?: string) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
308
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
309
|
+
if (!(name in all)) return null;
|
|
310
|
+
delete all[name];
|
|
311
|
+
return all;
|
|
312
|
+
});
|
|
248
313
|
}
|
|
249
314
|
|
|
250
315
|
export interface SecretEntry {
|
|
@@ -368,48 +433,46 @@ export function rotateMasterKey(
|
|
|
368
433
|
const unreadable: string[] = [];
|
|
369
434
|
|
|
370
435
|
for (const { file } of secretFiles(tenant)) {
|
|
371
|
-
const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
372
|
-
const next: SecretsFile = {};
|
|
373
436
|
let count = 0;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
437
|
+
mutateVaultFile(file, (all) => {
|
|
438
|
+
const next: SecretsFile = {};
|
|
439
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
440
|
+
// A record under the ACCOUNT's key is not under the install key, so
|
|
441
|
+
// rotating the install key must not touch it — re-encrypting it here
|
|
442
|
+
// with a key it was never sealed with is how a rotation would destroy a
|
|
443
|
+
// vault it was meant to protect. Rotating the install key now re-wraps
|
|
444
|
+
// the account keys instead, which is a handful of small values rather
|
|
445
|
+
// than every secret on the box.
|
|
446
|
+
if (rec.k === "tenant") {
|
|
447
|
+
next[name] = rec;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
let plain: string;
|
|
451
|
+
try {
|
|
452
|
+
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
|
|
453
|
+
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
454
|
+
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
455
|
+
} catch {
|
|
456
|
+
unreadable.push(name);
|
|
457
|
+
next[name] = rec; // untouched
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const iv = crypto.randomBytes(12);
|
|
461
|
+
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
|
|
462
|
+
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
463
|
+
next[name] = {
|
|
464
|
+
iv: iv.toString("base64"),
|
|
465
|
+
tag: c.getAuthTag().toString("base64"),
|
|
466
|
+
data: enc.toString("base64"),
|
|
467
|
+
updatedAt: rec.updatedAt, // rotation is not an edit
|
|
468
|
+
// Kept: without it an oauth2 secret stops being refreshed after a
|
|
469
|
+
// rotation, and a file/ssh/api secret stops being materialised.
|
|
470
|
+
...(rec.kind ? { kind: rec.kind } : {}),
|
|
471
|
+
};
|
|
472
|
+
count++;
|
|
395
473
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
399
|
-
next[name] = {
|
|
400
|
-
iv: iv.toString("base64"),
|
|
401
|
-
tag: c.getAuthTag().toString("base64"),
|
|
402
|
-
data: enc.toString("base64"),
|
|
403
|
-
updatedAt: rec.updatedAt, // rotation is not an edit
|
|
404
|
-
};
|
|
405
|
-
count++;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Write via a temp file in the same directory: a half-written secrets.json
|
|
409
|
-
// is an account that cannot authenticate anything.
|
|
410
|
-
const tmp = `${file}.rotating`;
|
|
411
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
412
|
-
fs.renameSync(tmp, file);
|
|
474
|
+
return next;
|
|
475
|
+
});
|
|
413
476
|
rewritten.push({ file, secrets: count });
|
|
414
477
|
}
|
|
415
478
|
|
|
@@ -487,12 +550,12 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
487
550
|
client_secret: config.client_secret,
|
|
488
551
|
...(config.extra ?? {}),
|
|
489
552
|
});
|
|
490
|
-
const res = await
|
|
553
|
+
const res = await fetchUntrusted(config.token_url, {
|
|
491
554
|
method: "POST",
|
|
492
555
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
493
556
|
body: body.toString(),
|
|
494
557
|
signal: AbortSignal.timeout(15_000),
|
|
495
|
-
});
|
|
558
|
+
}, { sameOrigin: true });
|
|
496
559
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
497
560
|
access_token?: string;
|
|
498
561
|
expires_in?: number;
|
|
@@ -638,7 +701,7 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
|
|
|
638
701
|
const signature = base64url(signer.sign(config.private_key));
|
|
639
702
|
const jwt = `${header}.${claim}.${signature}`;
|
|
640
703
|
|
|
641
|
-
const res = await
|
|
704
|
+
const res = await fetchUntrusted(config.token_url, {
|
|
642
705
|
method: "POST",
|
|
643
706
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
644
707
|
body: new URLSearchParams({
|
|
@@ -646,7 +709,7 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
|
|
|
646
709
|
assertion: jwt,
|
|
647
710
|
}).toString(),
|
|
648
711
|
signal: AbortSignal.timeout(15_000),
|
|
649
|
-
});
|
|
712
|
+
}, { sameOrigin: true });
|
|
650
713
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
651
714
|
access_token?: string;
|
|
652
715
|
expires_in?: number;
|
|
@@ -801,33 +864,32 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
801
864
|
const unreadable: string[] = [];
|
|
802
865
|
|
|
803
866
|
for (const { file } of secretFiles(tenant)) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
867
|
+
// Under the vault's lock: every pod runs this at boot, and a save made
|
|
868
|
+
// between our read and our write must not be lost.
|
|
869
|
+
mutateVaultFile(file, (all) => {
|
|
870
|
+
const plain = new Map<string, string>();
|
|
871
|
+
const unreadableHere: string[] = [];
|
|
872
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
873
|
+
if (rec.k === "tenant") continue; // already moved
|
|
874
|
+
const value = decrypt(tenant, rec);
|
|
875
|
+
if (value === null) {
|
|
876
|
+
unreadableHere.push(name);
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
plain.set(name, value);
|
|
814
880
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
next
|
|
826
|
-
}
|
|
827
|
-
const tmp = `${file}.rewrap`;
|
|
828
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
829
|
-
fs.renameSync(tmp, file);
|
|
830
|
-
moved += pending;
|
|
881
|
+
unreadable.push(...unreadableHere);
|
|
882
|
+
// Nothing to do, or something in THIS file could not be read — leave it
|
|
883
|
+
// alone. A partial conversion is worse than none, because the half that
|
|
884
|
+
// moved is no longer openable by the key the other half still needs.
|
|
885
|
+
if (plain.size === 0 || unreadableHere.length) return null;
|
|
886
|
+
const next: SecretsFile = { ...all };
|
|
887
|
+
for (const [name, value] of plain) {
|
|
888
|
+
next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
|
|
889
|
+
}
|
|
890
|
+
moved += plain.size;
|
|
891
|
+
return next;
|
|
892
|
+
});
|
|
831
893
|
}
|
|
832
894
|
return { moved, unreadable };
|
|
833
895
|
}
|