@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
package/src/secrets.ts CHANGED
@@ -23,26 +23,54 @@ 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";
27
+
28
+ /**
29
+ * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
30
+ * `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
31
+ * 12, 13, 14, 15 or 16 bytes — so a stored record whose tag had been cut to
32
+ * four bytes would still "verify" on 32 bits of authentication instead of
33
+ * 128. The tag comes from the record on disk; the record is the thing an
34
+ * attacker with file access would edit. One option makes a short tag an
35
+ * error rather than a weaker check.
36
+ */
37
+ const GCM = { authTagLength: 16 } as const;
26
38
 
27
39
  // The account's own data key, when the platform holds one; the install key
28
40
  // otherwise. See platform.ts — locally there is no account key.
29
41
  const tenantKey = (tenant: string) => platform.tenantKey(tenant);
30
42
  import crypto from "node:crypto";
31
43
  import { assertSafeName } from "./store.ts";
44
+ import { trimSlashes } from "./paths.ts";
32
45
 
33
46
  const keyFile = () => path.join(dataRoot(), ".secret-key");
34
47
 
35
48
  /** Which store a secret came from. */
36
49
  export type SecretScope = "account" | "workspace";
37
50
 
38
- function masterKey(): Buffer {
51
+ /**
52
+ * The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
53
+ * contents of `.secret-key` under the data root — created here, the first
54
+ * time anything asks, so there is never a moment when a derived token has
55
+ * nothing real to hang off. The vault, hook tokens, approval links and git
56
+ * push secrets all derive from this one value; rotating it invalidates every
57
+ * one of them together. Exported for webhook.ts, which used to fall back to
58
+ * a fixed dev string until the vault happened to create this file — so hook
59
+ * URLs were computable by anyone who read the source, and then changed the
60
+ * first time a secret was stored.
61
+ */
62
+ export function installKeyMaterial(): string {
39
63
  const fromEnv = process.env.FOLDRUN_SECRET_KEY;
40
- if (fromEnv) return crypto.createHash("sha256").update(fromEnv).digest();
64
+ if (fromEnv) return fromEnv;
41
65
  fs.mkdirSync(dataRoot(), { recursive: true });
42
66
  if (!fs.existsSync(keyFile())) {
43
67
  fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
44
68
  }
45
- return crypto.createHash("sha256").update(fs.readFileSync(keyFile(), "utf8")).digest();
69
+ return fs.readFileSync(keyFile(), "utf8");
70
+ }
71
+
72
+ function masterKey(): Buffer {
73
+ return crypto.createHash("sha256").update(installKeyMaterial()).digest();
46
74
  }
47
75
 
48
76
  // `workspace` undefined means the account store.
@@ -76,19 +104,80 @@ interface StoredSecret {
76
104
  type SecretsFile = Record<string, StoredSecret>;
77
105
 
78
106
  function read(tenant: string, workspace?: string): SecretsFile {
79
- const f = secretsFile(tenant, workspace);
80
- 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 {};
81
130
  try {
82
- return JSON.parse(fs.readFileSync(f, "utf8"));
131
+ return JSON.parse(fs.readFileSync(file, "utf8"));
83
132
  } catch {
84
133
  return {};
85
134
  }
86
135
  }
87
136
 
88
- function write(tenant: string, workspace: string | undefined, data: SecretsFile) {
89
- const f = secretsFile(tenant, workspace);
90
- fs.mkdirSync(path.dirname(f), { recursive: true });
91
- 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
+ }
92
181
  }
93
182
 
94
183
  export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
@@ -105,13 +194,14 @@ export function setSecret(
105
194
  }
106
195
  if (value.length > 8192) throw new Error("secret too long");
107
196
  const sealed = encryptValue(tenant, value);
108
- const all = read(tenant, workspace);
109
- all[name] = {
110
- ...sealed,
111
- updatedAt: new Date().toISOString(),
112
- ...(kind ? { kind } : {}),
113
- };
114
- 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
+ });
115
205
  }
116
206
 
