@foldrun/core 0.3.0 → 0.5.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -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/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -22,21 +22,47 @@ import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import { dataRoot } from "./paths.js";
24
24
  import { platform } from "./platform.js";
25
+ import { fetchUntrusted } from "./untrusted-fetch.js";
26
+ /**
27
+ * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
28
+ * `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
29
+ * 12, 13, 14, 15 or 16 bytes — so a stored record whose tag had been cut to
30
+ * four bytes would still "verify" on 32 bits of authentication instead of
31
+ * 128. The tag comes from the record on disk; the record is the thing an
32
+ * attacker with file access would edit. One option makes a short tag an
33
+ * error rather than a weaker check.
34
+ */
35
+ const GCM = { authTagLength: 16 };
25
36
  // The account's own data key, when the platform holds one; the install key
26
37
  // otherwise. See platform.ts — locally there is no account key.
27
38
  const tenantKey = (tenant) => platform.tenantKey(tenant);
28
39
  import crypto from "node:crypto";
29
40
  import { assertSafeName } from "./store.js";
41
+ import { trimSlashes } from "./paths.js";
30
42
  const keyFile = () => path.join(dataRoot(), ".secret-key");
31
- function masterKey() {
43
+ /**
44
+ * The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
45
+ * contents of `.secret-key` under the data root — created here, the first
46
+ * time anything asks, so there is never a moment when a derived token has
47
+ * nothing real to hang off. The vault, hook tokens, approval links and git
48
+ * push secrets all derive from this one value; rotating it invalidates every
49
+ * one of them together. Exported for webhook.ts, which used to fall back to
50
+ * a fixed dev string until the vault happened to create this file — so hook
51
+ * URLs were computable by anyone who read the source, and then changed the
52
+ * first time a secret was stored.
53
+ */
54
+ export function installKeyMaterial() {
32
55
  const fromEnv = process.env.FOLDRUN_SECRET_KEY;
33
56
  if (fromEnv)
34
- return crypto.createHash("sha256").update(fromEnv).digest();
57
+ return fromEnv;
35
58
  fs.mkdirSync(dataRoot(), { recursive: true });
36
59
  if (!fs.existsSync(keyFile())) {
37
60
  fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
38
61
  }
39
- return crypto.createHash("sha256").update(fs.readFileSync(keyFile(), "utf8")).digest();
62
+ return fs.readFileSync(keyFile(), "utf8");
63
+ }
64
+ function masterKey() {
65
+ return crypto.createHash("sha256").update(installKeyMaterial()).digest();
40
66
  }
41
67
  // `workspace` undefined means the account store.
42
68
  function secretsFile(tenant, workspace) {
@@ -50,20 +76,83 @@ function secretsFile(tenant, workspace) {
50
76
  return path.join(base, "secrets.json");
51
77
  }
52
78
  function read(tenant, workspace) {
53
- const f = secretsFile(tenant, workspace);
54
- if (!fs.existsSync(f))
79
+ return readFile(secretsFile(tenant, workspace));
80
+ }
81
+ // ------------------------------------------------------------ safe writes
82
+ //
83
+ // A vault file is read, changed and written back. It was written in place,
84
+ // with no lock: two saves at once kept one; a crash mid-write left half a
85
+ // file, which is an account that cannot authenticate anything; and the boot
86
+ // re-wrap ran in every pod at once through ONE shared temp name, so two pods
87
+ // could interleave into it and rename a corrupt file into place.
88
+ //
89
+ // Now every change goes through mutateVaultFile: an exclusive lock file
90
+ // (O_EXCL, which NFS 4.1 and EFS honour, so it holds across pods), the read
91
+ // and the change under it, a temp file unique to this writer, and a rename —
92
+ // atomic on the same filesystem, so a reader sees the old file or the new
93
+ // one, never half. A lock older than LOCK_STALE_MS is a crashed holder.
94
+ const LOCK_STALE_MS = 30_000;
95
+ const LOCK_WAIT_MS = 10_000;
96
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
97
+ function readFile(file) {
98
+ if (!fs.existsSync(file))
55
99
  return {};
56
100
  try {
57
- return JSON.parse(fs.readFileSync(f, "utf8"));
101
+ return JSON.parse(fs.readFileSync(file, "utf8"));
58
102
  }
59
103
  catch {
60
104
  return {};
61
105
  }
62
106
  }
63
- function write(tenant, workspace, data) {
64
- const f = secretsFile(tenant, workspace);
65
- fs.mkdirSync(path.dirname(f), { recursive: true });
66
- fs.writeFileSync(f, JSON.stringify(data, null, 2), { mode: 0o600 });
107
+ function lockVault(file) {
108
+ const lock = `${file}.lock`;
109
+ const deadline = Date.now() + LOCK_WAIT_MS;
110
+ for (;;) {
111
+ try {
112
+ const fd = fs.openSync(lock, "wx", 0o600);
113
+ fs.writeSync(fd, `${process.pid}@${new Date().toISOString()}`);
114
+ fs.closeSync(fd);
115
+ return () => fs.rmSync(lock, { force: true });
116
+ }
117
+ catch (err) {
118
+ if (err.code !== "EEXIST")
119
+ throw err;
120
+ try {
121
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
122
+ fs.rmSync(lock, { force: true }); // a holder that died; take it over
123
+ continue;
124
+ }
125
+ }
126
+ catch {
127
+ continue; // released between the open and the stat
128
+ }
129
+ if (Date.now() > deadline)
130
+ throw new Error("the vault is busy — another change is being saved; try again");
131
+ sleep(25);
132
+ }
133
+ }
134
+ }
135
+ /**
136
+ * Change one vault file safely: `change` gets what is stored now and returns
137
+ * the new contents, or null to leave the file untouched. Returns what is
138
+ * stored after.
139
+ */
140
+ export function mutateVaultFile(file, change) {
141
+ fs.mkdirSync(path.dirname(file), { recursive: true });
142
+ const release = lockVault(file);
143
+ try {
144
+ const current = readFile(file);
145
+ const next = change(structuredClone(current));
146
+ if (next === null)
147
+ return current;
148
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
149
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
150
+ fs.renameSync(tmp, file);
151
+ return next;
152
+ }
153
+ finally {
154
+ release();
155
+ }
67
156
  }
68
157
  export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
69
158
  export function setSecret(tenant, name, value, workspace, kind) {
@@ -73,13 +162,14 @@ export function setSecret(tenant, name, value, workspace, kind) {
73
162
  if (value.length > 8192)
74
163
  throw new Error("secret too long");
75
164
  const sealed = encryptValue(tenant, value);
76
- const all = read(tenant, workspace);
77
- all[name] = {
78
- ...sealed,
79
- updatedAt: new Date().toISOString(),
80
- ...(kind ? { kind } : {}),
81
- };
82
- write(tenant, workspace, all);
165
+ mutateVaultFile(secretsFile(tenant, workspace), (all) => {
166
+ all[name] = {
167
+ ...sealed,
168
+ updatedAt: new Date().toISOString(),
169
+ ...(kind ? { kind } : {}),
170
+ };
171
+ return all;
172
+ });
83
173
  }
84
174
  /**
85
175
  * Which key writes this account's records.
@@ -98,7 +188,7 @@ function keyFor(tenant) {
98
188
  export function encryptValue(tenant, value) {
99
189
  const { key, mark } = keyFor(tenant);
100
190
  const iv = crypto.randomBytes(12);
101
- const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
191
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
102
192
  const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
103
193
  return {
104
194
  iv: iv.toString("base64"),
@@ -134,7 +224,7 @@ function decrypt(tenant, rec) {
134
224
  else {
135
225
  key = masterKey();
136
226
  }
137
- const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"));
227
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"), GCM);
138
228
  decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
139
229
  return Buffer.concat([
140
230
  decipher.update(Buffer.from(rec.data, "base64")),
@@ -165,9 +255,12 @@ export function getSecret(tenant, name, workspace) {
165
255
  return shared === null ? null : { value: shared, scope: "account" };
166
256
  }
167
257
  export function deleteSecret(tenant, name, workspace) {
168
- const all = read(tenant, workspace);
169
- delete all[name];
170
- write(tenant, workspace, all);
258
+ mutateVaultFile(secretsFile(tenant, workspace), (all) => {
259
+ if (!(name in all))
260
+ return null;
261
+ delete all[name];
262
+ return all;
263
+ });
171
264
  }
172
265
  // Metadata only — never values. Without a workspace this lists the account
173
266
  // store; with one it lists what that workspace actually resolves, marking
@@ -263,47 +356,47 @@ export function rotateMasterKey(tenant, from, to) {
263
356
  const rewritten = [];
264
357
  const unreadable = [];
265
358
  for (const { file } of secretFiles(tenant)) {
266
- const all = JSON.parse(fs.readFileSync(file, "utf8"));
267
- const next = {};
268
359
  let count = 0;
269
- for (const [name, rec] of Object.entries(all)) {
270
- // A record under the ACCOUNT's key is not under the install key, so
271
- // rotating the install key must not touch it — re-encrypting it here
272
- // with a key it was never sealed with is how a rotation would destroy a
273
- // vault it was meant to protect. Rotating the install key now re-wraps
274
- // the account keys instead, which is a handful of small values rather
275
- // than every secret on the box.
276
- if (rec.k === "tenant") {
277
- next[name] = rec;
278
- continue;
279
- }
280
- let plain;
281
- try {
282
- const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"));
283
- d.setAuthTag(Buffer.from(rec.tag, "base64"));
284
- plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
285
- }
286
- catch {
287
- unreadable.push(name);
288
- next[name] = rec; // untouched
289
- continue;
360
+ mutateVaultFile(file, (all) => {
361
+ const next = {};
362
+ for (const [name, rec] of Object.entries(all)) {
363
+ // A record under the ACCOUNT's key is not under the install key, so
364
+ // rotating the install key must not touch it — re-encrypting it here
365
+ // with a key it was never sealed with is how a rotation would destroy a
366
+ // vault it was meant to protect. Rotating the install key now re-wraps
367
+ // the account keys instead, which is a handful of small values rather
368
+ // than every secret on the box.
369
+ if (rec.k === "tenant") {
370
+ next[name] = rec;
371
+ continue;
372
+ }
373
+ let plain;
374
+ try {
375
+ const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
376
+ d.setAuthTag(Buffer.from(rec.tag, "base64"));
377
+ plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
378
+ }
379
+ catch {
380
+ unreadable.push(name);
381
+ next[name] = rec; // untouched
382
+ continue;
383
+ }
384
+ const iv = crypto.randomBytes(12);
385
+ const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
386
+ const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
387
+ next[name] = {
388
+ iv: iv.toString("base64"),
389
+ tag: c.getAuthTag().toString("base64"),
390
+ data: enc.toString("base64"),
391
+ updatedAt: rec.updatedAt, // rotation is not an edit
392
+ // Kept: without it an oauth2 secret stops being refreshed after a
393
+ // rotation, and a file/ssh/api secret stops being materialised.
394
+ ...(rec.kind ? { kind: rec.kind } : {}),
395
+ };
396
+ count++;
290
397
  }
291
- const iv = crypto.randomBytes(12);
292
- const c = crypto.createCipheriv("aes-256-gcm", newKey, iv);
293
- const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
294
- next[name] = {
295
- iv: iv.toString("base64"),
296
- tag: c.getAuthTag().toString("base64"),
297
- data: enc.toString("base64"),
298
- updatedAt: rec.updatedAt, // rotation is not an edit
299
- };
300
- count++;
301
- }
302
- // Write via a temp file in the same directory: a half-written secrets.json
303
- // is an account that cannot authenticate anything.
304
- const tmp = `${file}.rotating`;
305
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
306
- fs.renameSync(tmp, file);
398
+ return next;
399
+ });
307
400
  rewritten.push({ file, secrets: count });
308
401
  }
309
402
  return { rewritten, unreadable };
@@ -347,12 +440,12 @@ async function exchange(config, cacheKey) {
347
440
  client_secret: config.client_secret,
348
441
  ...(config.extra ?? {}),
349
442
  });
350
- const res = await fetch(config.token_url, {
443
+ const res = await fetchUntrusted(config.token_url, {
351
444
  method: "POST",
352
445
  headers: { "content-type": "application/x-www-form-urlencoded" },
353
446
  body: body.toString(),
354
447
  signal: AbortSignal.timeout(15_000),
355
- });
448
+ }, { sameOrigin: true });
356
449
  const payload = (await res.json().catch(() => ({})));
357
450
  if (!res.ok || !payload.access_token) {
358
451
  throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
@@ -376,7 +469,10 @@ async function exchange(config, cacheKey) {
376
469
  * refresh throws with the secret's name and the provider's reason — a run
377
470
  * should fail saying "GOOGLE_ADS_TOKEN: invalid_grant", not 401 somewhere.
378
471
  */
379
- export async function materializeSecrets(env) {
472
+ export async function materializeSecrets(env,
473
+ /** Whose secrets these are, when the caller knows: lets the platform keep
474
+ * each OAuth connection's health and quote a reconnect link on failure. */
475
+ ctx) {
380
476
  const out = { ...env };
381
477
  for (const [name, value] of Object.entries(env)) {
382
478
  if (typeof value !== "string")
@@ -399,8 +495,12 @@ export async function materializeSecrets(env) {
399
495
  out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
400
496
  }
401
497
  catch (err) {
402
- throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
498
+ const message = err instanceof Error ? err.message : String(err);
499
+ const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
500
+ throw new Error(`secret ${name}: ${message}${link ? ` — reconnect it in one click: ${link}` : ""}`);
403
501
  }
502
+ if (ctx)
503
+ await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
404
504
  }
405
505
  else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
406
506
  let config;
@@ -437,7 +537,7 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
437
537
  if (!config[field]?.trim())
438
538
  throw new Error(`service account secret needs ${field}`);
439
539
  }
440
- if (!/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key)) {
540
+ if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
441
541
  throw new Error("private_key must be a PEM key (from the service account JSON)");
442
542
  }
443
543
  setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
@@ -467,7 +567,7 @@ async function exchangeServiceAccount(config, cacheKey) {
467
567
  signer.update(`${header}.${claim}`);
468
568
  const signature = base64url(signer.sign(config.private_key));
469
569
  const jwt = `${header}.${claim}.${signature}`;
470
- const res = await fetch(config.token_url, {
570
+ const res = await fetchUntrusted(config.token_url, {
471
571
  method: "POST",
472
572
  headers: { "content-type": "application/x-www-form-urlencoded" },
473
573
  body: new URLSearchParams({
@@ -475,7 +575,7 @@ async function exchangeServiceAccount(config, cacheKey) {
475
575
  assertion: jwt,
476
576
  }).toString(),
477
577
  signal: AbortSignal.timeout(15_000),
478
- });
578
+ }, { sameOrigin: true });
479
579
  const payload = (await res.json().catch(() => ({})));
480
580
  if (!res.ok || !payload.access_token) {
481
581
  throw new Error(`service account exchange failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token"}`);
@@ -534,7 +634,7 @@ export function setSshSecret(tenant, name, config, workspace) {
534
634
  const hasPassword = Boolean(config.password);
535
635
  if (hasKey === hasPassword)
536
636
  throw new Error("ssh secret needs a private key or a password (not both)");
537
- if (hasKey && !/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key)) {
637
+ if (hasKey && !/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
538
638
  throw new Error("private_key must be a PEM/OpenSSH private key");
539
639
  }
540
640
  if (hasPassword && /[\n\r]/.test(config.password))
@@ -571,7 +671,7 @@ export function setApiSecret(tenant, name, config, workspace) {
571
671
  const base = config.base_url?.trim();
572
672
  if (base && !/^https?:\/\//.test(base))
573
673
  throw new Error("base_url must be http(s)");
574
- setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: base.replace(/\/+$/, "") } : {}), headers: config.headers }), workspace, "api");
674
+ setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api");
575
675
  }
576
676
  export function isApiValue(value) {
577
677
  return value.startsWith(API_PREFIX);
@@ -594,33 +694,34 @@ export function rewrapTenantSecrets(tenant) {
594
694
  let moved = 0;
595
695
  const unreadable = [];
596
696
  for (const { file } of secretFiles(tenant)) {
597
- const all = JSON.parse(fs.readFileSync(file, "utf8"));
598
- const plain = new Map();
599
- let pending = 0;
600
- for (const [name, rec] of Object.entries(all)) {
601
- if (rec.k === "tenant")
602
- continue; // already moved
603
- const value = decrypt(tenant, rec);
604
- if (value === null) {
605
- unreadable.push(name);
606
- continue;
697
+ // Under the vault's lock: every pod runs this at boot, and a save made
698
+ // between our read and our write must not be lost.
699
+ mutateVaultFile(file, (all) => {
700
+ const plain = new Map();
701
+ const unreadableHere = [];
702
+ for (const [name, rec] of Object.entries(all)) {
703
+ if (rec.k === "tenant")
704
+ continue; // already moved
705
+ const value = decrypt(tenant, rec);
706
+ if (value === null) {
707
+ unreadableHere.push(name);
708
+ continue;
709
+ }
710
+ plain.set(name, value);
607
711
  }
608
- plain.set(name, value);
609
- pending += 1;
610
- }
611
- // Nothing to do, or something could not be read — leave the file alone.
612
- // A partial conversion is worse than none, because the half that moved is
613
- // no longer openable by the key the other half still needs.
614
- if (pending === 0 || unreadable.length)
615
- continue;
616
- const next = { ...all };
617
- for (const [name, value] of plain) {
618
- next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
619
- }
620
- const tmp = `${file}.rewrap`;
621
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
622
- fs.renameSync(tmp, file);
623
- moved += pending;
712
+ unreadable.push(...unreadableHere);
713
+ // Nothing to do, or something in THIS file could not be read — leave it
714
+ // alone. A partial conversion is worse than none, because the half that
715
+ // moved is no longer openable by the key the other half still needs.
716
+ if (plain.size === 0 || unreadableHere.length)
717
+ return null;
718
+ const next = { ...all };
719
+ for (const [name, value] of plain) {
720
+ next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
721
+ }
722
+ moved += plain.size;
723
+ return next;
724
+ });
624
725
  }
625
726
  return { moved, unreadable };
626
727
  }
@@ -18,4 +18,13 @@ export interface StarterFile {
18
18
  * workspace somewhere would be worse than no starter.
19
19
  */
20
20
  export declare function accountFiles(account: string): StarterFile[];
21
+ /**
22
+ * What an account folder ignores, at its own root.
23
+ *
24
+ * The workspace scaffold has always shipped one of these; an account folder is
25
+ * the thing that gets committed now, and the key that decrypts its secrets
26
+ * lives at ITS root — `<account>/.foldrun/` — so the guard has to be here too
27
+ * or the first `git add .` commits the vault key.
28
+ */
29
+ export declare function accountGitignore(): StarterFile;
21
30
  export declare function starterFiles(workspace: string): StarterFile[];
@@ -64,6 +64,36 @@ dropped further down.
64
64
  },
65
65
  ];
66
66
  }
67
+ /**
68
+ * What an account folder ignores, at its own root.
69
+ *
70
+ * The workspace scaffold has always shipped one of these; an account folder is
71
+ * the thing that gets committed now, and the key that decrypts its secrets
72
+ * lives at ITS root — `<account>/.foldrun/` — so the guard has to be here too
73
+ * or the first `git add .` commits the vault key.
74
+ */
75
+ export function accountGitignore() {
76
+ return {
77
+ path: ".gitignore",
78
+ content: `# The key that decrypts every secret in this account, plus the local run
79
+ # store. Never commit these.
80
+ .foldrun/
81
+
82
+ # Written by runs, not by you.
83
+ workspaces/*/runs/
84
+ workspaces/*/outputs/
85
+ state/
86
+ storage/
87
+ # The link a running step keeps in its agent folder (workspace/ for the
88
+ # shell); removed when the step ends, ignored in case one is left behind.
89
+ workspaces/*/agents/*/workspace
90
+
91
+ .env
92
+ .env.local
93
+ .DS_Store
94
+ `,
95
+ };
96
+ }
67
97
  export function starterFiles(workspace) {
68
98
  return [
69
99
  {
@@ -98,6 +128,9 @@ Context every agent here shares. Prices, rules, anything they should all know.
98
128
  # Written by runs, not by you.
99
129
  runs/
100
130
  outputs/
131
+ # The link a running step keeps in its agent folder (workspace/ for the
132
+ # shell); removed when the step ends, ignored in case one is left behind.
133
+ agents/*/workspace
101
134
 
102
135
  .env
103
136
  .env.local
@@ -114,6 +147,8 @@ effort: high
114
147
  tools:
115
148
  - web
116
149
  - read
150
+ web:
151
+ actions: [search, fetch]
117
152
  ---
118
153
 
119
154
  Pick exactly one topic, in a short paragraph, and say who it helps and why now.
@@ -126,7 +161,7 @@ Do not write the article.
126
161
  name: writer
127
162
  description: Turns a brief into a short draft.
128
163
  tools:
129
- - files
164
+ - write
130
165
  ---
131
166
 
132
167
  Write a short draft from the brief you were given. Save it to outputs/draft.md.
@@ -209,6 +244,10 @@ foldrun deploy --url <server> --token $FOLDRUN_TOKEN
209
244
  \`check\` is cheap and catches a broken flow before a schedule fires it at 3am.
210
245
  Run it after edits, the way you would run a typecheck.
211
246
 
247
+ A deploy that would delete files this folder no longer has lists them and
248
+ stops: with no terminal it is refused. Tell the person what it would delete; add \`--yes\`
249
+ only when they asked for exactly that deletion.
250
+
212
251
  ## Where things go
213
252
 
214
253
  | Path | What |
@@ -231,10 +270,23 @@ Regenerated every run means a file; accumulated across runs means \`state/\`.
231
270
  That last row is worth knowing: a subagent you write in Claude Code is imported
232
271
  as \`agents/<name>/agent.md\` at deploy, no conversion. Write it either way.
233
272
 
273
+ ## Paths inside a workspace
274
+
275
+ An agent's working directory is \`agents/<name>/\`. Write every path to the
276
+ workspace's own folders as \`workspace/…\` — \`workspace/storage/report.csv\`,
277
+ \`workspace/state/cursor.json\` — in an agent's prose, in flow options
278
+ (\`each: rows of\`, \`when: rows of\`, \`verify:\`, \`preview:\`, \`schema:\`) and in
279
+ script arguments. It resolves the same in the file tools, the shell, scripts
280
+ and checks. The older \`../../storage/…\` reaches the same place and still
281
+ works; \`foldrun check\` suggests the new spelling where it sees it.
282
+ A Node script started as \`node workspace/tools/x/run.mjs\` is its own main
283
+ module there, so its \`import.meta.url\` entry check runs (it did not before
284
+ 2 Oct 2026 and exited 0 doing nothing).
285
+
234
286
  ## What a deploy does NOT touch
235
287
 
236
- \`runs/\`, \`state/\`, \`secrets.json\`, and any memory an agent wrote that your
237
- push does not mention. Those belong to the platform, not to git — a deploy that
288
+ \`runs/\`, \`state/\`, \`storage/\`, \`secrets.json\`, the trigger log, and any
289
+ memory an agent wrote that your push does not mention. Those belong to the platform, not to git — a deploy that
238
290
  reverted what an agent learned would make every run a little dumber.
239
291
 
240
292
  Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
@@ -270,19 +322,32 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
270
322
  | \`/runs/<id>/stop\` | POST | kill it — destroys the sandbox too |
271
323
  | \`/runs/<id>/rerun\` | POST | again, or from a chosen step |
272
324
  | \`/runs/<id>/approve\` | POST | release a run waiting on a human |
325
+ | \`/runs/<id>/answer\` | POST | answer a question an agent is asking mid-step (\`tools: [ask]\`) |
326
+ | \`/runs/<id>/message\` | POST | tell a running step something; \`step\` picks one of a parallel group |
273
327
  | \`/flows\` · \`/flows/<f>\` | GET POST · POST DELETE PATCH | list, create, edit |
274
328
  | \`/flows/<f>/run\` | POST | start a flow |
275
329
  | \`/agents\` · \`/agents/<a>/run\` | GET POST · POST | list agents, run one |
276
330
  | \`/evals\` · \`/evals/<e>/run\` | GET POST · POST | list evals, run one |
277
331
  | \`/tools/<t>/test\` | POST | exercise one tool alone |
278
- | \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` | GET POST PUT DELETE · GET · POST | workspace files |
332
+ | \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` · \`/storage/preview\` | GET POST PUT DELETE · GET · POST · GET | workspace files, any type; \`preview\` returns one parsed for reading |
279
333
  | \`/assets\` | POST | upload an asset |
280
334
  | \`/hooks/<f>/rotate\` | POST | new webhook token for a flow |
335
+ | \`/notify/deliveries\` · \`/notify/deliveries/<id>/redeliver\` | GET · POST | every notify: webhook sent and each attempt; send one again |
336
+ | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
281
337
  | \`/vocabulary\` | GET | what this workspace's documents may say |
282
338
 
283
339
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
284
340
  \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
285
- \`/api/healthz\` (open) and \`/api/metrics\` (Prometheus).
341
+ \`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
342
+ \`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
343
+ \`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
344
+ and \`/api/metrics\` (Prometheus).
345
+
346
+ Every call answers — and the CLI sends — \`Foldrun-Version: YYYY-MM-DD\` (dated API
347
+ versions; an account pin in Settings → API), \`X-RateLimit-*\` headers (a \`429\`
348
+ carries \`Retry-After\`), and writes that start work take an \`Idempotency-Key\`
349
+ (a retry with the same key replays the first answer). The whole surface as
350
+ OpenAPI 3.1: \`/api/openapi.json\` (open), or \`foldrun api spec --out openapi.json\`.
286
351
 
287
352
  Debugging a failed run is usually: \`runs\` to find it, \`runs/<id>\` to read which
288
353
  step failed and what it cost, fix the markdown, \`deploy\`, then \`rerun\`.
@@ -295,6 +360,11 @@ git push endpoint.
295
360
 
296
361
  A numbered list of steps. The number is the GROUP: same number means those
297
362
  steps run in parallel, and the flow waits for all of them before the next.
363
+ The dashboard's canvas and the CLI edit the same file: \`foldrun flow show
364
+ <flow>\` draws it, \`foldrun flow add <flow> <pattern>\` inserts a pattern
365
+ (chain, parallel, router, fan-out, loop, approval, ask, wait, rescue,
366
+ subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
367
+ <agent> --subagent|--consult|--can-ask\` wires agents together.
298
368
 
299
369
  \`\`\`markdown
300
370
  ---
@@ -310,7 +380,7 @@ priority: normal # high | normal | low — where its runs stand in the q
310
380
  1. [[researcher]] — Find three sources on {{topic}}.
311
381
  2. [[writer]] — Draft from what the researcher found.
312
382
  model: max
313
- verify: the draft cites every source
383
+ verify: judge: the draft cites every source
314
384
  3?. [[editor]] — Tighten it.
315
385
  4!. [[publisher]] — Publish it.
316
386
  \`\`\`
@@ -323,11 +393,14 @@ Indented under a step, any of:
323
393
  | | |
324
394
  |---|---|
325
395
  | \`approve: true\` | stop for a human (\`!\` is shorthand for this) |
326
- | \`when: <condition>\` | run only if it holds |
396
+ | \`when: <MARKER>\` | run only if a line of the previous result begins with it |
397
+ | \`when: rows of <path>\` | run only if that CSV has a data row — for the steps after an \`each:\` |
327
398
  | \`case: <value>\` / \`else: true\` | branch on the previous step's result |
328
399
  | \`retry: 2\` | re-run on failure, up to 5 |
329
400
  | \`timeout: 900\` | seconds before the step is cut off |
330
- | \`verify: <claim>\` | the step fails unless this is true of its output |
401
+ | \`verify: <shell>\` | the step fails unless the command exits 0; the reply is in \`$FOLDRUN_REPLY_FILE\` |
402
+ | \`verify: judge: <claim>\` | a model grades the claim against the output |
403
+ | \`verify: contains:\` / \`not-contains:\` / \`matches:\` / \`file:\` | the cheap checks, no shell |
331
404
  | \`model:\` / \`effort:\` | override the flow's default for this step |
332
405
  | \`loop: 3\` / \`until: <condition>\` | repeat until it holds, at most 5 |
333
406
  | \`each: lines\` | run once per line of the previous result |
@@ -340,7 +413,11 @@ Indented under a step, any of:
340
413
  | \`preview: draft/*.mdx\` | what the approval box shows — paths under \`storage/\`, globs allowed |
341
414
  | \`delegate: [[a]], [[b]]\` | hand the step to whichever fits, up to 5 |
342
415
  | \`output: json\` | the reply ends with one JSON value; the next step gets it as data, \`each: items\` fans out over it |
416
+ | \`schema: {...}\` | with \`output: json\` — the JSON Schema the value must satisfy; inline, a YAML block under the key, or a file path |
343
417
  | \`each: items\` | run once per element of the previous step's JSON array |
418
+ | \`parallel: 3\` | how many \`each:\` instances run at once — default all |
419
+ | \`max_turns: 20\` | the most model turns before the step is stopped, beside \`budget:\` and \`timeout:\` |
420
+ | \`limits: {web.search: 10}\` | this step's call limits, over the agent's key by key |
344
421
 
345
422
  ## Writing an agent
346
423
 
@@ -354,14 +431,17 @@ description: Finds and summarises sources.
354
431
  model: default # fast | default | max
355
432
  effort: low # how hard to think about it
356
433
  size: large # small | large | heavy — the sandbox it rents
357
- tools: [WebSearch, Read, my-folder-tool] # built-ins and your own tools/, one list
434
+ tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
358
435
  disallowedTools: [Bash] # subtract from what it would otherwise have
359
436
  skills: [house-style] # from skills/
360
437
  scripts: [summarise.py] # from scripts/, each becomes a callable tool
361
438
  apis: [{ name: crm }] # an HTTP API, as a tool
362
439
  mcpServers: {} # an MCP server, as a tool
363
- agents: [writer] # colleagues this one may consult
364
- secrets: [CRM_TOKEN] # from the vault — never written in these files
440
+ agents: [writer] # colleagues this one may consult (an answer, no tools)
441
+ subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
442
+ secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
443
+ limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
444
+ timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
365
445
  provider: {} # BYOK: your own model credential
366
446
  permissionMode: plan # plan first, act once approved
367
447
  runtime: # only if scripts need packages
@@ -374,6 +454,10 @@ You research a topic and report what you found...
374
454
  Only \`name\` and the prose are required. Every other field widens or narrows
375
455
  what the agent can reach, and the default is narrow.
376
456
 
457
+ \`timezone:\` is nearest-wins all the way down: the agent's own, else its
458
+ flow's, else the workspace's AGENTS.md, else the account's, else UTC. It sets
459
+ \`TZ\` and the date the agent is told, so a Sydney desk stamps the Sydney date.
460
+
377
461
  \`runtime:\` installs pip/npm packages in the sandbox each agent runs in. Pin
378
462
  versions the normal way (\`pandas>=2\`, \`lodash@^4\`). Python and Node are
379
463
  supported; anything else should ship as a committed binary.