@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
@@ -22,6 +22,7 @@ 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";
25
26
  /**
26
27
  * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
27
28
  * `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
@@ -75,20 +76,83 @@ function secretsFile(tenant, workspace) {
75
76
  return path.join(base, "secrets.json");
76
77
  }
77
78
  function read(tenant, workspace) {
78
- const f = secretsFile(tenant, workspace);
79
- 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))
80
99
  return {};
81
100
  try {
82
- return JSON.parse(fs.readFileSync(f, "utf8"));
101
+ return JSON.parse(fs.readFileSync(file, "utf8"));
83
102
  }
84
103
  catch {
85
104
  return {};
86
105
  }
87
106
  }
88
- function write(tenant, workspace, data) {
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 });
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
+ }
92
156
  }
93
157
  export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
94
158
  export function setSecret(tenant, name, value, workspace, kind) {
@@ -98,13 +162,14 @@ export function setSecret(tenant, name, value, workspace, kind) {
98
162
  if (value.length > 8192)
99
163
  throw new Error("secret too long");
100
164
  const sealed = encryptValue(tenant, value);
101
- const all = read(tenant, workspace);
102
- all[name] = {
103
- ...sealed,
104
- updatedAt: new Date().toISOString(),
105
- ...(kind ? { kind } : {}),
106
- };
107
- 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
+ });
108
173
  }
109
174
  /**
110
175
  * Which key writes this account's records.
@@ -190,9 +255,12 @@ export function getSecret(tenant, name, workspace) {
190
255
  return shared === null ? null : { value: shared, scope: "account" };
191
256
  }
192
257
  export function deleteSecret(tenant, name, workspace) {
193
- const all = read(tenant, workspace);
194
- delete all[name];
195
- 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
+ });
196
264
  }
197
265
  // Metadata only — never values. Without a workspace this lists the account
198
266
  // store; with one it lists what that workspace actually resolves, marking
@@ -288,47 +356,47 @@ export function rotateMasterKey(tenant, from, to) {
288
356
  const rewritten = [];
289
357
  const unreadable = [];
290
358
  for (const { file } of secretFiles(tenant)) {
291
- const all = JSON.parse(fs.readFileSync(file, "utf8"));
292
- const next = {};
293
359
  let count = 0;
294
- for (const [name, rec] of Object.entries(all)) {
295
- // A record under the ACCOUNT's key is not under the install key, so
296
- // rotating the install key must not touch it — re-encrypting it here
297
- // with a key it was never sealed with is how a rotation would destroy a
298
- // vault it was meant to protect. Rotating the install key now re-wraps
299
- // the account keys instead, which is a handful of small values rather
300
- // than every secret on the box.
301
- if (rec.k === "tenant") {
302
- next[name] = rec;
303
- 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++;
304
397
  }
305
- let plain;
306
- try {
307
- const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
308
- d.setAuthTag(Buffer.from(rec.tag, "base64"));
309
- plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
310
- }
311
- catch {
312
- unreadable.push(name);
313
- next[name] = rec; // untouched
314
- continue;
315
- }
316
- const iv = crypto.randomBytes(12);
317
- const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
318
- const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
319
- next[name] = {
320
- iv: iv.toString("base64"),
321
- tag: c.getAuthTag().toString("base64"),
322
- data: enc.toString("base64"),
323
- updatedAt: rec.updatedAt, // rotation is not an edit
324
- };
325
- count++;
326
- }
327
- // Write via a temp file in the same directory: a half-written secrets.json
328
- // is an account that cannot authenticate anything.
329
- const tmp = `${file}.rotating`;
330
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
331
- fs.renameSync(tmp, file);
398
+ return next;
399
+ });
332
400
  rewritten.push({ file, secrets: count });
333
401
  }
334
402
  return { rewritten, unreadable };
@@ -372,12 +440,12 @@ async function exchange(config, cacheKey) {
372
440
  client_secret: config.client_secret,
373
441
  ...(config.extra ?? {}),
374
442
  });
375
- const res = await fetch(config.token_url, {
443
+ const res = await fetchUntrusted(config.token_url, {
376
444
  method: "POST",
377
445
  headers: { "content-type": "application/x-www-form-urlencoded" },
378
446
  body: body.toString(),
379
447
  signal: AbortSignal.timeout(15_000),
380
- });
448
+ }, { sameOrigin: true });
381
449
  const payload = (await res.json().catch(() => ({})));
382
450
  if (!res.ok || !payload.access_token) {
383
451
  throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
@@ -499,7 +567,7 @@ async function exchangeServiceAccount(config, cacheKey) {
499
567
  signer.update(`${header}.${claim}`);
500
568
  const signature = base64url(signer.sign(config.private_key));
501
569
  const jwt = `${header}.${claim}.${signature}`;
502
- const res = await fetch(config.token_url, {
570
+ const res = await fetchUntrusted(config.token_url, {
503
571
  method: "POST",
504
572
  headers: { "content-type": "application/x-www-form-urlencoded" },
505
573
  body: new URLSearchParams({
@@ -507,7 +575,7 @@ async function exchangeServiceAccount(config, cacheKey) {
507
575
  assertion: jwt,
508
576
  }).toString(),
509
577
  signal: AbortSignal.timeout(15_000),
510
- });
578
+ }, { sameOrigin: true });
511
579
  const payload = (await res.json().catch(() => ({})));
512
580
  if (!res.ok || !payload.access_token) {
513
581
  throw new Error(`service account exchange failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token"}`);
@@ -626,33 +694,34 @@ export function rewrapTenantSecrets(tenant) {
626
694
  let moved = 0;
627
695
  const unreadable = [];
628
696
  for (const { file } of secretFiles(tenant)) {
629
- const all = JSON.parse(fs.readFileSync(file, "utf8"));
630
- const plain = new Map();
631
- let pending = 0;
632
- for (const [name, rec] of Object.entries(all)) {
633
- if (rec.k === "tenant")
634
- continue; // already moved
635
- const value = decrypt(tenant, rec);
636
- if (value === null) {
637
- unreadable.push(name);
638
- 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);
639
711
  }
640
- plain.set(name, value);
641
- pending += 1;
642
- }
643
- // Nothing to do, or something could not be read — leave the file alone.
644
- // A partial conversion is worse than none, because the half that moved is
645
- // no longer openable by the key the other half still needs.
646
- if (pending === 0 || unreadable.length)
647
- continue;
648
- const next = { ...all };
649
- for (const [name, value] of plain) {
650
- next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
651
- }
652
- const tmp = `${file}.rewrap`;
653
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
654
- fs.renameSync(tmp, file);
655
- 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
+ });
656
725
  }
657
726
  return { moved, unreadable };
658
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 |
@@ -344,6 +417,7 @@ Indented under a step, any of:
344
417
  | \`each: items\` | run once per element of the previous step's JSON array |
345
418
  | \`parallel: 3\` | how many \`each:\` instances run at once — default all |
346
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 |
347
421
 
348
422
  ## Writing an agent
349
423
 
@@ -357,14 +431,17 @@ description: Finds and summarises sources.
357
431
  model: default # fast | default | max
358
432
  effort: low # how hard to think about it
359
433
  size: large # small | large | heavy — the sandbox it rents
360
- 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
361
435
  disallowedTools: [Bash] # subtract from what it would otherwise have
362
436
  skills: [house-style] # from skills/
363
437
  scripts: [summarise.py] # from scripts/, each becomes a callable tool
364
438
  apis: [{ name: crm }] # an HTTP API, as a tool
365
439
  mcpServers: {} # an MCP server, as a tool
366
- agents: [writer] # colleagues this one may consult
367
- 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
368
445
  provider: {} # BYOK: your own model credential
369
446
  permissionMode: plan # plan first, act once approved
370
447
  runtime: # only if scripts need packages
@@ -377,6 +454,10 @@ You research a topic and report what you found...
377
454
  Only \`name\` and the prose are required. Every other field widens or narrows
378
455
  what the agent can reach, and the default is narrow.
379
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
+
380
461
  \`runtime:\` installs pip/npm packages in the sandbox each agent runs in. Pin
381
462
  versions the normal way (\`pandas>=2\`, \`lodash@^4\`). Python and Node are
382
463
  supported; anything else should ship as a committed binary.