117
207
  /**
@@ -135,7 +225,7 @@ export function encryptValue(
135
225
  ): { iv: string; tag: string; data: string; k?: "tenant" } {
136
226
  const { key, mark } = keyFor(tenant);
137
227
  const iv = crypto.randomBytes(12);
138
- const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
228
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
139
229
  const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
140
230
  return {
141
231
  iv: iv.toString("base64"),
@@ -180,6 +270,7 @@ function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
180
270
  "aes-256-gcm",
181
271
  key,
182
272
  Buffer.from(rec.iv, "base64"),
273
+ GCM,
183
274
  );
184
275
  decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
185
276
  return Buffer.concat([
@@ -214,9 +305,11 @@ export function getSecret(
214
305
  }
215
306
 
216
307
  export function deleteSecret(tenant: string, name: string, workspace?: string) {
217
- const all = read(tenant, workspace);
218
- delete all[name];
219
- 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
+ });
220
313
  }
221
314
 
222
315
  export interface SecretEntry {
@@ -340,48 +433,46 @@ export function rotateMasterKey(
340
433
  const unreadable: string[] = [];
341
434
 
342
435
  for (const { file } of secretFiles(tenant)) {
343
- const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
344
- const next: SecretsFile = {};
345
436
  let count = 0;
346
-
347
- for (const [name, rec] of Object.entries(all)) {
348
- // A record under the ACCOUNT's key is not under the install key, so
349
- // rotating the install key must not touch it — re-encrypting it here
350
- // with a key it was never sealed with is how a rotation would destroy a
351
- // vault it was meant to protect. Rotating the install key now re-wraps
352
- // the account keys instead, which is a handful of small values rather
353
- // than every secret on the box.
354
- if (rec.k === "tenant") {
355
- next[name] = rec;
356
- 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++;
357
473
  }
358
- let plain: string;
359
- try {
360
- const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"));
361
- d.setAuthTag(Buffer.from(rec.tag, "base64"));
362
- plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
363
- } catch {
364
- unreadable.push(name);
365
- next[name] = rec; // untouched
366
- continue;
367
- }
368
- const iv = crypto.randomBytes(12);
369
- const c = crypto.createCipheriv("aes-256-gcm", newKey, iv);
370
- const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
371
- next[name] = {
372
- iv: iv.toString("base64"),
373
- tag: c.getAuthTag().toString("base64"),
374
- data: enc.toString("base64"),
375
- updatedAt: rec.updatedAt, // rotation is not an edit
376
- };
377
- count++;
378
- }
379
-
380
- // Write via a temp file in the same directory: a half-written secrets.json
381
- // is an account that cannot authenticate anything.
382
- const tmp = `${file}.rotating`;
383
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
384
- fs.renameSync(tmp, file);
474
+ return next;
475
+ });
385
476
  rewritten.push({ file, secrets: count });
386
477
  }
387
478
 
@@ -459,12 +550,12 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
459
550
  client_secret: config.client_secret,
460
551
  ...(config.extra ?? {}),
461
552
  });
462
- const res = await fetch(config.token_url, {
553
+ const res = await fetchUntrusted(config.token_url, {
463
554
  method: "POST",
464
555
  headers: { "content-type": "application/x-www-form-urlencoded" },
465
556
  body: body.toString(),
466
557
  signal: AbortSignal.timeout(15_000),
467
- });
558
+ }, { sameOrigin: true });
468
559
  const payload = (await res.json().catch(() => ({}))) as {
469
560
  access_token?: string;
470
561
  expires_in?: number;
@@ -498,6 +589,9 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
498
589
  */
