@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.
Files changed (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
@@ -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. Defaults to 120, capped at 600: a limit an author can
43
- * raise is a budget; one they can remove is a hang. */
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) return { cmd: spec.interpreter, args: [abs] };
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
- if (p.startsWith(path.resolve(cwd))) return p.replace(path.resolve(cwd), "/workspace");
216
- for (const [host, mount] of Object.entries(exec.mounts)) {
217
- if (p.startsWith(host)) return p.replace(host, mount);
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
- return path.resolve(libraryRoot, "tools", run.slice("account/tools/".length));
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/"]) {
@@ -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-run scratch directory inside the agent's own
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. Returns the
38
- * directory written (for cleanup) and the transformed env. Plain values
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 dir = path.join(agentDir, ".secret-files");
51
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
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) fs.rmSync(dir, { recursive: true, force: true });
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
  }
@@ -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, { name: string; use: { host: string; status: number | null; at?: 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, uses] of pending) {
135
+ for (const [tenant, updates] of pending) {
93
136
  pending.delete(tenant);
94
- const data = read(tenant);
95
- for (const { name, use } of uses) apply(data, name, use);
96
- write(tenant, data);
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({ name: key, use: { ...use, at: use.at ?? new Date().toISOString() } });
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
- const data = read(tenant);
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
- const f = secretsFile(tenant, workspace);
107
- if (!fs.existsSync(f)) return {};
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(f, "utf8"));
131
+ return JSON.parse(fs.readFileSync(file, "utf8"));
110
132
  } catch {
111
133
  return {};
112
134
  }
113
135
  }
114
136
 
115
- function write(tenant: string, workspace: string | undefined, data: SecretsFile) {
116
- const f = secretsFile(tenant, workspace);
117
- fs.mkdirSync(path.dirname(f), { recursive: true });
118
- fs.writeFileSync(f, JSON.stringify(data, null, 2), { mode: 0o600 });
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
- const all = read(tenant, workspace);
136
- all[name] = {
137
- ...sealed,
138
- updatedAt: new Date().toISOString(),
139
- ...(kind ? { kind } : {}),
140
- };
141
- write(tenant, workspace, all);
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
- const all = read(tenant, workspace);
246
- delete all[name];
247
- write(tenant, workspace, all);
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
- for (const [name, rec] of Object.entries(all)) {
376
- // A record under the ACCOUNT's key is not under the install key, so
377
- // rotating the install key must not touch it — re-encrypting it here
378
- // with a key it was never sealed with is how a rotation would destroy a
379
- // vault it was meant to protect. Rotating the install key now re-wraps
380
- // the account keys instead, which is a handful of small values rather
381
- // than every secret on the box.
382
- if (rec.k === "tenant") {
383
- next[name] = rec;
384
- continue;
385
- }
386
- let plain: string;
387
- try {
388
- const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
389
- d.setAuthTag(Buffer.from(rec.tag, "base64"));
390
- plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
391
- } catch {
392
- unreadable.push(name);
393
- next[name] = rec; // untouched
394
- continue;
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
- const iv = crypto.randomBytes(12);
397
- const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
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 fetch(config.token_url, {
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 fetch(config.token_url, {
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
- const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
805
- const plain = new Map<string, string>();
806
- let pending = 0;
807
-
808
- for (const [name, rec] of Object.entries(all)) {
809
- if (rec.k === "tenant") continue; // already moved
810
- const value = decrypt(tenant, rec);
811
- if (value === null) {
812
- unreadable.push(name);
813
- continue;
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
- plain.set(name, value);
816
- pending += 1;
817
- }
818
- // Nothing to do, or something could not be read — leave the file alone.
819
- // A partial conversion is worse than none, because the half that moved is
820
- // no longer openable by the key the other half still needs.
821
- if (pending === 0 || unreadable.length) continue;
822
-
823
- const next: SecretsFile = { ...all };
824
- for (const [name, value] of plain) {
825
- next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
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
  }