499
590
  export async function materializeSecrets(
500
591
  env: Record<string, string>,
592
+ /** Whose secrets these are, when the caller knows: lets the platform keep
593
+ * each OAuth connection's health and quote a reconnect link on failure. */
594
+ ctx?: { tenant: string; workspace?: string },
501
595
  ): Promise<Record<string, string>> {
502
596
  const out: Record<string, string> = { ...env };
503
597
  for (const [name, value] of Object.entries(env)) {
@@ -518,8 +612,11 @@ export async function materializeSecrets(
518
612
  const fp = crypto.createHash("sha256").update(grantMaterial).digest("hex").slice(0, 16);
519
613
  out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
520
614
  } catch (err) {
521
- throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
615
+ const message = err instanceof Error ? err.message : String(err);
616
+ const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
617
+ throw new Error(`secret ${name}: ${message}${link ? ` — reconnect it in one click: ${link}` : ""}`);
522
618
  }
619
+ if (ctx) await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
523
620
  } else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
524
621
  let config: ServiceAccountConfig;
525
622
  try {
@@ -570,7 +667,7 @@ export function setServiceAccountSecret(
570
667
  for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
571
668
  if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
572
669
  }
573
- if (!/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key)) {
670
+ if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
574
671
  throw new Error("private_key must be a PEM key (from the service account JSON)");
575
672
  }
576
673
  setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
@@ -604,7 +701,7 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
604
701
  const signature = base64url(signer.sign(config.private_key));
605
702
  const jwt = `${header}.${claim}.${signature}`;
606
703
 
607
- const res = await fetch(config.token_url, {
704
+ const res = await fetchUntrusted(config.token_url, {
608
705
  method: "POST",
609
706
  headers: { "content-type": "application/x-www-form-urlencoded" },
610
707
  body: new URLSearchParams({
@@ -612,7 +709,7 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
612
709
  assertion: jwt,
613
710
  }).toString(),
614
711
  signal: AbortSignal.timeout(15_000),
615
- });
712
+ }, { sameOrigin: true });
616
713
  const payload = (await res.json().catch(() => ({}))) as {
617
714
  access_token?: string;
618
715
  expires_in?: number;
@@ -692,7 +789,7 @@ export function setSshSecret(tenant: string, name: string, config: SshConfig, wo
692
789
  const hasKey = Boolean(config.private_key?.trim());
693
790
  const hasPassword = Boolean(config.password);
694
791
  if (hasKey === hasPassword) throw new Error("ssh secret needs a private key or a password (not both)");
695
- if (hasKey && !/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key!)) {
792
+ if (hasKey && !/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key!)) {
696
793
  throw new Error("private_key must be a PEM/OpenSSH private key");
697
794
  }
698
795
  if (hasPassword && /[\n\r]/.test(config.password!)) throw new Error("ssh password cannot contain newlines");
@@ -737,7 +834,7 @@ export function setApiSecret(tenant: string, name: string, config: ApiConfig, wo
737
834
  if (base && !/^https?:\/\//.test(base)) throw new Error("base_url must be http(s)");
738
835
  setSecret(
739
836
  tenant, name,
740
- API_PREFIX + JSON.stringify({ ...(base ? { base_url: base.replace(/\/+$/, "") } : {}), headers: config.headers }),
837
+ API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }),
741
838
  workspace, "api",
742
839
  );
743
840
  }
@@ -767,33 +864,32 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
767
864
  const unreadable: string[] = [];
768
865
 
769
866
  for (const { file } of secretFiles(tenant)) {
770
- const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
771
- const plain = new Map<string, string>();
772
- let pending = 0;
773
-
774
- for (const [name, rec] of Object.entries(all)) {
775
- if (rec.k === "tenant") continue; // already moved
776
- const value = decrypt(tenant, rec);
777
- if (value === null) {
778
- unreadable.push(name);
779
- 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);
780
880
  }
781
- plain.set(name, value);
782
- pending += 1;
783
- }
784
- // Nothing to do, or something could not be read — leave the file alone.
785
- // A partial conversion is worse than none, because the half that moved is
786
- // no longer openable by the key the other half still needs.
787
- if (pending === 0 || unreadable.length) continue;
788
-
789
- const next: SecretsFile = { ...all };
790
- for (const [name, value] of plain) {
791
- next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
792
- }
793
- const tmp = `${file}.rewrap`;
794
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
795
- fs.renameSync(tmp, file);
796
- 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
+ });
797
893
  }
798
894
  return { moved, unreadable };
799
895
  }
package/src/starter.ts CHANGED
@@ -72,6 +72,37 @@ dropped further down.
72
72
  ];
73
73
  }
74
74
 
75
+ /**
76
+ * What an account folder ignores, at its own root.
77
+ *
78
+ * The workspace scaffold has always shipped one of these; an account folder is
79
+ * the thing that gets committed now, and the key that decrypts its secrets
80
+ * lives at ITS root — `<account>/.foldrun/` — so the guard has to be here too
81
+ * or the first `git add .` commits the vault key.
82
+ */
83
+ export function accountGitignore(): StarterFile {
84
+ return {
85
+ path: ".gitignore",
86
+ content: `# The key that decrypts every secret in this account, plus the local run
87
+ # store. Never commit these.
88
+ .foldrun/
89
+
90
+ # Written by runs, not by you.
91
+ workspaces/*/runs/
92
+ workspaces/*/outputs/
93
+ state/
94
+ storage/
95
+ # The link a running step keeps in its agent folder (workspace/ for the
96
+ # shell); removed when the step ends, ignored in case one is left behind.
97
+ workspaces/*/agents/*/workspace
98
+
99
+ .env
100
+ .env.local
101
+ .DS_Store
102
+ `,
103
+ };
104
+ }
105
+
75
106
  export function starterFiles(workspace: string): StarterFile[] {
76
107
  return [
77
108
  {
@@ -107,6 +138,9 @@ Context every agent here shares. Prices, rules, anything they should all know.
107
138
  # Written by runs, not by you.
108
139
  runs/
109
140
  outputs/
141
+ # The link a running step keeps in its agent folder (workspace/ for the
142
+ # shell); removed when the step ends, ignored in case one is left behind.
143
+ agents/*/workspace
110
144
 
111
145
  .env
112
146
  .env.local
@@ -124,6 +158,8 @@ effort: high
124
158
  tools:
125
159
  - web
126
160
  - read
161
+ web:
162
+ actions: [search, fetch]
127
163
  ---
128
164
 
129
165
  Pick exactly one topic, in a short paragraph, and say who it helps and why now.
@@ -137,7 +173,7 @@ Do not write the article.
137
173
  name: writer
138
174
  description: Turns a brief into a short draft.
139
175
  tools:
140
- - files
176
+ - write
141
177
  ---
142
178
 
143
179
  Write a short draft from the brief you were given. Save it to outputs/draft.md.
@@ -224,6 +260,10 @@ foldrun deploy --url <server> --token $FOLDRUN_TOKEN
224
260
  \`check\` is cheap and catches a broken flow before a schedule fires it at 3am.
225
261
  Run it after edits, the way you would run a typecheck.
226
262
 
263
+ A deploy that would delete files this folder no longer has lists them and
264
+ stops: with no terminal it is refused. Tell the person what it would delete; add \`--yes\`
265
+ only when they asked for exactly that deletion.
266
+
227
267
  ## Where things go
228
268
 
229
269
  | Path | What |
@@ -246,10 +286,23 @@ Regenerated every run means a file; accumulated across runs means \`state/\`.
246
286
  That last row is worth knowing: a subagent you write in Claude Code is imported
247
287
  as \`agents/<name>/agent.md\` at deploy, no conversion. Write it either way.
248
288
 
289
+ ## Paths inside a workspace
290
+
291
+ An agent's working directory is \`agents/<name>/\`. Write every path to the
292
+ workspace's own folders as \`workspace/…\` — \`workspace/storage/report.csv\`,
293
+ \`workspace/state/cursor.json\` — in an agent's prose, in flow options
294
+ (\`each: rows of\`, \`when: rows of\`, \`verify:\`, \`preview:\`, \`schema:\`) and in
295
+ script arguments. It resolves the same in the file tools, the shell, scripts
296
+ and checks. The older \`../../storage/…\` reaches the same place and still
297
+ works; \`foldrun check\` suggests the new spelling where it sees it.
298
+ A Node script started as \`node workspace/tools/x/run.mjs\` is its own main
299
+ module there, so its \`import.meta.url\` entry check runs (it did not before
300
+ 2 Oct 2026 and exited 0 doing nothing).
301
+
249
302
  ## What a deploy does NOT touch
250
303
 
251
- \`runs/\`, \`state/\`, \`secrets.json\`, and any memory an agent wrote that your
252
- push does not mention. Those belong to the platform, not to git — a deploy that
304
+ \`runs/\`, \`state/\`, \`storage/\`, \`secrets.json\`, the trigger log, and any
305
+ memory an agent wrote that your push does not mention. Those belong to the platform, not to git — a deploy that
253
306
  reverted what an agent learned would make every run a little dumber.
254
307
 
255
308
  Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
@@ -285,19 +338,32 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
285
338
  | \`/runs/<id>/stop\` | POST | kill it — destroys the sandbox too |
286
339
  | \`/runs/<id>/rerun\` | POST | again, or from a chosen step |
287
340
  | \`/runs/<id>/approve\` | POST | release a run waiting on a human |
341
+ | \`/runs/<id>/answer\` | POST | answer a question an agent is asking mid-step (\`tools: [ask]\`) |
342
+ | \`/runs/<id>/message\` | POST | tell a running step something; \`step\` picks one of a parallel group |
288
343
  | \`/flows\` · \`/flows/<f>\` | GET POST · POST DELETE PATCH | list, create, edit |
289
344
  | \`/flows/<f>/run\` | POST | start a flow |
290
345
  | \`/agents\` · \`/agents/<a>/run\` | GET POST · POST | list agents, run one |
291
346
  | \`/evals\` · \`/evals/<e>/run\` | GET POST · POST | list evals, run one |
292
347
  | \`/tools/<t>/test\` | POST | exercise one tool alone |
293
- | \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` | GET POST PUT DELETE · GET · POST | workspace files |
348
+ | \`/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 |
294
349
  | \`/assets\` | POST | upload an asset |
295
350
  | \`/hooks/<f>/rotate\` | POST | new webhook token for a flow |
351
+ | \`/notify/deliveries\` · \`/notify/deliveries/<id>/redeliver\` | GET · POST | every notify: webhook sent and each attempt; send one again |
352
+ | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
296
353
  | \`/vocabulary\` | GET | what this workspace's documents may say |
297
354
 
298
355
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
299
356
  \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
300
- \`/api/healthz\` (open) and \`/api/metrics\` (Prometheus).
357
+ \`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
358
+ \`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
359
+ \`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
360
+ and \`/api/metrics\` (Prometheus).
361
+
362
+ Every call answers — and the CLI sends — \`Foldrun-Version: YYYY-MM-DD\` (dated API
363
+ versions; an account pin in Settings → API), \`X-RateLimit-*\` headers (a \`429\`
364
+ carries \`Retry-After\`), and writes that start work take an \`Idempotency-Key\`
365
+ (a retry with the same key replays the first answer). The whole surface as
366
+ OpenAPI 3.1: \`/api/openapi.json\` (open), or \`foldrun api spec --out openapi.json\`.
301
367
 
302
368
  Debugging a failed run is usually: \`runs\` to find it, \`runs/<id>\` to read which
303
369
  step failed and what it cost, fix the markdown, \`deploy\`, then \`rerun\`.
@@ -310,6 +376,11 @@ git push endpoint.
310
376
 
311
377
  A numbered list of steps. The number is the GROUP: same number means those
312
378
  steps run in parallel, and the flow waits for all of them before the next.
379
+ The dashboard's canvas and the CLI edit the same file: \`foldrun flow show
380
+ <flow>\` draws it, \`foldrun flow add <flow> <pattern>\` inserts a pattern
381
+ (chain, parallel, router, fan-out, loop, approval, ask, wait, rescue,
382
+ subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
383
+ <agent> --subagent|--consult|--can-ask\` wires agents together.
313
384
 
314
385
  \`\`\`markdown
315
386
  ---
@@ -325,7 +396,7 @@ priority: normal # high | normal | low — where its runs stand in the q
325
396
  1. [[researcher]] — Find three sources on {{topic}}.
326
397
  2. [[writer]] — Draft from what the researcher found.
327
398
  model: max
328
- verify: the draft cites every source
399
+ verify: judge: the draft cites every source
329
400
  3?. [[editor]] — Tighten it.
330
401
  4!. [[publisher]] — Publish it.
331
402
  \`\`\`
@@ -338,11 +409,14 @@ Indented under a step, any of:
338
409
  | | |
339
410
  |---|---|
340
411
  | \`approve: true\` | stop for a human (\`!\` is shorthand for this) |
341
- | \`when: <condition>\` | run only if it holds |
412
+ | \`when: <MARKER>\` | run only if a line of the previous result begins with it |
413
+ | \`when: rows of <path>\` | run only if that CSV has a data row — for the steps after an \`each:\` |
342
414
  | \`case: <value>\` / \`else: true\` | branch on the previous step's result |
343
415
  | \`retry: 2\` | re-run on failure, up to 5 |
344
416
  | \`timeout: 900\` | seconds before the step is cut off |
345
- | \`verify: <claim>\` | the step fails unless this is true of its output |
417
+ | \`verify: <shell>\` | the step fails unless the command exits 0; the reply is in \`$FOLDRUN_REPLY_FILE\` |
418
+ | \`verify: judge: <claim>\` | a model grades the claim against the output |
419
+ | \`verify: contains:\` / \`not-contains:\` / \`matches:\` / \`file:\` | the cheap checks, no shell |
346
420
  | \`model:\` / \`effort:\` | override the flow's default for this step |
347
421
  | \`loop: 3\` / \`until: <condition>\` | repeat until it holds, at most 5 |
348
422
  | \`each: lines\` | run once per line of the previous result |
@@ -355,7 +429,11 @@ Indented under a step, any of:
355
429
  | \`preview: draft/*.mdx\` | what the approval box shows — paths under \`storage/\`, globs allowed |
356
430
  | \`delegate: [[a]], [[b]]\` | hand the step to whichever fits, up to 5 |
357
431
  | \`output: json\` | the reply ends with one JSON value; the next step gets it as data, \`each: items\` fans out over it |
432
+ | \`schema: {...}\` | with \`output: json\` — the JSON Schema the value must satisfy; inline, a YAML block under the key, or a file path |
358
433
  | \`each: items\` | run once per element of the previous step's JSON array |
434
+ | \`parallel: 3\` | how many \`each:\` instances run at once — default all |
435
+ | \`max_turns: 20\` | the most model turns before the step is stopped, beside \`budget:\` and \`timeout:\` |
436
+ | \`limits: {web.search: 10}\` | this step's call limits, over the agent's key by key |
359
437
 
360
438
  ## Writing an agent
361
439
 
@@ -369,14 +447,17 @@ description: Finds and summarises sources.
369
447
  model: default # fast | default | max
370
448
  effort: low # how hard to think about it
371
449
  size: large # small | large | heavy — the sandbox it rents
372
- tools: [WebSearch, Read, my-folder-tool] # built-ins and your own tools/, one list
450
+ tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
373
451
  disallowedTools: [Bash] # subtract from what it would otherwise have
374
452
  skills: [house-style] # from skills/
375
453
  scripts: [summarise.py] # from scripts/, each becomes a callable tool
376
454
  apis: [{ name: crm }] # an HTTP API, as a tool
377
455
  mcpServers: {} # an MCP server, as a tool
378
- agents: [writer] # colleagues this one may consult
379
- secrets: [CRM_TOKEN] # from the vault — never written in these files
456
+ agents: [writer] # colleagues this one may consult (an answer, no tools)
457
+ subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
458
+ secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
459
+ limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
460
+ timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
380
461
  provider: {} # BYOK: your own model credential
381
462
  permissionMode: plan # plan first, act once approved
382
463
  runtime: # only if scripts need packages
@@ -389,6 +470,10 @@ You research a topic and report what you found...
389
470
  Only \`name\` and the prose are required. Every other field widens or narrows
390
471
  what the agent can reach, and the default is narrow.
391
472
 
473
+ \`timezone:\` is nearest-wins all the way down: the agent's own, else its
474
+ flow's, else the workspace's AGENTS.md, else the account's, else UTC. It sets
475
+ \`TZ\` and the date the agent is told, so a Sydney desk stamps the Sydney date.
476
+
392
477
  \`runtime:\` installs pip/npm packages in the sandbox each agent runs in. Pin
393
478
  versions the normal way (\`pandas>=2\`, \`lodash@^4\`). Python and Node are
394
479
  supported; anything else should ship as a committed binary.